This commit is contained in:
15
.vscode/launch.json
vendored
Normal file
15
.vscode/launch.json
vendored
Normal file
@@ -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}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -51,10 +51,12 @@ add_executable(robot_app
|
|||||||
src/io/io_module_usage.cpp
|
src/io/io_module_usage.cpp
|
||||||
src/storage/path_store.cpp
|
src/storage/path_store.cpp
|
||||||
src/storage/path_guide_store.cpp
|
src/storage/path_guide_store.cpp
|
||||||
|
src/storage/mission_run_store.cpp
|
||||||
src/path/path_planner.cpp
|
src/path/path_planner.cpp
|
||||||
src/path/path_service.cpp
|
src/path/path_service.cpp
|
||||||
src/storage/dashboard_store.cpp
|
src/storage/dashboard_store.cpp
|
||||||
src/storage/state_repository.cpp
|
src/storage/state_repository.cpp
|
||||||
|
src/settings/settings_service.cpp
|
||||||
src/validation/sensor_validator.cpp
|
src/validation/sensor_validator.cpp
|
||||||
src/server/static_file_server.cpp
|
src/server/static_file_server.cpp
|
||||||
src/server/api_server.cpp
|
src/server/api_server.cpp
|
||||||
@@ -64,6 +66,7 @@ add_executable(robot_app
|
|||||||
src/mission/mission_enqueue.cpp
|
src/mission/mission_enqueue.cpp
|
||||||
src/mission/modbus_trigger_service.cpp
|
src/mission/modbus_trigger_service.cpp
|
||||||
src/mission/mission_scheduler.cpp
|
src/mission/mission_scheduler.cpp
|
||||||
|
src/monitoring/monitoring_service.cpp
|
||||||
src/robot/robot_runtime.cpp
|
src/robot/robot_runtime.cpp
|
||||||
src/server/api_mission_routes.cpp
|
src/server/api_mission_routes.cpp
|
||||||
src/server/api_robot_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_routes.cpp
|
||||||
src/server/api_path_guide_routes.cpp
|
src/server/api_path_guide_routes.cpp
|
||||||
src/server/api_dashboard_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)
|
target_link_libraries(robot_app PRIVATE Threads::Threads SQLite::SQLite3)
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ Rail trái → flyout submenu → content (`www/nav.js`).
|
|||||||
|--------|-------------------------|---------|------------|
|
|--------|-------------------------|---------|------------|
|
||||||
| **Dashboards** | Danh sách động → `dashboard` | `dashboard.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` | 🔶 |
|
| **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` | 🔶 |
|
| **System** | integrations → `integrations` | `integrations.js` | 🔶 |
|
||||||
| **Help** | help-api → `help` | — | ⏳ |
|
| **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/<id>`
|
||||||
|
- `GET /api/monitoring/error_logs/<id>/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/<id>/actions`
|
||||||
|
- `DELETE /api/monitoring/mission_runs` (clear history)
|
||||||
|
- `DELETE /api/monitoring/mission_runs/<id>`
|
||||||
|
- `GET /api/monitoring/mission_runs/<id>/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).
|
**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 — ⏳
|
## 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
|
## 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
|
🔶 Đ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
|
❌ 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`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
#include "mission/mission_scheduler.hpp"
|
#include "mission/mission_scheduler.hpp"
|
||||||
#include "mission/mission_store.hpp"
|
#include "mission/mission_store.hpp"
|
||||||
#include "mission/modbus_trigger_service.hpp"
|
#include "mission/modbus_trigger_service.hpp"
|
||||||
|
#include "settings/settings_service.hpp"
|
||||||
|
#include "monitoring/monitoring_service.hpp"
|
||||||
#include "robot/robot_runtime.hpp"
|
#include "robot/robot_runtime.hpp"
|
||||||
#include "server/api_server.hpp"
|
#include "server/api_server.hpp"
|
||||||
#include "server/static_file_server.hpp"
|
#include "server/static_file_server.hpp"
|
||||||
@@ -77,7 +79,11 @@ int RobotApp::run()
|
|||||||
MissionStore mission_store(database);
|
MissionStore mission_store(database);
|
||||||
MissionQueue mission_queue(database, map_store, transition_store, mission_store, io_module_service, io_zone_runtime,
|
MissionQueue mission_queue(database, map_store, transition_store, mission_store, io_module_service, io_zone_runtime,
|
||||||
path_service);
|
path_service);
|
||||||
|
SettingsService settings(database);
|
||||||
|
MonitoringService monitoring(database);
|
||||||
RobotRuntime robot_runtime(database, mission_queue);
|
RobotRuntime robot_runtime(database, mission_queue);
|
||||||
|
robot_runtime.setMonitoring(&monitoring);
|
||||||
|
mission_queue.setMonitoring(&monitoring);
|
||||||
SiteStore site_store(database);
|
SiteStore site_store(database);
|
||||||
site_store.ensureDefaultSiteId();
|
site_store.ensureDefaultSiteId();
|
||||||
SoundStore sound_store(database);
|
SoundStore sound_store(database);
|
||||||
@@ -120,7 +126,9 @@ int RobotApp::run()
|
|||||||
path_store,
|
path_store,
|
||||||
path_guide_store,
|
path_guide_store,
|
||||||
path_service,
|
path_service,
|
||||||
dashboard_store);
|
dashboard_store,
|
||||||
|
monitoring,
|
||||||
|
settings);
|
||||||
api.registerRoutes(svr);
|
api.registerRoutes(svr);
|
||||||
auth.registerRoutes(svr);
|
auth.registerRoutes(svr);
|
||||||
StaticFileServer::mount(svr, www_root_);
|
StaticFileServer::mount(svr, www_root_);
|
||||||
|
|||||||
@@ -225,7 +225,8 @@ std::optional<std::string> AuthService::resourceForApiPath(const std::string& pa
|
|||||||
return "missions";
|
return "missions";
|
||||||
if (path.rfind("/api/triggers", 0) == 0 || path.rfind("/api/schedules", 0) == 0 ||
|
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/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";
|
return "integrations";
|
||||||
if (path.rfind("/api/dashboards", 0) == 0)
|
if (path.rfind("/api/dashboards", 0) == 0)
|
||||||
return "dashboard";
|
return "dashboard";
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include "io/io_module_service.hpp"
|
#include "io/io_module_service.hpp"
|
||||||
#include "io/io_zone_runtime.hpp"
|
#include "io/io_zone_runtime.hpp"
|
||||||
|
#include "monitoring/monitoring_service.hpp"
|
||||||
#include "path/path_service.hpp"
|
#include "path/path_service.hpp"
|
||||||
#include "mission/mission_store.hpp"
|
#include "mission/mission_store.hpp"
|
||||||
#include "mission/position_resolver.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"));
|
setRunnerState("idle", "Hoàn thành: " + entry.value("mission_name", "Mission"));
|
||||||
saveUnlocked();
|
saveUnlocked();
|
||||||
}
|
}
|
||||||
|
if (monitoring_)
|
||||||
|
{
|
||||||
|
std::string mon_err;
|
||||||
|
monitoring_->recordMissionRun(entry, mon_err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (const MissionCancelled&)
|
catch (const MissionCancelled&)
|
||||||
{
|
{
|
||||||
@@ -501,9 +507,15 @@ void MissionQueue::runMissionActions(nlohmann::json& entry)
|
|||||||
setRunnerState("idle", "Đã hủy: " + entry.value("mission_name", "Mission"));
|
setRunnerState("idle", "Đã hủy: " + entry.value("mission_name", "Mission"));
|
||||||
saveUnlocked();
|
saveUnlocked();
|
||||||
}
|
}
|
||||||
|
if (monitoring_)
|
||||||
|
{
|
||||||
|
std::string mon_err;
|
||||||
|
monitoring_->recordMissionRun(entry, mon_err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (...)
|
catch (...)
|
||||||
{
|
{
|
||||||
|
entry["log"] = log;
|
||||||
entry["status"] = "failed";
|
entry["status"] = "failed";
|
||||||
entry["finished_at"] = IdUtil::nowIso8601();
|
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"));
|
setRunnerState("error", "Lỗi khi chạy: " + entry.value("mission_name", "Mission"));
|
||||||
saveUnlocked();
|
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;
|
cancel_ = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class IoModuleService;
|
|||||||
class IoZoneRuntime;
|
class IoZoneRuntime;
|
||||||
class MapStore;
|
class MapStore;
|
||||||
class MissionStore;
|
class MissionStore;
|
||||||
|
class MonitoringService;
|
||||||
class PathService;
|
class PathService;
|
||||||
class TransitionStore;
|
class TransitionStore;
|
||||||
|
|
||||||
@@ -46,6 +47,8 @@ public:
|
|||||||
bool resume(std::string& err);
|
bool resume(std::string& err);
|
||||||
bool cancel(std::string& err);
|
bool cancel(std::string& err);
|
||||||
|
|
||||||
|
void setMonitoring(MonitoringService* monitoring) { monitoring_ = monitoring; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
enum class LoopControl { None, Break, Continue };
|
enum class LoopControl { None, Break, Continue };
|
||||||
|
|
||||||
@@ -56,6 +59,7 @@ private:
|
|||||||
IoModuleService& io_modules_;
|
IoModuleService& io_modules_;
|
||||||
IoZoneRuntime& io_zones_;
|
IoZoneRuntime& io_zones_;
|
||||||
PathService& paths_;
|
PathService& paths_;
|
||||||
|
MonitoringService* monitoring_ = nullptr;
|
||||||
PositionResolver position_resolver_;
|
PositionResolver position_resolver_;
|
||||||
mutable std::recursive_mutex mu_;
|
mutable std::recursive_mutex mu_;
|
||||||
nlohmann::json queue_;
|
nlohmann::json queue_;
|
||||||
|
|||||||
577
src/monitoring/monitoring_service.cpp
Normal file
577
src/monitoring/monitoring_service.cpp
Normal file
@@ -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 <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <ctime>
|
||||||
|
#include <iomanip>
|
||||||
|
#include <sstream>
|
||||||
|
|
||||||
|
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<std::time_t>(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<int>(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<double>();
|
||||||
|
}
|
||||||
|
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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<double>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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<std::mutex> 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<std::mutex> 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<nlohmann::json> MonitoringService::errorLogById(const std::string& id) const
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<nlohmann::json> MonitoringService::missionRun(const std::string& run_id) const
|
||||||
|
{
|
||||||
|
MissionRunStore store(db_);
|
||||||
|
return store.findRun(run_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<nlohmann::json> 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
|
||||||
68
src/monitoring/monitoring_service.hpp
Normal file
68
src/monitoring/monitoring_service.hpp
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <mutex>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
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<nlohmann::json> missionRun(const std::string& run_id) const;
|
||||||
|
std::optional<nlohmann::json> missionRunActions(const std::string& run_id) const;
|
||||||
|
bool deleteMissionRun(const std::string& run_id, std::string& err);
|
||||||
|
bool clearMissionRuns(std::string& err);
|
||||||
|
|
||||||
|
std::optional<nlohmann::json> 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
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
#include "robot/robot_runtime.hpp"
|
#include "robot/robot_runtime.hpp"
|
||||||
|
|
||||||
#include "mission/mission_queue.hpp"
|
#include "mission/mission_queue.hpp"
|
||||||
|
#include "monitoring/monitoring_service.hpp"
|
||||||
#include "storage/database.hpp"
|
#include "storage/database.hpp"
|
||||||
#include "util/id_util.hpp"
|
#include "util/id_util.hpp"
|
||||||
|
|
||||||
@@ -17,7 +18,7 @@ constexpr const char* kDefaultMessage = "Waiting for new missions...";
|
|||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
RobotRuntime::RobotRuntime(Database& db, MissionQueue& mission_queue)
|
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();
|
load();
|
||||||
ensureDefaultsUnlocked();
|
ensureDefaultsUnlocked();
|
||||||
@@ -67,6 +68,8 @@ void RobotRuntime::ensureDefaultsUnlocked()
|
|||||||
state_["active_map_id"] = nullptr;
|
state_["active_map_id"] = nullptr;
|
||||||
if (!state_.contains("pose") || !state_["pose"].is_object())
|
if (!state_.contains("pose") || !state_["pose"].is_object())
|
||||||
state_["pose"] = {{"x", 0.0}, {"y", 0.0}, {"yaw", 0.0}};
|
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();
|
saveUnlocked();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +113,7 @@ nlohmann::json RobotRuntime::buildStatusUnlocked() const
|
|||||||
{"cmd_angular", state_.value("cmd_angular", 0.0)},
|
{"cmd_angular", state_.value("cmd_angular", 0.0)},
|
||||||
{"active_map_id", state_.contains("active_map_id") ? state_["active_map_id"] : nullptr},
|
{"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}})},
|
{"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},
|
{"runner", runner},
|
||||||
{"queue_pending", pending},
|
{"queue_pending", pending},
|
||||||
{"updated_at", state_.value("updated_at", "")}};
|
{"updated_at", state_.value("updated_at", "")}};
|
||||||
@@ -135,6 +139,8 @@ bool RobotRuntime::start(std::string& err)
|
|||||||
state_["message"] = "Robot running";
|
state_["message"] = "Robot running";
|
||||||
state_["updated_at"] = IdUtil::nowIso8601();
|
state_["updated_at"] = IdUtil::nowIso8601();
|
||||||
saveUnlocked();
|
saveUnlocked();
|
||||||
|
if (monitoring_)
|
||||||
|
monitoring_->logSystem("ok", "Robot", "Robot started");
|
||||||
|
|
||||||
const std::string runner_state = mission_queue_.runnerStatus().value("state", "idle");
|
const std::string runner_state = mission_queue_.runnerStatus().value("state", "idle");
|
||||||
if (runner_state == "paused")
|
if (runner_state == "paused")
|
||||||
@@ -152,6 +158,8 @@ bool RobotRuntime::pause(std::string& err)
|
|||||||
state_["cmd_angular"] = 0.0;
|
state_["cmd_angular"] = 0.0;
|
||||||
state_["updated_at"] = IdUtil::nowIso8601();
|
state_["updated_at"] = IdUtil::nowIso8601();
|
||||||
saveUnlocked();
|
saveUnlocked();
|
||||||
|
if (monitoring_)
|
||||||
|
monitoring_->logSystem("warn", "Robot", "Robot paused");
|
||||||
|
|
||||||
const std::string runner_state = mission_queue_.runnerStatus().value("state", "idle");
|
const std::string runner_state = mission_queue_.runnerStatus().value("state", "idle");
|
||||||
if (runner_state == "running" || runner_state == "paused")
|
if (runner_state == "running" || runner_state == "paused")
|
||||||
@@ -168,6 +176,8 @@ bool RobotRuntime::resetError(std::string& err)
|
|||||||
state_["message"] = kDefaultMessage;
|
state_["message"] = kDefaultMessage;
|
||||||
state_["updated_at"] = IdUtil::nowIso8601();
|
state_["updated_at"] = IdUtil::nowIso8601();
|
||||||
saveUnlocked();
|
saveUnlocked();
|
||||||
|
if (monitoring_)
|
||||||
|
monitoring_->logSystem("ok", "Robot", "Error state cleared");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,6 +298,11 @@ bool RobotRuntime::queueSoundPlay(const std::string& sound_id, int volume, std::
|
|||||||
void RobotRuntime::tick()
|
void RobotRuntime::tick()
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(mu_);
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
const auto now = std::chrono::steady_clock::now();
|
||||||
|
double dt = std::chrono::duration<double>(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 running = state_.value("motion", "paused") == "running";
|
||||||
const bool joy = state_.value("joystick_engaged", false);
|
const bool joy = state_.value("joystick_engaged", false);
|
||||||
double battery = state_.value("battery_percent", 54.0);
|
double battery = state_.value("battery_percent", 54.0);
|
||||||
@@ -300,6 +315,13 @@ void RobotRuntime::tick()
|
|||||||
battery = std::min(100.0, battery + 0.005);
|
battery = std::min(100.0, battery + 0.005);
|
||||||
|
|
||||||
state_["battery_percent"] = static_cast<int>(std::lround(battery));
|
state_["battery_percent"] = static_cast<int>(std::lround(battery));
|
||||||
|
|
||||||
|
if (monitoring_)
|
||||||
|
{
|
||||||
|
nlohmann::json status = buildStatusUnlocked();
|
||||||
|
monitoring_->onRobotTick(status, dt);
|
||||||
|
}
|
||||||
|
|
||||||
saveUnlocked();
|
saveUnlocked();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include <nlohmann/json.hpp>
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
@@ -9,12 +10,15 @@ namespace lm {
|
|||||||
|
|
||||||
class Database;
|
class Database;
|
||||||
class MissionQueue;
|
class MissionQueue;
|
||||||
|
class MonitoringService;
|
||||||
|
|
||||||
class RobotRuntime
|
class RobotRuntime
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
explicit RobotRuntime(Database& db, MissionQueue& mission_queue);
|
explicit RobotRuntime(Database& db, MissionQueue& mission_queue);
|
||||||
|
|
||||||
|
void setMonitoring(MonitoringService* monitoring) { monitoring_ = monitoring; }
|
||||||
|
|
||||||
nlohmann::json status() const;
|
nlohmann::json status() const;
|
||||||
bool start(std::string& err);
|
bool start(std::string& err);
|
||||||
bool pause(std::string& err);
|
bool pause(std::string& err);
|
||||||
@@ -29,8 +33,10 @@ public:
|
|||||||
private:
|
private:
|
||||||
Database& db_;
|
Database& db_;
|
||||||
MissionQueue& mission_queue_;
|
MissionQueue& mission_queue_;
|
||||||
|
MonitoringService* monitoring_ = nullptr;
|
||||||
mutable std::mutex mu_;
|
mutable std::mutex mu_;
|
||||||
nlohmann::json state_;
|
nlohmann::json state_;
|
||||||
|
std::chrono::steady_clock::time_point last_tick_;
|
||||||
|
|
||||||
void load();
|
void load();
|
||||||
void saveUnlocked() const;
|
void saveUnlocked() const;
|
||||||
|
|||||||
141
src/server/api_monitoring_routes.cpp
Normal file
141
src/server/api_monitoring_routes.cpp
Normal file
@@ -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
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
#include "domain/layout_profile.hpp"
|
#include "domain/layout_profile.hpp"
|
||||||
#include "domain/layout_schema.hpp"
|
#include "domain/layout_schema.hpp"
|
||||||
#include "mission/mission_enqueue.hpp"
|
#include "mission/mission_enqueue.hpp"
|
||||||
|
#include "monitoring/monitoring_service.hpp"
|
||||||
#include "util/http_util.hpp"
|
#include "util/http_util.hpp"
|
||||||
#include "util/id_util.hpp"
|
#include "util/id_util.hpp"
|
||||||
#include "util/string_util.hpp"
|
#include "util/string_util.hpp"
|
||||||
@@ -25,7 +26,9 @@ ApiServer::ApiServer(StateRepository& repo,
|
|||||||
PathStore& path_store,
|
PathStore& path_store,
|
||||||
PathGuideStore& path_guide_store,
|
PathGuideStore& path_guide_store,
|
||||||
PathService& path_service,
|
PathService& path_service,
|
||||||
DashboardStore& dashboard_store)
|
DashboardStore& dashboard_store,
|
||||||
|
MonitoringService& monitoring,
|
||||||
|
SettingsService& settings)
|
||||||
: repo_(repo),
|
: repo_(repo),
|
||||||
mission_queue_(mission_queue),
|
mission_queue_(mission_queue),
|
||||||
mission_store_(mission_store),
|
mission_store_(mission_store),
|
||||||
@@ -41,7 +44,9 @@ ApiServer::ApiServer(StateRepository& repo,
|
|||||||
path_store_(path_store),
|
path_store_(path_store),
|
||||||
path_guide_store_(path_guide_store),
|
path_guide_store_(path_guide_store),
|
||||||
path_service_(path_service),
|
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);
|
registerPathRoutes(svr);
|
||||||
registerPathGuideRoutes(svr);
|
registerPathGuideRoutes(svr);
|
||||||
registerDashboardRoutes(svr);
|
registerDashboardRoutes(svr);
|
||||||
|
registerMonitoringRoutes(svr);
|
||||||
|
registerSettingsRoutes(svr);
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace lm
|
} // namespace lm
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
#include "robot/robot_runtime.hpp"
|
#include "robot/robot_runtime.hpp"
|
||||||
#include "storage/dashboard_store.hpp"
|
#include "storage/dashboard_store.hpp"
|
||||||
#include "storage/map_store.hpp"
|
#include "storage/map_store.hpp"
|
||||||
|
#include "monitoring/monitoring_service.hpp"
|
||||||
|
#include "settings/settings_service.hpp"
|
||||||
#include "storage/site_store.hpp"
|
#include "storage/site_store.hpp"
|
||||||
#include "storage/sound_store.hpp"
|
#include "storage/sound_store.hpp"
|
||||||
#include "storage/io_module_store.hpp"
|
#include "storage/io_module_store.hpp"
|
||||||
@@ -39,7 +41,9 @@ public:
|
|||||||
PathStore& path_store,
|
PathStore& path_store,
|
||||||
PathGuideStore& path_guide_store,
|
PathGuideStore& path_guide_store,
|
||||||
PathService& path_service,
|
PathService& path_service,
|
||||||
DashboardStore& dashboard_store);
|
DashboardStore& dashboard_store,
|
||||||
|
MonitoringService& monitoring,
|
||||||
|
SettingsService& settings);
|
||||||
|
|
||||||
void registerRoutes(httplib::Server& svr);
|
void registerRoutes(httplib::Server& svr);
|
||||||
|
|
||||||
@@ -60,6 +64,8 @@ private:
|
|||||||
PathGuideStore& path_guide_store_;
|
PathGuideStore& path_guide_store_;
|
||||||
PathService& path_service_;
|
PathService& path_service_;
|
||||||
DashboardStore& dashboard_store_;
|
DashboardStore& dashboard_store_;
|
||||||
|
MonitoringService& monitoring_;
|
||||||
|
SettingsService& settings_;
|
||||||
|
|
||||||
bool enqueueRequest(const nlohmann::json& request, httplib::Response& res, int status_code = 201);
|
bool enqueueRequest(const nlohmann::json& request, httplib::Response& res, int status_code = 201);
|
||||||
std::optional<nlohmann::json> enqueueMission(const nlohmann::json& request, std::string& err);
|
std::optional<nlohmann::json> enqueueMission(const nlohmann::json& request, std::string& err);
|
||||||
@@ -74,6 +80,8 @@ private:
|
|||||||
void registerPathRoutes(httplib::Server& svr);
|
void registerPathRoutes(httplib::Server& svr);
|
||||||
void registerPathGuideRoutes(httplib::Server& svr);
|
void registerPathGuideRoutes(httplib::Server& svr);
|
||||||
void registerDashboardRoutes(httplib::Server& svr);
|
void registerDashboardRoutes(httplib::Server& svr);
|
||||||
|
void registerMonitoringRoutes(httplib::Server& svr);
|
||||||
|
void registerSettingsRoutes(httplib::Server& svr);
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace lm
|
} // namespace lm
|
||||||
|
|||||||
35
src/server/api_settings_routes.cpp
Normal file
35
src/server/api_settings_routes.cpp
Normal file
@@ -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
|
||||||
|
|
||||||
110
src/settings/settings_service.cpp
Normal file
110
src/settings/settings_service.cpp
Normal file
@@ -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<std::mutex> 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<std::mutex> 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<std::mutex> lock(mu_);
|
||||||
|
return clampInt(settings_.value("retention_mission_runs", 2000), 10, 20000);
|
||||||
|
}
|
||||||
|
|
||||||
|
int SettingsService::retentionSystemLog() const
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
return clampInt(settings_.value("retention_system_log", 2000), 100, 20000);
|
||||||
|
}
|
||||||
|
|
||||||
|
int SettingsService::retentionErrorLogs() const
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
return clampInt(settings_.value("retention_error_logs", 200), 10, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string SettingsService::logLevel() const
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
return settings_.value("log_level", std::string("info"));
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
|
|
||||||
37
src/settings/settings_service.hpp
Normal file
37
src/settings/settings_service.hpp
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
|
#include <mutex>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
@@ -185,6 +185,29 @@ CREATE TABLE IF NOT EXISTS dashboard_state (
|
|||||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
active_dashboard_id TEXT
|
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";
|
)SQL";
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
@@ -508,6 +531,41 @@ bool Database::applySchemaMigrations(std::string& err)
|
|||||||
setMeta("schema_version", "9");
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
385
src/storage/mission_run_store.cpp
Normal file
385
src/storage/mission_run_store.cpp
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
#include "storage/mission_run_store.hpp"
|
||||||
|
|
||||||
|
#include "storage/database.hpp"
|
||||||
|
#include "util/id_util.hpp"
|
||||||
|
|
||||||
|
#include <sqlite3.h>
|
||||||
|
|
||||||
|
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<std::string>();
|
||||||
|
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<std::string>() : "";
|
||||||
|
const std::string finished_at =
|
||||||
|
queue_entry.contains("finished_at") && queue_entry["finished_at"].is_string() ? queue_entry["finished_at"].get<std::string>() : "";
|
||||||
|
|
||||||
|
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<std::mutex> 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<std::string>() : "";
|
||||||
|
|
||||||
|
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<std::mutex> 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<const char*>(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<nlohmann::json> MissionRunStore::findRun(const std::string& run_id) const
|
||||||
|
{
|
||||||
|
sqlite3* db = db_.handle();
|
||||||
|
if (!db || run_id.empty())
|
||||||
|
return std::nullopt;
|
||||||
|
|
||||||
|
std::lock_guard<std::mutex> 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<const char*>(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<nlohmann::json> MissionRunStore::actionsForRun(const std::string& run_id) const
|
||||||
|
{
|
||||||
|
sqlite3* db = db_.handle();
|
||||||
|
if (!db)
|
||||||
|
return std::nullopt;
|
||||||
|
|
||||||
|
std::lock_guard<std::mutex> 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<const char*>(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<std::mutex> 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<std::mutex> 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
|
||||||
|
|
||||||
31
src/storage/mission_run_store.hpp
Normal file
31
src/storage/mission_run_store.hpp
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
|
#include <mutex>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
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<nlohmann::json> findRun(const std::string& run_id) const;
|
||||||
|
std::optional<nlohmann::json> 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
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ const pageConfigEl = el("pageConfig");
|
|||||||
const pageMapsEl = el("pageMaps");
|
const pageMapsEl = el("pageMaps");
|
||||||
const pageMissionsEl = el("pageMissions");
|
const pageMissionsEl = el("pageMissions");
|
||||||
const pageIntegrationsEl = el("pageIntegrations");
|
const pageIntegrationsEl = el("pageIntegrations");
|
||||||
|
const pageSettingsEl = el("pageSettings");
|
||||||
const pageSoundsEl = el("pageSounds");
|
const pageSoundsEl = el("pageSounds");
|
||||||
const pageTransitionsEl = el("pageTransitions");
|
const pageTransitionsEl = el("pageTransitions");
|
||||||
const pageUsersEl = el("pageUsers");
|
const pageUsersEl = el("pageUsers");
|
||||||
@@ -131,7 +132,7 @@ const state = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function setActivePage(page) {
|
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";
|
let p = valid.includes(page) ? page : "missions";
|
||||||
if (window.AuthApp && !window.AuthApp.canAccessPage(p)) {
|
if (window.AuthApp && !window.AuthApp.canAccessPage(p)) {
|
||||||
const fallback = valid.find((v) => window.AuthApp.canAccessPage(v));
|
const fallback = valid.find((v) => window.AuthApp.canAccessPage(v));
|
||||||
@@ -149,6 +150,7 @@ function setActivePage(page) {
|
|||||||
if (pagePathsEl) pagePathsEl.hidden = p !== "paths";
|
if (pagePathsEl) pagePathsEl.hidden = p !== "paths";
|
||||||
if (pagePathGuidesEl) pagePathGuidesEl.hidden = p !== "path-guides";
|
if (pagePathGuidesEl) pagePathGuidesEl.hidden = p !== "path-guides";
|
||||||
if (pageUsersEl) pageUsersEl.hidden = p !== "users";
|
if (pageUsersEl) pageUsersEl.hidden = p !== "users";
|
||||||
|
if (pageSettingsEl) pageSettingsEl.hidden = p !== "settings";
|
||||||
if (pageIntegrationsEl) pageIntegrationsEl.hidden = p !== "integrations";
|
if (pageIntegrationsEl) pageIntegrationsEl.hidden = p !== "integrations";
|
||||||
if (pageMonitoringEl) pageMonitoringEl.hidden = p !== "monitoring";
|
if (pageMonitoringEl) pageMonitoringEl.hidden = p !== "monitoring";
|
||||||
if (pageHelpEl) pageHelpEl.hidden = p !== "help";
|
if (pageHelpEl) pageHelpEl.hidden = p !== "help";
|
||||||
@@ -166,6 +168,7 @@ function setActivePage(page) {
|
|||||||
contentEl.classList.toggle("content--paths", p === "paths");
|
contentEl.classList.toggle("content--paths", p === "paths");
|
||||||
contentEl.classList.toggle("content--path-guides", p === "path-guides");
|
contentEl.classList.toggle("content--path-guides", p === "path-guides");
|
||||||
contentEl.classList.toggle("content--users", p === "users");
|
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--integrations", p === "integrations");
|
||||||
contentEl.classList.toggle("content--monitoring", p === "monitoring");
|
contentEl.classList.toggle("content--monitoring", p === "monitoring");
|
||||||
contentEl.classList.toggle("content--help", p === "help");
|
contentEl.classList.toggle("content--help", p === "help");
|
||||||
@@ -191,6 +194,10 @@ function setActivePage(page) {
|
|||||||
else if (window.DashboardApp?.onPageHide) window.DashboardApp.onPageHide();
|
else if (window.DashboardApp?.onPageHide) window.DashboardApp.onPageHide();
|
||||||
if (p === "integrations" && window.IntegrationsApp) window.IntegrationsApp.onPageShow();
|
if (p === "integrations" && window.IntegrationsApp) window.IntegrationsApp.onPageShow();
|
||||||
else if (window.IntegrationsApp?.onPageHide) window.IntegrationsApp.onPageHide();
|
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);
|
window.NavApp?.syncFromPage?.(p);
|
||||||
try {
|
try {
|
||||||
localStorage.setItem("activePage", p);
|
localStorage.setItem("activePage", p);
|
||||||
|
|||||||
306
www/i18n.js
306
www/i18n.js
@@ -66,6 +66,7 @@
|
|||||||
"nav.setup": "Setup",
|
"nav.setup": "Setup",
|
||||||
"nav.monitoring": "Monitoring",
|
"nav.monitoring": "Monitoring",
|
||||||
"nav.system": "System",
|
"nav.system": "System",
|
||||||
|
"nav.settings": "Settings",
|
||||||
"nav.help": "Help",
|
"nav.help": "Help",
|
||||||
"nav.logout": "Log out",
|
"nav.logout": "Log out",
|
||||||
"nav.dashboard": "Dashboard",
|
"nav.dashboard": "Dashboard",
|
||||||
@@ -81,6 +82,11 @@
|
|||||||
"nav.users": "Users",
|
"nav.users": "Users",
|
||||||
"nav.build-robot": "Build Robot",
|
"nav.build-robot": "Build Robot",
|
||||||
"nav.monitoring-log": "System log",
|
"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.integrations": "Tích hợp",
|
||||||
"nav.help-api": "API documentation",
|
"nav.help-api": "API documentation",
|
||||||
|
|
||||||
@@ -884,9 +890,150 @@
|
|||||||
"integrations.dialog.schedule.startTime": "Thời gian bắt đầu",
|
"integrations.dialog.schedule.startTime": "Thời gian bắt đầu",
|
||||||
"integrations.schedule.runNow": "Chạy ngay",
|
"integrations.schedule.runNow": "Chạy ngay",
|
||||||
|
|
||||||
"monitoring.log.title": "System log",
|
"monitoring.comingSoon": "Tính năng đang phát triển.",
|
||||||
"monitoring.log.subtitle": "Monitoring → System log — nhật ký hệ thống (đang phát triển).",
|
"monitoring.refresh": "Làm mới",
|
||||||
"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.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.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.",
|
"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.setup": "Setup",
|
||||||
"nav.monitoring": "Monitoring",
|
"nav.monitoring": "Monitoring",
|
||||||
"nav.system": "System",
|
"nav.system": "System",
|
||||||
|
"nav.settings": "Settings",
|
||||||
"nav.help": "Help",
|
"nav.help": "Help",
|
||||||
"nav.logout": "Log out",
|
"nav.logout": "Log out",
|
||||||
"nav.dashboard": "Dashboard",
|
"nav.dashboard": "Dashboard",
|
||||||
@@ -969,6 +1117,11 @@
|
|||||||
"nav.users": "Users",
|
"nav.users": "Users",
|
||||||
"nav.build-robot": "Build Robot",
|
"nav.build-robot": "Build Robot",
|
||||||
"nav.monitoring-log": "System log",
|
"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.integrations": "Integrations",
|
||||||
"nav.help-api": "API documentation",
|
"nav.help-api": "API documentation",
|
||||||
|
|
||||||
@@ -1772,9 +1925,150 @@
|
|||||||
"integrations.dialog.schedule.startTime": "Start time",
|
"integrations.dialog.schedule.startTime": "Start time",
|
||||||
"integrations.schedule.runNow": "Run now",
|
"integrations.schedule.runNow": "Run now",
|
||||||
|
|
||||||
"monitoring.log.title": "System log",
|
"monitoring.comingSoon": "Feature coming soon.",
|
||||||
"monitoring.log.subtitle": "Monitoring → System log — system log (coming soon).",
|
"monitoring.refresh": "Refresh",
|
||||||
"monitoring.log.placeholder": "Monitoring will show robot logs, alerts and mission history here.",
|
"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.title": "API documentation",
|
||||||
"help.api.subtitle": "Help → API — MiR v2.0.0 REST reference for external integration.",
|
"help.api.subtitle": "Help → API — MiR v2.0.0 REST reference for external integration.",
|
||||||
|
|||||||
365
www/index.html
365
www/index.html
@@ -2378,18 +2378,367 @@ GET /api/v2.0.0/status</pre>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="page" id="pageMonitoring" data-page-content="monitoring" hidden>
|
<div class="page" id="pageSettings" data-page-content="settings" hidden>
|
||||||
<section class="card">
|
<div class="mapsMirPage">
|
||||||
<div class="cardHeader">
|
<header class="mapsMirHeader">
|
||||||
|
<div class="mapsMirHeaderText">
|
||||||
|
<h1 class="mapsMirTitle" data-i18n="settings.title">Settings</h1>
|
||||||
|
<p class="mapsMirSubtitle" data-i18n="settings.subtitle">System → Settings — global robot configuration.</p>
|
||||||
|
</div>
|
||||||
|
<div class="mapsMirHeaderActions">
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="settingsRefreshBtn">
|
||||||
|
<span data-i18n="monitoring.refresh">Refresh</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="settingsMirGridWrap" id="settingsGridView">
|
||||||
|
<div class="settingsMirGrid" id="settingsTileGrid"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mapsMirCard" id="settingsDetailView" hidden>
|
||||||
|
<header class="mapsMirCardHeader">
|
||||||
<div>
|
<div>
|
||||||
<div class="cardTitle" data-i18n="monitoring.log.title">System log</div>
|
<h2 class="mapsMirCardTitle" data-i18n="settings.loggingRetention.title">Logging & retention</h2>
|
||||||
<div class="cardSub" data-i18n="monitoring.log.subtitle">Monitoring → System log — nhật ký hệ thống (đang phát triển).</div>
|
<p class="mapsMirCardSub" data-i18n="settings.loggingRetention.subtitle">Tune history limits and log level.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mapsMirCardActions">
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="settingsBackBtn">
|
||||||
|
<span data-i18n="settings.back">Back</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--primary" id="settingsApplyBtn">
|
||||||
|
<span data-i18n="settings.apply">Apply</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="cardBody">
|
</header>
|
||||||
<p class="mutedNote" data-i18n="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.</p>
|
<div class="mapsMirCardBody">
|
||||||
|
<div class="mapsMirFormGrid">
|
||||||
|
<label class="mapsMirField">
|
||||||
|
<span class="mapsMirFieldLabel" data-i18n="settings.retention.missionRuns">Mission history (runs)</span>
|
||||||
|
<input type="number" min="10" max="20000" step="10" id="settingsRetentionMissionRuns" class="mapsMirInput" />
|
||||||
|
<span class="mapsMirFieldHint" data-i18n="settings.retention.missionRunsHint">Max persisted mission runs (bounded history).</span>
|
||||||
|
</label>
|
||||||
|
<label class="mapsMirField">
|
||||||
|
<span class="mapsMirFieldLabel" data-i18n="settings.retention.systemLog">System log entries</span>
|
||||||
|
<input type="number" min="100" max="20000" step="100" id="settingsRetentionSystemLog" class="mapsMirInput" />
|
||||||
|
<span class="mapsMirFieldHint" data-i18n="settings.retention.systemLogHint">Max number of system log rows stored.</span>
|
||||||
|
</label>
|
||||||
|
<label class="mapsMirField">
|
||||||
|
<span class="mapsMirFieldLabel" data-i18n="settings.retention.errorLogs">Error log entries</span>
|
||||||
|
<input type="number" min="10" max="5000" step="10" id="settingsRetentionErrorLogs" class="mapsMirInput" />
|
||||||
|
<span class="mapsMirFieldHint" data-i18n="settings.retention.errorLogsHint">Max number of error log rows stored.</span>
|
||||||
|
</label>
|
||||||
|
<label class="mapsMirField">
|
||||||
|
<span class="mapsMirFieldLabel" data-i18n="settings.logLevel">Log level</span>
|
||||||
|
<select id="settingsLogLevel" class="mapsMirSelect">
|
||||||
|
<option value="error">error</option>
|
||||||
|
<option value="warn">warn</option>
|
||||||
|
<option value="info">info</option>
|
||||||
|
<option value="debug">debug</option>
|
||||||
|
</select>
|
||||||
|
<span class="mapsMirFieldHint" data-i18n="settings.logLevelHint">Controls verbosity of system logs.</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="mapsMirNote" id="settingsStatusText" aria-live="polite"></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="mapsMirCard">
|
||||||
|
<header class="mapsMirCardHeader">
|
||||||
|
<div>
|
||||||
|
<h2 class="mapsMirCardTitle" data-i18n="settings.comingSoon.title">More settings</h2>
|
||||||
|
<p class="mapsMirCardSub" data-i18n="settings.comingSoon.subtitle">Time, network, security and backups will appear here.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="mapsMirCardBody">
|
||||||
|
<p class="mutedNote" data-i18n="monitoring.comingSoon">Feature coming soon.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page" id="pageMonitoring" data-page-content="monitoring" hidden>
|
||||||
|
<div id="monitoringAnalyticsView" class="mapsMirPage" hidden>
|
||||||
|
<header class="mapsMirHeader">
|
||||||
|
<div class="mapsMirHeaderText">
|
||||||
|
<h1 class="mapsMirTitle" data-i18n="monitoring.analytics.title">Analytics</h1>
|
||||||
|
<p class="mapsMirSubtitle">
|
||||||
|
<span data-i18n="monitoring.analytics.subtitle">Driven distance over time.</span>
|
||||||
|
<button type="button" class="mapsMirHelpBtn" id="analyticsHelpBtn" data-i18n-title="monitoring.analytics.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--outline" id="analyticsRefreshBtn">
|
||||||
|
<span data-i18n="monitoring.refresh">Refresh</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="monAnalyticsToolbar">
|
||||||
|
<div class="monAnalyticsDates">
|
||||||
|
<label class="monAnalyticsLabel" for="analyticsStartDate" data-i18n="monitoring.analytics.startDate">Start date</label>
|
||||||
|
<input type="date" id="analyticsStartDate" class="monAnalyticsInput" />
|
||||||
|
<label class="monAnalyticsLabel" for="analyticsEndDate" data-i18n="monitoring.analytics.endDate">End date</label>
|
||||||
|
<input type="date" id="analyticsEndDate" class="monAnalyticsInput" />
|
||||||
|
</div>
|
||||||
|
<div class="monAnalyticsPresets" id="analyticsPresets"></div>
|
||||||
|
<div class="monAnalyticsOptions">
|
||||||
|
<label class="monAnalyticsLabel" for="analyticsGrouping" data-i18n="monitoring.analytics.grouping">Grouping</label>
|
||||||
|
<select id="analyticsGrouping" class="monAnalyticsSelect">
|
||||||
|
<option value="day" data-i18n="monitoring.analytics.perDay">Per day</option>
|
||||||
|
<option value="month" data-i18n="monitoring.analytics.perMonth">Per month</option>
|
||||||
|
</select>
|
||||||
|
<label class="monAnalyticsLabel" for="analyticsChartMode" data-i18n="monitoring.analytics.chartType">Chart</label>
|
||||||
|
<select id="analyticsChartMode" class="monAnalyticsSelect">
|
||||||
|
<option value="bar" data-i18n="monitoring.analytics.barChart">Bar graph</option>
|
||||||
|
<option value="accumulated" data-i18n="monitoring.analytics.accumulated">Accumulated</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="monAnalyticsSummary">
|
||||||
|
<span id="analyticsTotalLabel" class="monAnalyticsTotal">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="monAnalyticsChartWrap">
|
||||||
|
<svg id="analyticsChart" class="monAnalyticsChart" viewBox="0 0 800 280" preserveAspectRatio="xMidYMid meet" role="img" aria-label="Driven distance chart"></svg>
|
||||||
|
<div id="analyticsChartEmpty" class="mapsMirEmpty" hidden data-i18n="monitoring.analytics.empty">No distance data for this period.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="monitoringSystemLogView" class="mapsMirPage" hidden>
|
||||||
|
<header class="mapsMirHeader">
|
||||||
|
<div class="mapsMirHeaderText">
|
||||||
|
<h1 class="mapsMirTitle" data-i18n="monitoring.systemLog.title">System log</h1>
|
||||||
|
<p class="mapsMirSubtitle" data-i18n="monitoring.systemLog.subtitle">Events from operating system components.</p>
|
||||||
|
</div>
|
||||||
|
<div class="mapsMirHeaderActions">
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="systemLogRefreshBtn"><span data-i18n="monitoring.refresh">Refresh</span></button>
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="systemLogClearFiltersBtn"><span data-i18n="monitoring.clearFilters">Clear filters</span></button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="mapsMirFilterBar">
|
||||||
|
<label class="mapsMirFilterLabel" for="systemLogFilterInput" data-i18n="monitoring.filterLabel">Filter:</label>
|
||||||
|
<input type="search" id="systemLogFilterInput" class="mapsMirFilterInput" data-i18n-placeholder="monitoring.systemLog.filterPlaceholder" autocomplete="off" />
|
||||||
|
<span id="systemLogFilterCount" class="mapsMirFilterCount">0</span>
|
||||||
|
<div class="mapsMirPager">
|
||||||
|
<button type="button" class="mapsMirPageBtn" id="systemLogPageFirst">«</button>
|
||||||
|
<button type="button" class="mapsMirPageBtn" id="systemLogPagePrev">‹</button>
|
||||||
|
<span id="systemLogPageLabel" class="mapsMirPageLabel">Page 1 of 1</span>
|
||||||
|
<button type="button" class="mapsMirPageBtn" id="systemLogPageNext">›</button>
|
||||||
|
<button type="button" class="mapsMirPageBtn" id="systemLogPageLast">»</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mapsMirTableWrap">
|
||||||
|
<table class="mapsMirTable mapsMirTable--systemLog" id="systemLogTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="monMirThState" data-i18n="monitoring.systemLog.colState">State</th>
|
||||||
|
<th data-i18n="monitoring.systemLog.colModule">Module</th>
|
||||||
|
<th data-i18n="monitoring.systemLog.colMessage">Message</th>
|
||||||
|
<th data-i18n="monitoring.systemLog.colTime">Time</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="systemLogList"></tbody>
|
||||||
|
</table>
|
||||||
|
<div id="systemLogListEmpty" class="mapsMirEmpty" hidden data-i18n="monitoring.systemLog.empty">No system log entries.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="monitoringErrorLogsView" class="mapsMirPage" hidden>
|
||||||
|
<header class="mapsMirHeader">
|
||||||
|
<div class="mapsMirHeaderText">
|
||||||
|
<h1 class="mapsMirTitle" data-i18n="monitoring.errorLogs.title">Error logs</h1>
|
||||||
|
<p class="mapsMirSubtitle" data-i18n="monitoring.errorLogs.subtitle">Detected system errors.</p>
|
||||||
|
</div>
|
||||||
|
<div class="mapsMirHeaderActions">
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--primary" id="errorLogsGenerateBtn"><span data-i18n="monitoring.errorLogs.generate">Generate log</span></button>
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="errorLogsDeleteAllBtn"><span data-i18n="monitoring.errorLogs.deleteAll">Delete all</span></button>
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="errorLogsRefreshBtn"><span data-i18n="monitoring.refresh">Refresh</span></button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="mapsMirFilterBar">
|
||||||
|
<label class="mapsMirFilterLabel" for="errorLogsFilterInput" data-i18n="monitoring.filterLabel">Filter:</label>
|
||||||
|
<input type="search" id="errorLogsFilterInput" class="mapsMirFilterInput" data-i18n-placeholder="monitoring.errorLogs.filterPlaceholder" autocomplete="off" />
|
||||||
|
<span id="errorLogsFilterCount" class="mapsMirFilterCount">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="mapsMirTableWrap">
|
||||||
|
<table class="mapsMirTable mapsMirTable--errorLogs" id="errorLogsTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th data-i18n="monitoring.errorLogs.colDescription">Description</th>
|
||||||
|
<th data-i18n="monitoring.errorLogs.colModule">Module</th>
|
||||||
|
<th data-i18n="monitoring.errorLogs.colTime">Time</th>
|
||||||
|
<th class="mapsMirThFunctions" data-i18n="monitoring.errorLogs.colFunctions">Functions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="errorLogsList"></tbody>
|
||||||
|
</table>
|
||||||
|
<div id="errorLogsListEmpty" class="mapsMirEmpty" hidden data-i18n="monitoring.errorLogs.empty">No errors logged.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="monitoringHardwareView" class="mapsMirPage" hidden>
|
||||||
|
<header class="mapsMirHeader">
|
||||||
|
<div class="mapsMirHeaderText">
|
||||||
|
<h1 class="mapsMirTitle" data-i18n="monitoring.hardware.title">Hardware health</h1>
|
||||||
|
<p class="mapsMirSubtitle" data-i18n="monitoring.hardware.subtitle">Robot hardware component status.</p>
|
||||||
|
</div>
|
||||||
|
<div class="mapsMirHeaderActions">
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="hardwareRefreshBtn"><span data-i18n="monitoring.refresh">Refresh</span></button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div id="hardwareGroupsList" class="monHwList"></div>
|
||||||
|
<div id="hardwareListEmpty" class="mapsMirEmpty" hidden data-i18n="monitoring.hardware.empty">No hardware data.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="monitoringSafetyView" class="mapsMirPage" hidden>
|
||||||
|
<header class="mapsMirHeader">
|
||||||
|
<div class="mapsMirHeaderText">
|
||||||
|
<h1 class="mapsMirTitle" data-i18n="monitoring.safety.title">Safety system</h1>
|
||||||
|
<p class="mapsMirSubtitle" data-i18n="monitoring.safety.subtitle">Live emergency stop and laser scanner status.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="monSafetyGrid" id="safetyGrid">
|
||||||
|
<div class="monSafetyCard" id="safetyEstopCard">
|
||||||
|
<h2 class="monSafetyCardTitle" data-i18n="monitoring.safety.estop">Emergency stop</h2>
|
||||||
|
<div class="monSafetyStatus" id="safetyEstopStatus">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="monSafetyCard" id="safetyFrontCard">
|
||||||
|
<h2 class="monSafetyCardTitle" data-i18n="monitoring.safety.frontScanner">Front scanner</h2>
|
||||||
|
<div class="monSafetyStatus" id="safetyFrontStatus">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="monSafetyCard" id="safetyRearCard">
|
||||||
|
<h2 class="monSafetyCardTitle" data-i18n="monitoring.safety.rearScanner">Rear scanner</h2>
|
||||||
|
<div class="monSafetyStatus" id="safetyRearStatus">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="monitoringPlaceholderView" class="mapsMirPage" hidden>
|
||||||
|
<header class="mapsMirHeader">
|
||||||
|
<div class="mapsMirHeaderText">
|
||||||
|
<h1 class="mapsMirTitle" id="monitoringPlaceholderTitle">—</h1>
|
||||||
|
<p class="mapsMirSubtitle">
|
||||||
|
<span id="monitoringPlaceholderSubtitle">—</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="mapsMirTableWrap">
|
||||||
|
<p class="mapsMirEmpty" id="monitoringPlaceholderBody" data-i18n="monitoring.comingSoon">Tính năng đang phát triển.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="monitoringMissionLogView" class="mapsMirPage" hidden>
|
||||||
|
<header class="mapsMirHeader">
|
||||||
|
<div class="mapsMirHeaderText">
|
||||||
|
<h1 class="mapsMirTitle" data-i18n="monitoring.missionLog.title">Mission log</h1>
|
||||||
|
<p class="mapsMirSubtitle">
|
||||||
|
<span data-i18n="monitoring.missionLog.subtitle">Missions executed and currently running.</span>
|
||||||
|
<button type="button" class="mapsMirHelpBtn" id="missionLogHelpBtn" data-i18n-title="monitoring.missionLog.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--outline" id="missionLogRefreshBtn">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M11 2v3H8M3 12V9h3" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/><path d="M2.5 5.5A5 5 0 0 1 11 3.5l1.5 1.5M12.5 8.5A5 5 0 0 1 3.5 10.5L2 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
|
||||||
|
<span data-i18n="monitoring.refresh">Refresh</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--outline mapsMirBtn--danger" id="missionLogClearHistoryBtn">
|
||||||
|
<span data-i18n="monitoring.missionLog.clearHistory">Clear history</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="missionLogClearFiltersBtn">
|
||||||
|
<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="monitoring.clearFilters">Clear filters</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="mapsMirFilterBar">
|
||||||
|
<label class="mapsMirFilterLabel" for="missionLogFilterInput" data-i18n="monitoring.filterLabel">Filter:</label>
|
||||||
|
<input type="search" id="missionLogFilterInput" class="mapsMirFilterInput" data-i18n-placeholder="monitoring.missionLog.filterPlaceholder" placeholder="Filter by mission or state..." autocomplete="off" />
|
||||||
|
<span id="missionLogFilterCount" class="mapsMirFilterCount">0 item(s) found</span>
|
||||||
|
<div class="mapsMirPager">
|
||||||
|
<button type="button" class="mapsMirPageBtn" id="missionLogPageFirst" aria-label="First page">«</button>
|
||||||
|
<button type="button" class="mapsMirPageBtn" id="missionLogPagePrev" aria-label="Previous page">‹</button>
|
||||||
|
<span id="missionLogPageLabel" class="mapsMirPageLabel">Page 1 of 1</span>
|
||||||
|
<button type="button" class="mapsMirPageBtn" id="missionLogPageNext" aria-label="Next page">›</button>
|
||||||
|
<button type="button" class="mapsMirPageBtn" id="missionLogPageLast" aria-label="Last page">»</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mapsMirTableWrap">
|
||||||
|
<table class="mapsMirTable mapsMirTable--missionLog" id="missionLogTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="monMirThIcon" aria-hidden="true"></th>
|
||||||
|
<th data-i18n="monitoring.missionLog.colMission">Mission</th>
|
||||||
|
<th data-i18n="monitoring.missionLog.colState">State</th>
|
||||||
|
<th data-i18n="monitoring.missionLog.colMessage">Message</th>
|
||||||
|
<th data-i18n="monitoring.missionLog.colStart">Start time</th>
|
||||||
|
<th data-i18n="monitoring.missionLog.colRanFor">Ran for</th>
|
||||||
|
<th data-i18n="monitoring.missionLog.colStartedBy">Started by</th>
|
||||||
|
<th class="mapsMirThFunctions" data-i18n="monitoring.missionLog.colFunctions">Functions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="missionLogList"></tbody>
|
||||||
|
</table>
|
||||||
|
<div id="missionLogListEmpty" class="mapsMirEmpty" hidden data-i18n="monitoring.missionLog.empty">No missions in the log yet.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="monitoringActionLogView" class="mapsMirPage" hidden>
|
||||||
|
<header class="mapsMirHeader">
|
||||||
|
<div class="mapsMirHeaderText">
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--outline monitoringActionLogBack" id="actionLogBackBtn">
|
||||||
|
<span data-i18n="monitoring.actionLog.back">← Mission log</span>
|
||||||
|
</button>
|
||||||
|
<h1 class="mapsMirTitle" id="actionLogTitle" data-i18n="monitoring.actionLog.title">Mission action log</h1>
|
||||||
|
<p class="mapsMirSubtitle">
|
||||||
|
<span id="actionLogSubtitle">—</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="mapsMirHeaderActions">
|
||||||
|
<a class="mapsMirBtn mapsMirBtn--outline" id="actionLogDownloadBtn" href="#" download>
|
||||||
|
<span data-i18n="monitoring.actionLog.download">Download</span>
|
||||||
|
</a>
|
||||||
|
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="actionLogClearFiltersBtn">
|
||||||
|
<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="monitoring.clearFilters">Clear filters</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="mapsMirFilterBar">
|
||||||
|
<label class="mapsMirFilterLabel" for="actionLogFilterInput" data-i18n="monitoring.filterLabel">Filter:</label>
|
||||||
|
<input type="search" id="actionLogFilterInput" class="mapsMirFilterInput" data-i18n-placeholder="monitoring.actionLog.filterPlaceholder" placeholder="Filter by action, state or message..." autocomplete="off" />
|
||||||
|
<span id="actionLogFilterCount" class="mapsMirFilterCount">0 item(s) found</span>
|
||||||
|
<div class="mapsMirPager">
|
||||||
|
<button type="button" class="mapsMirPageBtn" id="actionLogPageFirst" aria-label="First page">«</button>
|
||||||
|
<button type="button" class="mapsMirPageBtn" id="actionLogPagePrev" aria-label="Previous page">‹</button>
|
||||||
|
<span id="actionLogPageLabel" class="mapsMirPageLabel">Page 1 of 1</span>
|
||||||
|
<button type="button" class="mapsMirPageBtn" id="actionLogPageNext" aria-label="Next page">›</button>
|
||||||
|
<button type="button" class="mapsMirPageBtn" id="actionLogPageLast" aria-label="Last page">»</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mapsMirTableWrap">
|
||||||
|
<table class="mapsMirTable mapsMirTable--actionLog" id="actionLogTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="monMirThIcon" aria-hidden="true"></th>
|
||||||
|
<th data-i18n="monitoring.actionLog.colAction">Action</th>
|
||||||
|
<th data-i18n="monitoring.actionLog.colState">State</th>
|
||||||
|
<th data-i18n="monitoring.actionLog.colMessage">Message</th>
|
||||||
|
<th data-i18n="monitoring.actionLog.colStart">Start time</th>
|
||||||
|
<th data-i18n="monitoring.actionLog.colRanFor">Ran for</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="actionLogList"></tbody>
|
||||||
|
</table>
|
||||||
|
<div id="actionLogListEmpty" class="mapsMirEmpty" hidden data-i18n="monitoring.actionLog.empty">No actions logged for this mission.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="page" id="pageHelp" data-page-content="help" hidden>
|
<div class="page" id="pageHelp" data-page-content="help" hidden>
|
||||||
@@ -2783,6 +3132,8 @@ GET /api/v2.0.0/status</pre>
|
|||||||
<script src="/topbar.js"></script>
|
<script src="/topbar.js"></script>
|
||||||
<script src="/dashboard.js"></script>
|
<script src="/dashboard.js"></script>
|
||||||
<script src="/integrations.js"></script>
|
<script src="/integrations.js"></script>
|
||||||
|
<script src="/settings.js"></script>
|
||||||
|
<script src="/monitoring.js"></script>
|
||||||
<script src="/app.js"></script>
|
<script src="/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
927
www/monitoring.js
Normal file
927
www/monitoring.js
Normal file
@@ -0,0 +1,927 @@
|
|||||||
|
(() => {
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
const POLL_MS = 2500;
|
||||||
|
|
||||||
|
const ICONS = {
|
||||||
|
mission: `<svg class="monMirIcon" width="22" height="22" viewBox="0 0 22 22" aria-hidden="true"><rect x="4" y="5" width="14" height="12" rx="2" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M7 9h8M7 12h5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>`,
|
||||||
|
action: `<svg class="monMirIcon" width="22" height="22" viewBox="0 0 22 22" aria-hidden="true"><circle cx="11" cy="11" r="7" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M11 7v4l3 2" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>`,
|
||||||
|
view: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M1 7s2.5-4 6-4 6 4 6 4-2.5 4-6 4-6-4-6-4z" fill="none" stroke="currentColor" stroke-width="1.2"/><circle cx="7" cy="7" r="1.8" fill="none" stroke="currentColor" stroke-width="1.2"/></svg>`,
|
||||||
|
download: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M7 2v7M4 7l3 3 3-3" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><path d="M2 11h10" stroke="currentColor" stroke-width="1.3" stroke-linecap="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 VIEW_IDS = [
|
||||||
|
"monitoringAnalyticsView",
|
||||||
|
"monitoringSystemLogView",
|
||||||
|
"monitoringErrorLogsView",
|
||||||
|
"monitoringHardwareView",
|
||||||
|
"monitoringSafetyView",
|
||||||
|
"monitoringPlaceholderView",
|
||||||
|
"monitoringMissionLogView",
|
||||||
|
"monitoringActionLogView",
|
||||||
|
];
|
||||||
|
|
||||||
|
const el = (id) => document.getElementById(id);
|
||||||
|
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
|
||||||
|
|
||||||
|
const store = {
|
||||||
|
section: "analytics",
|
||||||
|
view: "analytics",
|
||||||
|
queue: [],
|
||||||
|
runner: {},
|
||||||
|
runs: [],
|
||||||
|
runActions: new Map(),
|
||||||
|
selectedEntryId: null,
|
||||||
|
missionFilter: "",
|
||||||
|
missionPage: 1,
|
||||||
|
actionFilter: "",
|
||||||
|
actionPage: 1,
|
||||||
|
systemLogItems: [],
|
||||||
|
systemLogFilter: "",
|
||||||
|
systemLogPage: 1,
|
||||||
|
errorLogItems: [],
|
||||||
|
errorLogFilter: "",
|
||||||
|
hardwareGroups: [],
|
||||||
|
hardwareExpanded: new Set(),
|
||||||
|
analytics: null,
|
||||||
|
analyticsChartMode: "bar",
|
||||||
|
safety: null,
|
||||||
|
pollTimer: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
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 hideAllViews() {
|
||||||
|
VIEW_IDS.forEach((id) => {
|
||||||
|
const node = el(id);
|
||||||
|
if (!node) return;
|
||||||
|
node.hidden = true;
|
||||||
|
node.setAttribute("aria-hidden", "true");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showView(node) {
|
||||||
|
hideAllViews();
|
||||||
|
if (!node) return;
|
||||||
|
node.hidden = false;
|
||||||
|
node.removeAttribute("aria-hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageCount(total) {
|
||||||
|
return Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso) {
|
||||||
|
if (!iso) return "—";
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return String(iso);
|
||||||
|
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateYmd(d) {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(d.getDate()).padStart(2, "0");
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDaysYmd(ymd, delta) {
|
||||||
|
const d = new Date(`${ymd}T12:00:00`);
|
||||||
|
d.setDate(d.getDate() + delta);
|
||||||
|
return formatDateYmd(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPoll(fn) {
|
||||||
|
stopPoll();
|
||||||
|
store.pollTimer = window.setInterval(() => {
|
||||||
|
fn().catch(() => {});
|
||||||
|
}, POLL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPoll() {
|
||||||
|
if (store.pollTimer) {
|
||||||
|
window.clearInterval(store.pollTimer);
|
||||||
|
store.pollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateDotClass(state) {
|
||||||
|
if (state === "error") return "monMirStateDot--error";
|
||||||
|
if (state === "warn") return "monMirStateDot--warn";
|
||||||
|
if (state === "info") return "monMirStateDot--info";
|
||||||
|
return "monMirStateDot--ok";
|
||||||
|
}
|
||||||
|
|
||||||
|
function hwStatusClass(status) {
|
||||||
|
if (status === "error") return "monHwStatus--error";
|
||||||
|
if (status === "warn") return "monHwStatus--warn";
|
||||||
|
return "monHwStatus--ok";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——— Analytics ———
|
||||||
|
|
||||||
|
function initAnalyticsPresets() {
|
||||||
|
const wrap = el("analyticsPresets");
|
||||||
|
if (!wrap) return;
|
||||||
|
const presets = [
|
||||||
|
{ key: "week", label: "monitoring.analytics.presetWeek", days: -6 },
|
||||||
|
{ key: "7d", label: "monitoring.analytics.preset7d", days: -6 },
|
||||||
|
{ key: "30d", label: "monitoring.analytics.preset30d", days: -29 },
|
||||||
|
{ key: "365d", label: "monitoring.analytics.preset365d", days: -364 },
|
||||||
|
];
|
||||||
|
wrap.innerHTML = presets
|
||||||
|
.map(
|
||||||
|
(p) =>
|
||||||
|
`<button type="button" class="mapsMirBtn mapsMirBtn--outline monAnalyticsPresetBtn" data-preset-days="${p.days}" data-i18n="${p.label}">${t(p.label)}</button>`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAnalyticsDateRange(start, end) {
|
||||||
|
if (el("analyticsStartDate")) el("analyticsStartDate").value = start;
|
||||||
|
if (el("analyticsEndDate")) el("analyticsEndDate").value = end;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAnalytics() {
|
||||||
|
const start = el("analyticsStartDate")?.value || "";
|
||||||
|
const end = el("analyticsEndDate")?.value || "";
|
||||||
|
const grouping = el("analyticsGrouping")?.value || "day";
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (start) qs.set("start", start);
|
||||||
|
if (end) qs.set("end", end);
|
||||||
|
if (grouping) qs.set("grouping", grouping);
|
||||||
|
store.analytics = await apiJson(`/api/monitoring/analytics?${qs}`);
|
||||||
|
renderAnalyticsChart();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAnalyticsChart() {
|
||||||
|
const data = store.analytics;
|
||||||
|
const svg = el("analyticsChart");
|
||||||
|
const empty = el("analyticsChartEmpty");
|
||||||
|
const totalEl = el("analyticsTotalLabel");
|
||||||
|
if (!svg || !data) return;
|
||||||
|
|
||||||
|
const buckets = Array.isArray(data.buckets) ? data.buckets : [];
|
||||||
|
const mode = el("analyticsChartMode")?.value || store.analyticsChartMode || "bar";
|
||||||
|
store.analyticsChartMode = mode;
|
||||||
|
|
||||||
|
if (totalEl) {
|
||||||
|
totalEl.textContent = t("monitoring.analytics.totalPeriod", {
|
||||||
|
n: (data.total_meters || 0).toFixed(1),
|
||||||
|
lifetime: (data.lifetime_meters || 0).toFixed(1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty) empty.hidden = buckets.length > 0;
|
||||||
|
svg.hidden = buckets.length === 0;
|
||||||
|
if (!buckets.length) {
|
||||||
|
svg.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = buckets.map((b) => (mode === "accumulated" ? b.accumulated : b.meters) || 0);
|
||||||
|
const maxVal = Math.max(...values, 1);
|
||||||
|
const padL = 48;
|
||||||
|
const padR = 16;
|
||||||
|
const padT = 16;
|
||||||
|
const padB = 40;
|
||||||
|
const w = 800;
|
||||||
|
const h = 280;
|
||||||
|
const chartW = w - padL - padR;
|
||||||
|
const chartH = h - padT - padB;
|
||||||
|
const barGap = 4;
|
||||||
|
const barW = Math.max(8, (chartW - barGap * (buckets.length - 1)) / buckets.length);
|
||||||
|
|
||||||
|
let svgBody = "";
|
||||||
|
values.forEach((val, i) => {
|
||||||
|
const barH = (val / maxVal) * chartH;
|
||||||
|
const x = padL + i * (barW + barGap);
|
||||||
|
const y = padT + chartH - barH;
|
||||||
|
const label = String(buckets[i].label || "").slice(5);
|
||||||
|
svgBody += `<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${barW.toFixed(1)}" height="${barH.toFixed(1)}" class="monAnalyticsBar" rx="2"><title>${escapeHtml(buckets[i].label)}: ${val.toFixed(1)} m</title></rect>`;
|
||||||
|
svgBody += `<text x="${(x + barW / 2).toFixed(1)}" y="${h - 12}" text-anchor="middle" class="monAnalyticsLabel">${escapeHtml(label)}</text>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
svgBody += `<line x1="${padL}" y1="${padT + chartH}" x2="${w - padR}" y2="${padT + chartH}" class="monAnalyticsAxis"/>`;
|
||||||
|
svgBody += `<text x="${padL - 8}" y="${padT + 8}" text-anchor="end" class="monAnalyticsLabel">${maxVal.toFixed(0)}m</text>`;
|
||||||
|
svg.innerHTML = svgBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAnalytics() {
|
||||||
|
store.view = "analytics";
|
||||||
|
store.section = "analytics";
|
||||||
|
showView(el("monitoringAnalyticsView"));
|
||||||
|
const today = formatDateYmd(new Date());
|
||||||
|
if (!el("analyticsStartDate")?.value) setAnalyticsDateRange(addDaysYmd(today, -6), today);
|
||||||
|
startPoll(refreshAnalytics);
|
||||||
|
void refreshAnalytics();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——— System log ———
|
||||||
|
|
||||||
|
function filteredSystemLog() {
|
||||||
|
const q = store.systemLogFilter.trim().toLowerCase();
|
||||||
|
let items = [...store.systemLogItems];
|
||||||
|
if (q) {
|
||||||
|
items = items.filter((e) => {
|
||||||
|
return (
|
||||||
|
String(e.module || "").toLowerCase().includes(q) ||
|
||||||
|
String(e.message || "").toLowerCase().includes(q) ||
|
||||||
|
String(e.state || "").toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSystemLog() {
|
||||||
|
const listEl = el("systemLogList");
|
||||||
|
if (!listEl) return;
|
||||||
|
const items = filteredSystemLog();
|
||||||
|
const total = items.length;
|
||||||
|
const pages = pageCount(total);
|
||||||
|
if (store.systemLogPage > pages) store.systemLogPage = pages;
|
||||||
|
const start = (store.systemLogPage - 1) * PAGE_SIZE;
|
||||||
|
const pageItems = items.slice(start, start + PAGE_SIZE);
|
||||||
|
|
||||||
|
if (el("systemLogFilterCount")) el("systemLogFilterCount").textContent = t("monitoring.itemsFound", { n: total });
|
||||||
|
if (el("systemLogPageLabel")) el("systemLogPageLabel").textContent = t("monitoring.pageOf", { page: store.systemLogPage, total: pages });
|
||||||
|
|
||||||
|
listEl.innerHTML = "";
|
||||||
|
const tableEl = el("systemLogTable");
|
||||||
|
const emptyEl = el("systemLogListEmpty");
|
||||||
|
if (tableEl) tableEl.hidden = total === 0;
|
||||||
|
if (emptyEl) {
|
||||||
|
emptyEl.hidden = total > 0;
|
||||||
|
emptyEl.textContent = store.systemLogFilter ? t("monitoring.systemLog.emptyFilter") : t("monitoring.systemLog.empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
pageItems.forEach((entry) => {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
tr.className = "monMirRow";
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td><span class="monMirStateDot ${stateDotClass(entry.state)}" title="${escapeHtml(entry.state || "")}"></span></td>
|
||||||
|
<td>${escapeHtml(entry.module || "—")}</td>
|
||||||
|
<td class="monMirMessageCell">${escapeHtml(entry.message || "—")}</td>
|
||||||
|
<td>${escapeHtml(formatTime(entry.ts))}</td>`;
|
||||||
|
listEl.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshSystemLog() {
|
||||||
|
const data = await apiJson("/api/monitoring/system_log");
|
||||||
|
store.systemLogItems = Array.isArray(data.items) ? data.items : [];
|
||||||
|
renderSystemLog();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSystemLog() {
|
||||||
|
store.view = "system-log";
|
||||||
|
store.section = "monitoring-log";
|
||||||
|
showView(el("monitoringSystemLogView"));
|
||||||
|
startPoll(refreshSystemLog);
|
||||||
|
void refreshSystemLog();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——— Error logs ———
|
||||||
|
|
||||||
|
function filteredErrorLogs() {
|
||||||
|
const q = store.errorLogFilter.trim().toLowerCase();
|
||||||
|
let items = [...store.errorLogItems];
|
||||||
|
if (q) {
|
||||||
|
items = items.filter((e) => {
|
||||||
|
return (
|
||||||
|
String(e.description || "").toLowerCase().includes(q) ||
|
||||||
|
String(e.module || "").toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderErrorLogs() {
|
||||||
|
const listEl = el("errorLogsList");
|
||||||
|
if (!listEl) return;
|
||||||
|
const items = filteredErrorLogs();
|
||||||
|
const total = items.length;
|
||||||
|
|
||||||
|
if (el("errorLogsFilterCount")) el("errorLogsFilterCount").textContent = t("monitoring.itemsFound", { n: total });
|
||||||
|
|
||||||
|
listEl.innerHTML = "";
|
||||||
|
const tableEl = el("errorLogsTable");
|
||||||
|
const emptyEl = el("errorLogsListEmpty");
|
||||||
|
if (tableEl) tableEl.hidden = total === 0;
|
||||||
|
if (emptyEl) {
|
||||||
|
emptyEl.hidden = total > 0;
|
||||||
|
emptyEl.textContent = store.errorLogFilter ? t("monitoring.errorLogs.emptyFilter") : t("monitoring.errorLogs.empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
items.forEach((entry) => {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
tr.className = "monMirRow";
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td>${escapeHtml(entry.description || "—")}</td>
|
||||||
|
<td>${escapeHtml(entry.module || "—")}</td>
|
||||||
|
<td>${escapeHtml(formatTime(entry.ts))}</td>
|
||||||
|
<td class="mapsMirTdFunctions">
|
||||||
|
<a class="mapsMirIconBtn" href="/api/monitoring/error_logs/${encodeURIComponent(entry.id)}/download" download title="${escapeHtml(t("monitoring.errorLogs.download"))}">${ICONS.download}</a>
|
||||||
|
<button type="button" class="mapsMirIconBtn mapsMirIconBtn--danger" data-delete-error="${escapeHtml(entry.id)}" title="${escapeHtml(t("monitoring.errorLogs.delete"))}">${ICONS.delete}</button>
|
||||||
|
</td>`;
|
||||||
|
listEl.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshErrorLogs() {
|
||||||
|
const data = await apiJson("/api/monitoring/error_logs");
|
||||||
|
store.errorLogItems = Array.isArray(data.items) ? data.items : [];
|
||||||
|
renderErrorLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showErrorLogs() {
|
||||||
|
store.view = "error-logs";
|
||||||
|
store.section = "error-logs";
|
||||||
|
showView(el("monitoringErrorLogsView"));
|
||||||
|
startPoll(refreshErrorLogs);
|
||||||
|
void refreshErrorLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——— Hardware health ———
|
||||||
|
|
||||||
|
function renderHardware() {
|
||||||
|
const listEl = el("hardwareGroupsList");
|
||||||
|
const emptyEl = el("hardwareListEmpty");
|
||||||
|
if (!listEl) return;
|
||||||
|
const groups = store.hardwareGroups;
|
||||||
|
listEl.innerHTML = "";
|
||||||
|
if (emptyEl) emptyEl.hidden = groups.length > 0;
|
||||||
|
if (!groups.length) return;
|
||||||
|
|
||||||
|
groups.forEach((group) => {
|
||||||
|
const gid = group.id || group.name;
|
||||||
|
const expanded = store.hardwareExpanded.has(gid);
|
||||||
|
const section = document.createElement("section");
|
||||||
|
section.className = "monHwGroup";
|
||||||
|
section.innerHTML = `
|
||||||
|
<button type="button" class="monHwGroupHeader" data-hw-toggle="${escapeHtml(gid)}" aria-expanded="${expanded}">
|
||||||
|
<span class="monHwGroupArrow">${expanded ? "▾" : "▸"}</span>
|
||||||
|
<span class="monHwStatusDot ${hwStatusClass(group.status)}"></span>
|
||||||
|
<span class="monHwGroupName">${escapeHtml(group.name || gid)}</span>
|
||||||
|
<span class="monHwGroupBadge ${hwStatusClass(group.status)}">${escapeHtml(group.status_label || group.status || "")}</span>
|
||||||
|
</button>
|
||||||
|
<div class="monHwGroupBody" ${expanded ? "" : "hidden"}></div>`;
|
||||||
|
const body = section.querySelector(".monHwGroupBody");
|
||||||
|
const components = Array.isArray(group.components) ? group.components : [];
|
||||||
|
components.forEach((c) => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "monHwComponent";
|
||||||
|
row.innerHTML = `
|
||||||
|
<span class="monHwStatusDot ${hwStatusClass(c.status)}"></span>
|
||||||
|
<span class="monHwComponentName">${escapeHtml(c.name || c.id || "—")}</span>
|
||||||
|
<span class="monHwComponentMsg">${escapeHtml(c.message || c.status_label || "")}</span>`;
|
||||||
|
body?.appendChild(row);
|
||||||
|
});
|
||||||
|
listEl.appendChild(section);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshHardware() {
|
||||||
|
const data = await apiJson("/api/monitoring/hardware_health");
|
||||||
|
store.hardwareGroups = Array.isArray(data.groups) ? data.groups : [];
|
||||||
|
renderHardware();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showHardware() {
|
||||||
|
store.view = "hardware-health";
|
||||||
|
store.section = "hardware-health";
|
||||||
|
showView(el("monitoringHardwareView"));
|
||||||
|
startPoll(refreshHardware);
|
||||||
|
void refreshHardware();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——— Safety ———
|
||||||
|
|
||||||
|
function renderSafety() {
|
||||||
|
const s = store.safety || {};
|
||||||
|
const estop = s.emergency_stop || "released";
|
||||||
|
const front = s.front_scanner || "free";
|
||||||
|
const rear = s.rear_scanner || "free";
|
||||||
|
|
||||||
|
const setCard = (statusEl, cardEl, value, okValues, okKey, badKey) => {
|
||||||
|
if (!statusEl || !cardEl) return;
|
||||||
|
const ok = okValues.includes(value);
|
||||||
|
statusEl.textContent = ok ? t(okKey) : t(badKey);
|
||||||
|
cardEl.classList.toggle("monSafetyCard--ok", ok);
|
||||||
|
cardEl.classList.toggle("monSafetyCard--bad", !ok);
|
||||||
|
};
|
||||||
|
|
||||||
|
setCard(el("safetyEstopStatus"), el("safetyEstopCard"), estop, ["released"], "monitoring.safety.estopReleased", "monitoring.safety.estopActivated");
|
||||||
|
setCard(el("safetyFrontStatus"), el("safetyFrontCard"), front, ["free"], "monitoring.safety.scannerFree", "monitoring.safety.scannerBlocked");
|
||||||
|
setCard(el("safetyRearStatus"), el("safetyRearCard"), rear, ["free"], "monitoring.safety.scannerFree", "monitoring.safety.scannerBlocked");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshSafety() {
|
||||||
|
store.safety = await apiJson("/api/monitoring/safety");
|
||||||
|
renderSafety();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSafety() {
|
||||||
|
store.view = "safety-system";
|
||||||
|
store.section = "safety-system";
|
||||||
|
showView(el("monitoringSafetyView"));
|
||||||
|
startPoll(refreshSafety);
|
||||||
|
void refreshSafety();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——— Mission log (Phase 1) ———
|
||||||
|
|
||||||
|
const missionLogViewEl = () => el("monitoringMissionLogView");
|
||||||
|
const actionLogViewEl = () => el("monitoringActionLogView");
|
||||||
|
const missionLogListEl = () => el("missionLogList");
|
||||||
|
const actionLogListEl = () => el("actionLogList");
|
||||||
|
|
||||||
|
function findEntry(id) {
|
||||||
|
return (
|
||||||
|
store.queue.find((e) => e && e.id === id) ||
|
||||||
|
store.runs.find((e) => e && e.id === id) ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function missionLogEntries() {
|
||||||
|
const items = [...store.runs, ...store.queue];
|
||||||
|
const seen = new Set();
|
||||||
|
return items.filter((e) => {
|
||||||
|
if (!e || typeof e !== "object") return false;
|
||||||
|
if (seen.has(e.id)) return false;
|
||||||
|
seen.add(e.id);
|
||||||
|
const status = String(e.status || "");
|
||||||
|
return status === "executing" || status === "completed" || status === "failed" || status === "cancelled";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms) {
|
||||||
|
if (!Number.isFinite(ms) || ms < 0) return "—";
|
||||||
|
const sec = Math.floor(ms / 1000);
|
||||||
|
if (sec < 60) return `${sec}s`;
|
||||||
|
const min = Math.floor(sec / 60);
|
||||||
|
const rem = sec % 60;
|
||||||
|
if (min < 60) return `${min}m ${rem}s`;
|
||||||
|
const hr = Math.floor(min / 60);
|
||||||
|
return `${hr}h ${min % 60}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryDuration(entry) {
|
||||||
|
const start = entry.started_at ? new Date(entry.started_at).getTime() : NaN;
|
||||||
|
if (!Number.isFinite(start)) return "—";
|
||||||
|
const end = entry.finished_at ? new Date(entry.finished_at).getTime() : Date.now();
|
||||||
|
return formatDuration(end - start);
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryMessage(entry) {
|
||||||
|
if (entry.status === "executing") {
|
||||||
|
const cur = store.runner?.current_action;
|
||||||
|
if (cur) return String(cur);
|
||||||
|
return store.runner?.message || t("monitoring.missionLog.running");
|
||||||
|
}
|
||||||
|
const log = Array.isArray(entry.log) ? entry.log : [];
|
||||||
|
const last = log.length ? log[log.length - 1] : null;
|
||||||
|
if (last?.message) return String(last.message);
|
||||||
|
if (entry.status === "failed") return t("monitoring.missionLog.stateFailed");
|
||||||
|
if (entry.status === "cancelled") return t("monitoring.missionLog.stateCancelled");
|
||||||
|
if (entry.status === "completed") return t("monitoring.missionLog.stateCompleted");
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStartedBy(entry) {
|
||||||
|
const src = String(entry.source || "ui");
|
||||||
|
const key = `monitoring.missionLog.source.${src}`;
|
||||||
|
const label = t(key);
|
||||||
|
return label === key ? src : label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateLabel(status) {
|
||||||
|
const key = `monitoring.missionLog.state.${status}`;
|
||||||
|
const label = t(key);
|
||||||
|
return label === key ? status : label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateClass(status) {
|
||||||
|
if (status === "executing") return "monMirState--running";
|
||||||
|
if (status === "completed") return "monMirState--ok";
|
||||||
|
if (status === "failed") return "monMirState--error";
|
||||||
|
if (status === "cancelled") return "monMirState--warn";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function levelLabel(level) {
|
||||||
|
const key = `monitoring.actionLog.level.${level || "info"}`;
|
||||||
|
const label = t(key);
|
||||||
|
return label === key ? level || "info" : label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function levelClass(level) {
|
||||||
|
if (level === "error") return "monMirState--error";
|
||||||
|
if (level === "warn") return "monMirState--warn";
|
||||||
|
if (level === "user") return "monMirState--user";
|
||||||
|
return "monMirState--ok";
|
||||||
|
}
|
||||||
|
|
||||||
|
function filteredMissionEntries() {
|
||||||
|
const q = store.missionFilter.trim().toLowerCase();
|
||||||
|
let items = missionLogEntries();
|
||||||
|
if (q) {
|
||||||
|
items = items.filter((e) => {
|
||||||
|
const name = String(e.mission_name || "").toLowerCase();
|
||||||
|
const state = stateLabel(e.status).toLowerCase();
|
||||||
|
return name.includes(q) || state.includes(q) || String(e.status).includes(q);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items.sort((a, b) => {
|
||||||
|
const ta = new Date(a.started_at || a.created_at || 0).getTime();
|
||||||
|
const tb = new Date(b.started_at || b.created_at || 0).getTime();
|
||||||
|
return tb - ta;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMissionLog() {
|
||||||
|
const listEl = missionLogListEl();
|
||||||
|
if (!listEl) return;
|
||||||
|
const items = filteredMissionEntries();
|
||||||
|
const total = items.length;
|
||||||
|
const pages = pageCount(total);
|
||||||
|
if (store.missionPage > pages) store.missionPage = pages;
|
||||||
|
const start = (store.missionPage - 1) * PAGE_SIZE;
|
||||||
|
const pageItems = items.slice(start, start + PAGE_SIZE);
|
||||||
|
|
||||||
|
if (el("missionLogFilterCount")) el("missionLogFilterCount").textContent = t("monitoring.itemsFound", { n: total });
|
||||||
|
if (el("missionLogPageLabel")) el("missionLogPageLabel").textContent = t("monitoring.pageOf", { page: store.missionPage, total: pages });
|
||||||
|
|
||||||
|
listEl.innerHTML = "";
|
||||||
|
const tableEl = el("missionLogTable");
|
||||||
|
const emptyEl = el("missionLogListEmpty");
|
||||||
|
if (tableEl) tableEl.hidden = total === 0;
|
||||||
|
if (emptyEl) {
|
||||||
|
emptyEl.hidden = total > 0;
|
||||||
|
emptyEl.textContent = store.missionFilter ? t("monitoring.missionLog.emptyFilter") : t("monitoring.missionLog.empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
pageItems.forEach((entry) => {
|
||||||
|
const status = entry.status || "—";
|
||||||
|
const isPersisted = entry.__persisted === true;
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
tr.className = "monMirRow";
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td class="monMirTdIcon">${ICONS.mission}</td>
|
||||||
|
<td>${escapeHtml(entry.mission_name || entry.mission_id || "—")}</td>
|
||||||
|
<td><span class="monMirState ${stateClass(status)}">${escapeHtml(stateLabel(status))}</span></td>
|
||||||
|
<td class="monMirMessageCell">${escapeHtml(entryMessage(entry))}</td>
|
||||||
|
<td>${escapeHtml(formatTime(entry.started_at || entry.created_at))}</td>
|
||||||
|
<td>${escapeHtml(entryDuration(entry))}</td>
|
||||||
|
<td>${escapeHtml(formatStartedBy(entry))}</td>
|
||||||
|
<td class="mapsMirTdFunctions">
|
||||||
|
<button type="button" class="mapsMirIconBtn" data-view-log="${escapeHtml(entry.id)}" title="${escapeHtml(t("monitoring.missionLog.viewActions"))}">${ICONS.view}</button>
|
||||||
|
${
|
||||||
|
isPersisted && status !== "executing"
|
||||||
|
? `<button type="button" class="mapsMirIconBtn mapsMirIconBtn--danger" data-delete-run="${escapeHtml(entry.id)}" title="${escapeHtml(t("monitoring.missionLog.deleteRun"))}">${ICONS.delete}</button>`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
${
|
||||||
|
isPersisted && status !== "executing"
|
||||||
|
? `<a class="mapsMirIconBtn" href="/api/monitoring/mission_runs/${encodeURIComponent(entry.id)}/download" download title="${escapeHtml(t("monitoring.missionLog.downloadRun"))}">${ICONS.download}</a>`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
</td>`;
|
||||||
|
listEl.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildActionRows(entry) {
|
||||||
|
const rows = [];
|
||||||
|
const cached = store.runActions.get(entry?.id);
|
||||||
|
const log = Array.isArray(entry?.log) ? entry.log : Array.isArray(cached) ? cached : [];
|
||||||
|
log.forEach((line, idx) => {
|
||||||
|
const next = log[idx + 1];
|
||||||
|
const ts = line.ts ? new Date(line.ts).getTime() : NaN;
|
||||||
|
const nextTs = next?.ts ? new Date(next.ts).getTime() : NaN;
|
||||||
|
const ranFor = Number.isFinite(ts) && Number.isFinite(nextTs) ? formatDuration(nextTs - ts) : "—";
|
||||||
|
rows.push({
|
||||||
|
action: line.action || line.message || "—",
|
||||||
|
state: line.level || "info",
|
||||||
|
message: line.message || "—",
|
||||||
|
start: line.ts,
|
||||||
|
ranFor,
|
||||||
|
current: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (entry?.status === "executing" && store.runner?.current_queue_id === entry.id) {
|
||||||
|
const cur = store.runner.current_action;
|
||||||
|
if (cur) {
|
||||||
|
rows.unshift({
|
||||||
|
action: cur,
|
||||||
|
state: "executing",
|
||||||
|
message: store.runner.message || cur,
|
||||||
|
start: store.runner.updated_at || null,
|
||||||
|
ranFor: "—",
|
||||||
|
current: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filteredActionRows(entry) {
|
||||||
|
const q = store.actionFilter.trim().toLowerCase();
|
||||||
|
let rows = buildActionRows(entry);
|
||||||
|
if (q) {
|
||||||
|
rows = rows.filter((r) => {
|
||||||
|
return (
|
||||||
|
String(r.action).toLowerCase().includes(q) ||
|
||||||
|
String(r.message).toLowerCase().includes(q) ||
|
||||||
|
levelLabel(r.state).toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderActionLog() {
|
||||||
|
const listEl = actionLogListEl();
|
||||||
|
if (!listEl) return;
|
||||||
|
const entry = findEntry(store.selectedEntryId);
|
||||||
|
if (!entry) {
|
||||||
|
listEl.innerHTML = "";
|
||||||
|
if (el("actionLogListEmpty")) {
|
||||||
|
el("actionLogListEmpty").hidden = false;
|
||||||
|
el("actionLogListEmpty").textContent = t("monitoring.actionLog.missingEntry");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = filteredActionRows(entry);
|
||||||
|
const total = rows.length;
|
||||||
|
const pages = pageCount(total);
|
||||||
|
if (store.actionPage > pages) store.actionPage = pages;
|
||||||
|
const start = (store.actionPage - 1) * PAGE_SIZE;
|
||||||
|
const pageItems = rows.slice(start, start + PAGE_SIZE);
|
||||||
|
|
||||||
|
if (el("actionLogFilterCount")) el("actionLogFilterCount").textContent = t("monitoring.itemsFound", { n: total });
|
||||||
|
if (el("actionLogPageLabel")) el("actionLogPageLabel").textContent = t("monitoring.pageOf", { page: store.actionPage, total: pages });
|
||||||
|
|
||||||
|
listEl.innerHTML = "";
|
||||||
|
const tableEl = el("actionLogTable");
|
||||||
|
const emptyEl = el("actionLogListEmpty");
|
||||||
|
if (tableEl) tableEl.hidden = total === 0;
|
||||||
|
if (emptyEl) {
|
||||||
|
emptyEl.hidden = total > 0;
|
||||||
|
emptyEl.textContent = store.actionFilter ? t("monitoring.actionLog.emptyFilter") : t("monitoring.actionLog.empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
pageItems.forEach((row) => {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
tr.className = `monMirRow${row.current ? " monMirRow--current" : ""}`;
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td class="monMirTdIcon">${ICONS.action}</td>
|
||||||
|
<td>${escapeHtml(row.action)}</td>
|
||||||
|
<td><span class="monMirState ${row.state === "executing" ? "monMirState--running" : levelClass(row.state)}">${escapeHtml(row.state === "executing" ? t("monitoring.actionLog.state.executing") : levelLabel(row.state))}</span></td>
|
||||||
|
<td class="monMirMessageCell">${escapeHtml(row.message)}</td>
|
||||||
|
<td>${escapeHtml(formatTime(row.start))}</td>
|
||||||
|
<td>${escapeHtml(row.ranFor)}</td>`;
|
||||||
|
listEl.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshMissionLog() {
|
||||||
|
const [queueData, runsData] = await Promise.all([
|
||||||
|
apiJson("/api/mission_queue"),
|
||||||
|
apiJson("/api/monitoring/mission_runs?limit=500"),
|
||||||
|
]);
|
||||||
|
store.queue = Array.isArray(queueData.queue) ? queueData.queue : [];
|
||||||
|
store.runner = queueData.runner && typeof queueData.runner === "object" ? queueData.runner : {};
|
||||||
|
store.runs = (Array.isArray(runsData.runs) ? runsData.runs : []).map((r) => ({ ...r, __persisted: true }));
|
||||||
|
if (store.view === "mission-log") renderMissionLog();
|
||||||
|
else if (store.view === "action-log") renderActionLog();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMissionLogList() {
|
||||||
|
store.view = "mission-log";
|
||||||
|
store.section = "mission-log";
|
||||||
|
store.selectedEntryId = null;
|
||||||
|
showView(missionLogViewEl());
|
||||||
|
startPoll(refreshMissionLog);
|
||||||
|
void refreshMissionLog();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showActionLog(entryId) {
|
||||||
|
store.view = "action-log";
|
||||||
|
store.selectedEntryId = entryId;
|
||||||
|
store.actionFilter = "";
|
||||||
|
store.actionPage = 1;
|
||||||
|
if (el("actionLogFilterInput")) el("actionLogFilterInput").value = "";
|
||||||
|
const entry = findEntry(entryId);
|
||||||
|
if (el("actionLogSubtitle")) {
|
||||||
|
el("actionLogSubtitle").textContent = entry
|
||||||
|
? t("monitoring.actionLog.subtitleMission", { name: entry.mission_name || entry.mission_id || "—" })
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
const dl = el("actionLogDownloadBtn");
|
||||||
|
if (dl) {
|
||||||
|
const persisted = entry && entry.__persisted === true;
|
||||||
|
dl.hidden = !persisted;
|
||||||
|
if (persisted) dl.href = `/api/monitoring/mission_runs/${encodeURIComponent(entryId)}/download`;
|
||||||
|
else dl.href = "#";
|
||||||
|
}
|
||||||
|
showView(actionLogViewEl());
|
||||||
|
startPoll(refreshMissionLog);
|
||||||
|
if (entry && (!Array.isArray(entry.log) || entry.log.length === 0) && !store.runActions.has(entryId)) {
|
||||||
|
apiJson(`/api/monitoring/mission_runs/${encodeURIComponent(entryId)}/actions`)
|
||||||
|
.then((data) => {
|
||||||
|
const items = Array.isArray(data.items) ? data.items : [];
|
||||||
|
store.runActions.set(entryId, items);
|
||||||
|
renderActionLog();
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
renderActionLog();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSection(section) {
|
||||||
|
store.section = section;
|
||||||
|
if (section === "analytics") showAnalytics();
|
||||||
|
else if (section === "monitoring-log") showSystemLog();
|
||||||
|
else if (section === "error-logs") showErrorLogs();
|
||||||
|
else if (section === "hardware-health") showHardware();
|
||||||
|
else if (section === "safety-system") showSafety();
|
||||||
|
else if (section === "mission-log") showMissionLogList();
|
||||||
|
else showAnalytics();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindPager(prefix, getTotal, getPage, setPage, render) {
|
||||||
|
const bind = (id, fn) => el(id)?.addEventListener("click", fn);
|
||||||
|
bind(`${prefix}PageFirst`, () => { setPage(1); render(); });
|
||||||
|
bind(`${prefix}PagePrev`, () => { setPage(Math.max(1, getPage() - 1)); render(); });
|
||||||
|
bind(`${prefix}PageNext`, () => { setPage(getPage() + 1); render(); });
|
||||||
|
bind(`${prefix}PageLast`, () => { setPage(pageCount(getTotal())); render(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindEvents() {
|
||||||
|
initAnalyticsPresets();
|
||||||
|
|
||||||
|
el("analyticsRefreshBtn")?.addEventListener("click", () => refreshAnalytics().catch((e) => alert(e.message)));
|
||||||
|
el("analyticsHelpBtn")?.addEventListener("click", () => alert(t("monitoring.analytics.helpBody")));
|
||||||
|
["analyticsStartDate", "analyticsEndDate", "analyticsGrouping", "analyticsChartMode"].forEach((id) => {
|
||||||
|
el(id)?.addEventListener("change", () => refreshAnalytics().catch(() => {}));
|
||||||
|
});
|
||||||
|
el("analyticsPresets")?.addEventListener("click", (evt) => {
|
||||||
|
const btn = evt.target.closest("[data-preset-days]");
|
||||||
|
if (!btn) return;
|
||||||
|
const days = Number(btn.dataset.presetDays);
|
||||||
|
const today = formatDateYmd(new Date());
|
||||||
|
setAnalyticsDateRange(addDaysYmd(today, days), today);
|
||||||
|
refreshAnalytics().catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
el("systemLogFilterInput")?.addEventListener("input", () => {
|
||||||
|
store.systemLogFilter = el("systemLogFilterInput").value;
|
||||||
|
store.systemLogPage = 1;
|
||||||
|
renderSystemLog();
|
||||||
|
});
|
||||||
|
el("systemLogClearFiltersBtn")?.addEventListener("click", () => {
|
||||||
|
store.systemLogFilter = "";
|
||||||
|
store.systemLogPage = 1;
|
||||||
|
if (el("systemLogFilterInput")) el("systemLogFilterInput").value = "";
|
||||||
|
renderSystemLog();
|
||||||
|
});
|
||||||
|
el("systemLogRefreshBtn")?.addEventListener("click", () => refreshSystemLog().catch((e) => alert(e.message)));
|
||||||
|
bindPager("systemLog", () => filteredSystemLog().length, () => store.systemLogPage, (p) => { store.systemLogPage = p; }, renderSystemLog);
|
||||||
|
|
||||||
|
el("errorLogsFilterInput")?.addEventListener("input", () => {
|
||||||
|
store.errorLogFilter = el("errorLogsFilterInput").value;
|
||||||
|
renderErrorLogs();
|
||||||
|
});
|
||||||
|
el("errorLogsRefreshBtn")?.addEventListener("click", () => refreshErrorLogs().catch((e) => alert(e.message)));
|
||||||
|
el("errorLogsGenerateBtn")?.addEventListener("click", () => {
|
||||||
|
apiJson("/api/monitoring/error_logs/generate", { method: "POST" })
|
||||||
|
.then(() => refreshErrorLogs())
|
||||||
|
.catch((e) => alert(e.message));
|
||||||
|
});
|
||||||
|
el("errorLogsDeleteAllBtn")?.addEventListener("click", () => {
|
||||||
|
if (!window.confirm(t("monitoring.errorLogs.deleteAllConfirm"))) return;
|
||||||
|
apiJson("/api/monitoring/error_logs", { method: "DELETE" })
|
||||||
|
.then(() => refreshErrorLogs())
|
||||||
|
.catch((e) => alert(e.message));
|
||||||
|
});
|
||||||
|
el("errorLogsList")?.addEventListener("click", (evt) => {
|
||||||
|
const btn = evt.target.closest("[data-delete-error]");
|
||||||
|
if (!btn?.dataset.deleteError) return;
|
||||||
|
apiJson(`/api/monitoring/error_logs/${encodeURIComponent(btn.dataset.deleteError)}`, { method: "DELETE" })
|
||||||
|
.then(() => refreshErrorLogs())
|
||||||
|
.catch((e) => alert(e.message));
|
||||||
|
});
|
||||||
|
|
||||||
|
el("hardwareRefreshBtn")?.addEventListener("click", () => refreshHardware().catch((e) => alert(e.message)));
|
||||||
|
el("hardwareGroupsList")?.addEventListener("click", (evt) => {
|
||||||
|
const btn = evt.target.closest("[data-hw-toggle]");
|
||||||
|
if (!btn?.dataset.hwToggle) return;
|
||||||
|
const gid = btn.dataset.hwToggle;
|
||||||
|
if (store.hardwareExpanded.has(gid)) store.hardwareExpanded.delete(gid);
|
||||||
|
else store.hardwareExpanded.add(gid);
|
||||||
|
renderHardware();
|
||||||
|
});
|
||||||
|
|
||||||
|
el("missionLogFilterInput")?.addEventListener("input", () => {
|
||||||
|
store.missionFilter = el("missionLogFilterInput").value;
|
||||||
|
store.missionPage = 1;
|
||||||
|
renderMissionLog();
|
||||||
|
});
|
||||||
|
el("missionLogClearFiltersBtn")?.addEventListener("click", () => {
|
||||||
|
store.missionFilter = "";
|
||||||
|
store.missionPage = 1;
|
||||||
|
if (el("missionLogFilterInput")) el("missionLogFilterInput").value = "";
|
||||||
|
renderMissionLog();
|
||||||
|
});
|
||||||
|
el("missionLogRefreshBtn")?.addEventListener("click", () => refreshMissionLog().catch((e) => alert(e.message)));
|
||||||
|
el("missionLogClearHistoryBtn")?.addEventListener("click", () => {
|
||||||
|
if (!window.confirm(t("monitoring.missionLog.clearHistoryConfirm"))) return;
|
||||||
|
apiJson("/api/monitoring/mission_runs", { method: "DELETE" })
|
||||||
|
.then(() => refreshMissionLog())
|
||||||
|
.catch((e) => alert(e.message));
|
||||||
|
});
|
||||||
|
el("missionLogHelpBtn")?.addEventListener("click", () => alert(t("monitoring.missionLog.helpBody")));
|
||||||
|
missionLogListEl()?.addEventListener("click", (evt) => {
|
||||||
|
const btn = evt.target.closest("[data-view-log]");
|
||||||
|
if (!btn?.dataset.viewLog) return;
|
||||||
|
showActionLog(btn.dataset.viewLog);
|
||||||
|
});
|
||||||
|
missionLogListEl()?.addEventListener("click", (evt) => {
|
||||||
|
const btn = evt.target.closest("[data-delete-run]");
|
||||||
|
if (!btn?.dataset.deleteRun) return;
|
||||||
|
if (!window.confirm(t("monitoring.missionLog.deleteRunConfirm"))) return;
|
||||||
|
apiJson(`/api/monitoring/mission_runs/${encodeURIComponent(btn.dataset.deleteRun)}`, { method: "DELETE" })
|
||||||
|
.then(() => refreshMissionLog())
|
||||||
|
.catch((e) => alert(e.message));
|
||||||
|
});
|
||||||
|
el("actionLogBackBtn")?.addEventListener("click", () => showMissionLogList());
|
||||||
|
el("actionLogFilterInput")?.addEventListener("input", () => {
|
||||||
|
store.actionFilter = el("actionLogFilterInput").value;
|
||||||
|
store.actionPage = 1;
|
||||||
|
renderActionLog();
|
||||||
|
});
|
||||||
|
el("actionLogClearFiltersBtn")?.addEventListener("click", () => {
|
||||||
|
store.actionFilter = "";
|
||||||
|
store.actionPage = 1;
|
||||||
|
if (el("actionLogFilterInput")) el("actionLogFilterInput").value = "";
|
||||||
|
renderActionLog();
|
||||||
|
});
|
||||||
|
bindPager("missionLog", () => filteredMissionEntries().length, () => store.missionPage, (p) => { store.missionPage = p; }, renderMissionLog);
|
||||||
|
bindPager("actionLog", () => {
|
||||||
|
const entry = findEntry(store.selectedEntryId);
|
||||||
|
return entry ? filteredActionRows(entry).length : 0;
|
||||||
|
}, () => store.actionPage, (p) => { store.actionPage = p; }, renderActionLog);
|
||||||
|
|
||||||
|
window.addEventListener("lm:locale-change", () => {
|
||||||
|
if (store.view === "analytics") renderAnalyticsChart();
|
||||||
|
else if (store.view === "system-log") renderSystemLog();
|
||||||
|
else if (store.view === "error-logs") renderErrorLogs();
|
||||||
|
else if (store.view === "hardware-health") renderHardware();
|
||||||
|
else if (store.view === "safety-system") renderSafety();
|
||||||
|
else if (store.view === "mission-log") renderMissionLog();
|
||||||
|
else if (store.view === "action-log") renderActionLog();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPageShow() {
|
||||||
|
const section = window.NavApp?.getActiveSection?.() || store.section || "analytics";
|
||||||
|
showSection(section);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPageHide() {
|
||||||
|
stopPoll();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.MonitoringApp = { init: bindEvents, onPageShow, onPageHide, showSection, refresh: refreshMissionLog };
|
||||||
|
|
||||||
|
function boot() {
|
||||||
|
bindEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.AuthApp?.isReady()) boot();
|
||||||
|
else window.addEventListener("lm:auth-ready", boot, { once: true });
|
||||||
|
})();
|
||||||
20
www/nav.js
20
www/nav.js
@@ -26,10 +26,20 @@
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
monitoring: {
|
monitoring: {
|
||||||
items: [{ section: "monitoring-log", page: "monitoring" }],
|
items: [
|
||||||
|
{ section: "analytics", page: "monitoring" },
|
||||||
|
{ section: "monitoring-log", page: "monitoring" },
|
||||||
|
{ section: "error-logs", page: "monitoring" },
|
||||||
|
{ section: "hardware-health", page: "monitoring" },
|
||||||
|
{ section: "safety-system", page: "monitoring" },
|
||||||
|
{ section: "mission-log", page: "monitoring" },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
system: {
|
system: {
|
||||||
items: [{ section: "integrations", page: "integrations" }],
|
items: [
|
||||||
|
{ section: "settings", page: "settings" },
|
||||||
|
{ section: "integrations", page: "integrations" },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
help: {
|
help: {
|
||||||
items: [{ section: "help-api", page: "help" }],
|
items: [{ section: "help-api", page: "help" }],
|
||||||
@@ -49,7 +59,8 @@
|
|||||||
"path-guides": { module: "setup", section: "path-guides" },
|
"path-guides": { module: "setup", section: "path-guides" },
|
||||||
users: { module: "setup", section: "users" },
|
users: { module: "setup", section: "users" },
|
||||||
integrations: { module: "system", section: "integrations" },
|
integrations: { module: "system", section: "integrations" },
|
||||||
monitoring: { module: "monitoring", section: "monitoring-log" },
|
settings: { module: "system", section: "settings" },
|
||||||
|
monitoring: { module: "monitoring", section: "analytics" },
|
||||||
help: { module: "help", section: "help-api" },
|
help: { module: "help", section: "help-api" },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -191,6 +202,7 @@
|
|||||||
updateRailUI();
|
updateRailUI();
|
||||||
navigateToPage(page);
|
navigateToPage(page);
|
||||||
if (page === "dashboard") window.DashboardApp?.handleNav?.(section);
|
if (page === "dashboard") window.DashboardApp?.handleNav?.(section);
|
||||||
|
if (page === "monitoring") window.MonitoringApp?.showSection?.(section);
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncDashboardSection(section) {
|
function syncDashboardSection(section) {
|
||||||
@@ -212,6 +224,7 @@
|
|||||||
saveState();
|
saveState();
|
||||||
updateRailUI();
|
updateRailUI();
|
||||||
if (page === "dashboard") window.DashboardApp?.handleNav?.(activeSection);
|
if (page === "dashboard") window.DashboardApp?.handleNav?.(activeSection);
|
||||||
|
if (page === "monitoring") window.MonitoringApp?.showSection?.(activeSection);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleFlyout() {
|
function toggleFlyout() {
|
||||||
@@ -310,5 +323,6 @@
|
|||||||
selectSection,
|
selectSection,
|
||||||
toggleFlyout,
|
toggleFlyout,
|
||||||
refreshFlyout,
|
refreshFlyout,
|
||||||
|
getActiveSection: () => activeSection,
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
148
www/settings.js
Normal file
148
www/settings.js
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
(() => {
|
||||||
|
const el = (id) => document.getElementById(id);
|
||||||
|
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
|
||||||
|
|
||||||
|
const statusEl = el("settingsStatusText");
|
||||||
|
const inputMissionRuns = el("settingsRetentionMissionRuns");
|
||||||
|
const inputSystemLog = el("settingsRetentionSystemLog");
|
||||||
|
const inputErrorLogs = el("settingsRetentionErrorLogs");
|
||||||
|
const selectLogLevel = el("settingsLogLevel");
|
||||||
|
const gridEl = el("settingsTileGrid");
|
||||||
|
const gridViewEl = el("settingsGridView");
|
||||||
|
const detailViewEl = el("settingsDetailView");
|
||||||
|
|
||||||
|
function setStatus(text, kind = "info") {
|
||||||
|
if (!statusEl) return;
|
||||||
|
statusEl.textContent = text || "";
|
||||||
|
statusEl.classList.toggle("mapsMirNote--ok", kind === "ok");
|
||||||
|
statusEl.classList.toggle("mapsMirNote--error", kind === "error");
|
||||||
|
}
|
||||||
|
|
||||||
|
const TILE_SVGS = {
|
||||||
|
mapping: `<svg width="42" height="42" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6l6-2 4 2 6-2v14l-6 2-4-2-6 2V6z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M10 4v14M14 6v14" fill="none" stroke="currentColor" stroke-width="1.6"/></svg>`,
|
||||||
|
warning: `<svg width="42" height="42" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l10 18H2L12 3z" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M12 9v5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><circle cx="12" cy="17" r="1" fill="currentColor"/></svg>`,
|
||||||
|
gear: `<svg width="42" height="42" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 15.2a3.2 3.2 0 1 0 0-6.4 3.2 3.2 0 0 0 0 6.4z" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M19.4 15a8.6 8.6 0 0 0 .1-2l2-1.2-2-3.4-2.3.7a8.5 8.5 0 0 0-1.7-1L15 5h-4l-.5 2.1a8.5 8.5 0 0 0-1.7 1l-2.3-.7-2 3.4L6.6 13a8.6 8.6 0 0 0 .1 2l-2 1.2 2 3.4 2.3-.7c.5.4 1.1.7 1.7 1L11 22h4l.5-2.1c.6-.3 1.2-.6 1.7-1l2.3.7 2-3.4-2-1.2z" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>`,
|
||||||
|
battery: `<svg width="42" height="42" viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="7" width="16" height="10" rx="2" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M21 10v4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><path d="M6 10h8v4H6z" fill="currentColor"/></svg>`,
|
||||||
|
calendar: `<svg width="42" height="42" viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="5" width="16" height="15" rx="2" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M8 3v4M16 3v4M4 9h16" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>`,
|
||||||
|
wifi: `<svg width="42" height="42" viewBox="0 0 24 24" aria-hidden="true"><path d="M2 8c5-4 15-4 20 0" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><path d="M5 12c3.5-2.7 10.5-2.7 14 0" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><path d="M8.5 15.5c2-1.5 5-1.5 7 0" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><circle cx="12" cy="19" r="1.3" fill="currentColor"/></svg>`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const TILES = [
|
||||||
|
{ id: "mapping", title: "settings.tile.mapping", sub: "settings.tile.mappingSub", icon: "mapping", action: "coming_soon" },
|
||||||
|
{ id: "error_handling", title: "settings.tile.errorHandling", sub: "settings.tile.errorHandlingSub", icon: "warning", action: "coming_soon" },
|
||||||
|
{ id: "battery", title: "settings.tile.battery", sub: "settings.tile.batterySub", icon: "battery", action: "coming_soon" },
|
||||||
|
{ id: "wifi", title: "settings.tile.wifi", sub: "settings.tile.wifiSub", icon: "wifi", action: "coming_soon" },
|
||||||
|
{ id: "date_time", title: "settings.tile.dateTime", sub: "settings.tile.dateTimeSub", icon: "calendar", action: "coming_soon" },
|
||||||
|
{ id: "advanced", title: "settings.tile.advanced", sub: "settings.tile.advancedSub", icon: "gear", action: "open_logging_retention" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function showGrid() {
|
||||||
|
if (gridViewEl) gridViewEl.hidden = false;
|
||||||
|
if (detailViewEl) detailViewEl.hidden = true;
|
||||||
|
setStatus("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDetail() {
|
||||||
|
if (gridViewEl) gridViewEl.hidden = true;
|
||||||
|
if (detailViewEl) detailViewEl.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGrid() {
|
||||||
|
if (!gridEl) return;
|
||||||
|
gridEl.innerHTML = TILES.map((tile) => {
|
||||||
|
const disabled = tile.action === "coming_soon";
|
||||||
|
return `
|
||||||
|
<button type="button" class="settingsMirTile${disabled ? " is-disabled" : ""}" data-settings-tile="${tile.id}" ${disabled ? "disabled" : ""}>
|
||||||
|
<div class="settingsMirTileIcon">${TILE_SVGS[tile.icon] || ""}</div>
|
||||||
|
<div class="settingsMirTileTitle">${t(tile.title)}</div>
|
||||||
|
<div class="settingsMirTileSub">${t(tile.sub)}</div>
|
||||||
|
</button>`;
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 toInt(v, fallback) {
|
||||||
|
const n = Number(v);
|
||||||
|
if (!Number.isFinite(n)) return fallback;
|
||||||
|
return Math.round(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
setStatus(t("settings.loading"));
|
||||||
|
const data = await apiJson("/api/settings");
|
||||||
|
const s = data && typeof data === "object" ? data : {};
|
||||||
|
|
||||||
|
if (inputMissionRuns) inputMissionRuns.value = String(toInt(s.retention_mission_runs, 2000));
|
||||||
|
if (inputSystemLog) inputSystemLog.value = String(toInt(s.retention_system_log, 2000));
|
||||||
|
if (inputErrorLogs) inputErrorLogs.value = String(toInt(s.retention_error_logs, 200));
|
||||||
|
if (selectLogLevel) selectLogLevel.value = String(s.log_level || "info");
|
||||||
|
|
||||||
|
setStatus(t("settings.loaded"), "ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apply() {
|
||||||
|
const payload = {
|
||||||
|
retention_mission_runs: toInt(inputMissionRuns?.value, 2000),
|
||||||
|
retention_system_log: toInt(inputSystemLog?.value, 2000),
|
||||||
|
retention_error_logs: toInt(inputErrorLogs?.value, 200),
|
||||||
|
log_level: String(selectLogLevel?.value || "info"),
|
||||||
|
};
|
||||||
|
|
||||||
|
setStatus(t("settings.saving"));
|
||||||
|
const res = await apiJson("/api/settings", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ok = res && typeof res === "object";
|
||||||
|
setStatus(ok ? t("settings.saved") : t("settings.saveFailed"), ok ? "ok" : "error");
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bind() {
|
||||||
|
renderGrid();
|
||||||
|
gridEl?.addEventListener("click", (evt) => {
|
||||||
|
const btn = evt.target.closest("[data-settings-tile]");
|
||||||
|
if (!btn?.dataset.settingsTile) return;
|
||||||
|
const tile = TILES.find((x) => x.id === btn.dataset.settingsTile);
|
||||||
|
if (!tile) return;
|
||||||
|
if (tile.action === "open_logging_retention") {
|
||||||
|
showDetail();
|
||||||
|
refresh().catch((e) => setStatus(e.message, "error"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
el("settingsRefreshBtn")?.addEventListener("click", () => refresh().catch((e) => setStatus(e.message, "error")));
|
||||||
|
el("settingsApplyBtn")?.addEventListener("click", () => apply().catch((e) => setStatus(e.message, "error")));
|
||||||
|
el("settingsBackBtn")?.addEventListener("click", () => showGrid());
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPageShow() {
|
||||||
|
showGrid();
|
||||||
|
refresh().catch((e) => setStatus(e.message, "error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPageHide() {}
|
||||||
|
|
||||||
|
window.SettingsApp = { onPageShow, onPageHide, refresh };
|
||||||
|
|
||||||
|
function boot() {
|
||||||
|
bind();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.AuthApp?.isReady()) boot();
|
||||||
|
else window.addEventListener("lm:auth-ready", boot, { once: true });
|
||||||
|
})();
|
||||||
|
|
||||||
189
www/style.css
189
www/style.css
@@ -3503,6 +3503,62 @@ body.auth-readonly-integrations .integrationToolbar .btn.primary { pointer-event
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settingsMirGridWrap {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsMirGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsMirTile {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 54px auto auto;
|
||||||
|
place-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 16px 14px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
min-height: 138px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsMirTile:hover {
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
box-shadow: 0 1px 10px rgba(2, 6, 23, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsMirTile:disabled,
|
||||||
|
.settingsMirTile.is-disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: default;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsMirTileIcon {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
color: var(--mir-accent, #22c55e);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsMirTileTitle {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsMirTileSub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
.mapsMirHelpBtn {
|
.mapsMirHelpBtn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -3940,6 +3996,139 @@ body.auth-readonly-io-modules .ioModulesMirRow .ioModuleDeleteBtn { pointer-even
|
|||||||
body.auth-readonly-paths .pathsMirRow .mapsMirIconBtn--danger { pointer-events: none; opacity: 0.55; }
|
body.auth-readonly-paths .pathsMirRow .mapsMirIconBtn--danger { pointer-events: none; opacity: 0.55; }
|
||||||
.mapEditorPathPreview { filter: drop-shadow(0 0 2px rgba(37, 99, 235, 0.45)); }
|
.mapEditorPathPreview { filter: drop-shadow(0 0 2px rgba(37, 99, 235, 0.45)); }
|
||||||
|
|
||||||
|
.mapsMirTable--missionLog thead th.monMirThIcon,
|
||||||
|
.mapsMirTable--actionLog thead th.monMirThIcon { width: 52px; }
|
||||||
|
.monMirIcon { color: var(--mir-accent, #2563eb); display: block; }
|
||||||
|
.monMirTdIcon { width: 52px; text-align: center; }
|
||||||
|
.monMirMessageCell { max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.monMirState {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.monMirState--ok { background: #e8f5e9; color: #2e7d32; }
|
||||||
|
.monMirState--running { background: #e3f2fd; color: #1565c0; }
|
||||||
|
.monMirState--warn { background: #fff8e1; color: #f57f17; }
|
||||||
|
.monMirState--error { background: #ffebee; color: #c62828; }
|
||||||
|
.monMirState--user { background: #f3e5f5; color: #6a1b9a; }
|
||||||
|
.monMirRow--current { background: rgba(37, 99, 235, 0.06); }
|
||||||
|
.monitoringActionLogBack { margin-bottom: 8px; }
|
||||||
|
|
||||||
|
.monMirStateDot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.monMirStateDot--ok { background: #2e7d32; }
|
||||||
|
.monMirStateDot--warn { background: #f57f17; }
|
||||||
|
.monMirStateDot--error { background: #c62828; }
|
||||||
|
.monMirStateDot--info { background: #1565c0; }
|
||||||
|
.mapsMirTable--systemLog thead th.monMirThState { width: 52px; }
|
||||||
|
|
||||||
|
.monAnalyticsToolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px 20px;
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.monAnalyticsDates, .monAnalyticsOptions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||||
|
.monAnalyticsPresets { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.monAnalyticsLabel { font-size: 12px; font-weight: 600; color: #475569; }
|
||||||
|
.monAnalyticsInput, .monAnalyticsSelect {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.monAnalyticsSummary { margin-bottom: 8px; }
|
||||||
|
.monAnalyticsTotal { font-size: 14px; font-weight: 600; color: #1e293b; }
|
||||||
|
.monAnalyticsChartWrap {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
.monAnalyticsChart { width: 100%; height: auto; display: block; }
|
||||||
|
.monAnalyticsBar { fill: #2563eb; }
|
||||||
|
.monAnalyticsAxis { stroke: #94a3b8; stroke-width: 1; }
|
||||||
|
.monAnalyticsLabel { font-size: 10px; fill: #64748b; }
|
||||||
|
|
||||||
|
.monHwList { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.monHwGroup {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.monHwGroupHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: none;
|
||||||
|
background: #f8fafc;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.monHwGroupArrow { color: #64748b; width: 14px; }
|
||||||
|
.monHwGroupName { font-weight: 600; flex: 1; }
|
||||||
|
.monHwStatusDot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.monHwStatusDot.monHwStatus--ok, .monHwGroupBadge.monHwStatus--ok { background: #2e7d32; color: #fff; }
|
||||||
|
.monHwStatusDot.monHwStatus--warn, .monHwGroupBadge.monHwStatus--warn { background: #f57f17; color: #fff; }
|
||||||
|
.monHwStatusDot.monHwStatus--error, .monHwGroupBadge.monHwStatus--error { background: #c62828; color: #fff; }
|
||||||
|
.monHwGroupBadge {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.monHwGroupBody { padding: 8px 16px 12px 40px; border-top: 1px solid #e2e8f0; }
|
||||||
|
.monHwComponent {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 6px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.monHwComponentName { font-weight: 500; min-width: 160px; }
|
||||||
|
.monHwComponentMsg { color: #64748b; }
|
||||||
|
|
||||||
|
.monSafetyGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
max-width: 900px;
|
||||||
|
}
|
||||||
|
.monSafetyCard {
|
||||||
|
border: 2px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
background: #fff;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.monSafetyCard--ok { border-color: #2e7d32; background: #f1f8f4; }
|
||||||
|
.monSafetyCard--bad { border-color: #c62828; background: #fff5f5; }
|
||||||
|
.monSafetyCardTitle { font-size: 15px; font-weight: 600; margin: 0 0 12px; }
|
||||||
|
.monSafetyStatus { font-size: 18px; font-weight: 700; }
|
||||||
|
.monSafetyCard--ok .monSafetyStatus { color: #2e7d32; }
|
||||||
|
.monSafetyCard--bad .monSafetyStatus { color: #c62828; }
|
||||||
|
|
||||||
.transMirIcon {
|
.transMirIcon {
|
||||||
color: var(--mir-green, #5cb85c);
|
color: var(--mir-green, #5cb85c);
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
Reference in New Issue
Block a user