Compare commits

...

3 Commits

Author SHA1 Message Date
c8fbaf9484 Cập nhật tài liệu 2026-07-24 09:07:54 +07:00
67e4ad4b62 Sửa tên Build Robot -> Robot+
Some checks failed
Test / test (push) Has been cancelled
2026-06-25 17:21:11 +07:00
77ea3cc361 Thêm phần 5. Monitoring và 6. System
Some checks failed
Test / test (push) Has been cancelled
2026-06-25 16:00:18 +07:00
39 changed files with 9931 additions and 103 deletions

15
.vscode/launch.json vendored Normal file
View 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}"
}
]
}

View File

@@ -51,10 +51,12 @@ add_executable(robot_app
src/io/io_module_usage.cpp
src/storage/path_store.cpp
src/storage/path_guide_store.cpp
src/storage/mission_run_store.cpp
src/path/path_planner.cpp
src/path/path_service.cpp
src/storage/dashboard_store.cpp
src/storage/state_repository.cpp
src/settings/settings_service.cpp
src/validation/sensor_validator.cpp
src/server/static_file_server.cpp
src/server/api_server.cpp
@@ -64,6 +66,7 @@ add_executable(robot_app
src/mission/mission_enqueue.cpp
src/mission/modbus_trigger_service.cpp
src/mission/mission_scheduler.cpp
src/monitoring/monitoring_service.cpp
src/robot/robot_runtime.cpp
src/server/api_mission_routes.cpp
src/server/api_robot_routes.cpp
@@ -73,6 +76,8 @@ add_executable(robot_app
src/server/api_path_routes.cpp
src/server/api_path_guide_routes.cpp
src/server/api_dashboard_routes.cpp
src/server/api_monitoring_routes.cpp
src/server/api_settings_routes.cpp
)
target_link_libraries(robot_app PRIVATE Threads::Threads SQLite::SQLite3)

157
README.md
View File

@@ -2,27 +2,32 @@
Giao diện web quản lý robot: mission, map, dashboard, user, Modbus/REST tích hợp.
Tài liệu UI/UX và trạng thái tính năng: [`docs/Reference_guide.md`](docs/Reference_guide.md).
Tài liệu UI/UX và trạng thái tính năng: `[docs/Reference_guide.md](docs/Reference_guide.md)`.
**Backend:** C++ executable `robot_app` (`src/app/robot_app.cpp`, class `RobotApp`).
**Frontend:** static `www/` (vanilla JS).
Chức năng chính:
- Đăng ký danh sách cảm biến LiDAR (tên, ip, port)
- Đăng ký IMU (tên, frame_id, topic, nguồn) và pose trên robot
- Kéo thả icon LiDAR/IMU trên canvas để set vị trí (robot frame)
- Nhiều layout — mỗi layout lưu profile trong SQLite (`layout_profiles`); catalog trong document `state`
- Mission editor, map editor, dashboard, transitions, paths, path guides, I/O modules
## Dữ liệu
| Thành phần | Vị trí |
|------------|--------|
| Database chính | `data/RBS.db` (SQLite, WAL) — auth, users, missions, queue, maps, dashboards, … |
| Media map | `data/maps/{map_id}/` |
| Media sound | `data/sounds/{sound_id}/` |
| Recordings (stub) | `data/recordings/` |
| Layout catalog | document `state` trong DB + `data/state.json` (legacy) |
| Thành phần | Vị trí |
| ----------------- | ------------------------------------------------------------------------------- |
| Database chính | `data/RBS.db` (SQLite, WAL) — auth, users, missions, queue, maps, dashboards, … |
| Media map | `data/maps/{map_id}/` |
| Media sound | `data/sounds/{sound_id}/` |
| Recordings (stub) | `data/recordings/` |
| Layout catalog | document `state` trong DB + `data/state.json` (legacy) |
Lần khởi động đầu, server **import** các file JSON cũ (nếu có) vào SQLite: `auth.json`, `missions.json`, `mission_queue.json`, `robot_runtime.json`.
@@ -62,16 +67,20 @@ Tắt auth cho dev/test:
LM_AUTH_DISABLED=1 ./build/robot_app
```
## Docker (giới hạn 2 CPU, 4 GB RAM)
Mô phỏng cấu hình controller tối thiểu (Dual-Core, 4 GB) trên máy dev:
| Mục | Giá trị |
|-----|---------|
| Compose service | `robot-app` |
| Image | `robot-app:RBS` |
| Container | `robot-app-limited` |
| Port | `8080` |
| Mục | Giá trị |
| --------------- | ------------------- |
| Compose service | `robot-app` |
| Image | `robot-app:RBS` |
| Container | `robot-app-limited` |
| Port | `8080` |
```bash
./scripts/lm.sh docker up
@@ -100,69 +109,87 @@ Dữ liệu lưu trên host qua volume `./data:/app/data`.
./scripts/lm.sh docker htop
```
## Biến môi trường
| Biến | Mặc định | Mô tả |
|------|----------|--------|
| `LM_AUTH_DISABLED` | — | `1` = tắt xác thực API (dev/test) |
| `LM_URL` | `http://127.0.0.1:8080` | URL khi test container |
| `LM_CONTAINER` | `robot-app-limited` | Tên container Docker |
| `LM_TEST_PORT` | `18080` | Port server tạm khi `test run` |
| Biến | Mặc định | Mô tả |
| ------------------ | ----------------------- | --------------------------------- |
| `LM_AUTH_DISABLED` | — | `1` = tắt xác thực API (dev/test) |
| `LM_URL` | `http://127.0.0.1:8080` | URL khi test container |
| `LM_CONTAINER` | `robot-app-limited` | Tên container Docker |
| `LM_TEST_PORT` | `18080` | Port server tạm khi `test run` |
## API (tóm tắt)
### Maps, sounds, dashboards
| Method | Endpoint | Mô tả |
|--------|----------|-------|
| GET/POST | `/api/maps` | Danh sách / tạo map |
| GET/PUT/DELETE | `/api/maps/{id}` | CRUD map |
| GET/POST | `/api/maps/{id}/image` | Ảnh map (`data/maps/{id}/`) |
| GET/POST | `/api/sounds` | Danh sách / tạo sound |
| GET/PUT/DELETE | `/api/sounds/{id}` | CRUD sound |
| GET/POST | `/api/sounds/{id}/file` | Upload/tải file âm thanh |
| POST | `/api/sounds/{id}/play` | Phát trên robot (volume 0100) |
| GET/PUT | `/api/dashboards` | Dashboard server-side |
| Method | Endpoint | Mô tả |
| -------------- | ----------------------- | ------------------------------ |
| GET/POST | `/api/maps` | Danh sách / tạo map |
| GET/PUT/DELETE | `/api/maps/{id}` | CRUD map |
| GET/POST | `/api/maps/{id}/image` | nh map (`data/maps/{id}/`) |
| GET/POST | `/api/sounds` | Danh sách / tạo sound |
| GET/PUT/DELETE | `/api/sounds/{id}` | CRUD sound |
| GET/POST | `/api/sounds/{id}/file` | Upload/tải file âm thanh |
| POST | `/api/sounds/{id}/play` | Phát trên robot (volume 0100) |
| GET/PUT | `/api/dashboards` | Dashboard server-side |
### Missions & queue
| Method | Endpoint | Mô tả |
|--------|----------|-------|
| GET/PUT | `/api/missions` | Danh sách / lưu missions |
| GET | `/api/mission_queue` | Queue + runner status |
| POST | `/api/mission_queue` | Enqueue mission |
| DELETE | `/api/mission_queue` | Xóa toàn bộ queue |
| DELETE | `/api/mission_queue/{id}` | Xóa một entry |
| PUT | `/api/mission_queue/reorder` | Sắp xếp lại queue |
| POST | `/api/mission_queue/pause` | Tạm dừng runner |
| POST | `/api/mission_queue/continue` | Tiếp tục runner |
| POST | `/api/mission_queue/cancel` | Hủy mission đang chạy |
| Method | Endpoint | Mô tả |
| ------- | ----------------------------- | ------------------------ |
| GET/PUT | `/api/missions` | Danh sách / lưu missions |
| GET | `/api/mission_queue` | Queue + runner status |
| POST | `/api/mission_queue` | Enqueue mission |
| DELETE | `/api/mission_queue` | Xóa toàn bộ queue |
| DELETE | `/api/mission_queue/{id}` | Xóa một entry |
| PUT | `/api/mission_queue/reorder` | Sắp xếp lại queue |
| POST | `/api/mission_queue/pause` | Tạm dừng runner |
| POST | `/api/mission_queue/continue` | Tiếp tục runner |
| POST | `/api/mission_queue/cancel` | Hủy mission đang chạy |
REST tương thích: `GET/POST/DELETE /api/v2.0.0/mission_queue`
### Transitions, paths, I/O
| Method | Endpoint | Mô tả |
|--------|----------|-------|
| GET/POST | `/api/transitions` | CRUD transition (`?site_id=`) |
| GET/DELETE | `/api/paths`, `/api/paths/{id}` | Path cache (auto-create; xóa thủ công) |
| GET/POST/PUT/DELETE | `/api/path_guides` | Path guides |
| GET/POST/PUT/DELETE | `/api/io_modules` | I/O modules (Bluetooth / WISE) |
| POST | `/api/io_modules/test` | Kiểm tra kết nối TCP |
| Method | Endpoint | Mô tả |
| ------------------- | ------------------------------- | -------------------------------------- |
| GET/POST | `/api/transitions` | CRUD transition (`?site_id=`) |
| GET/DELETE | `/api/paths`, `/api/paths/{id}` | Path cache (auto-create; xóa thủ công) |
| GET/POST/PUT/DELETE | `/api/path_guides` | Path guides |
| GET/POST/PUT/DELETE | `/api/io_modules` | I/O modules (Bluetooth / WISE) |
| POST | `/api/io_modules/test` | Kiểm tra kết nối TCP |
**Transitions:** Setup → Transitions — cấu hình chuyển map (from/to map, start/goal position, mission liên kết). Khi mission chạy `move_to_position` hoặc `adjust_localization` tới position trên map khác `active_map_id`, runner tự chèn: đi tới start position → chạy transition mission → chuyển map → đi tới goal position → tiếp tục bước gốc.
### Robot & tích hợp
| Method | Endpoint | Mô tả |
|--------|----------|-------|
| GET | `/api/robot/status` | Trạng thái robot (pin, pose, mission strip) |
| POST | `/api/robot/start`, `/api/robot/pause` | Start / pause |
| POST | `/api/robot/errors/reset` | Xóa lỗi |
| POST | `/api/robot/active_map` | Đặt map đang hoạt động |
| GET/POST/DELETE | `/api/triggers` | Modbus mission triggers (coil 10012000) |
| GET/POST/DELETE | `/api/fleet/schedules` | Lịch fleet (stub scheduler) |
| GET | `/api/recordings` | Stub — trả về `[]` |
| Method | Endpoint | Mô tả |
| --------------- | -------------------------------------- | ------------------------------------------- |
| GET | `/api/robot/status` | Trạng thái robot (pin, pose, mission strip) |
| POST | `/api/robot/start`, `/api/robot/pause` | Start / pause |
| POST | `/api/robot/errors/reset` | Xóa lỗi |
| POST | `/api/robot/active_map` | Đặt map đang hoạt động |
| GET/POST/DELETE | `/api/triggers` | Modbus mission triggers (coil 10012000) |
| GET/POST/DELETE | `/api/fleet/schedules` | Lịch fleet (stub scheduler) |
| GET | `/api/recordings` | Stub — trả về `[]` |
Modbus TCP server: port **5502** (mission triggers + action coils 16).
@@ -170,15 +197,17 @@ Modbus TCP server: port **5502** (mission triggers + action coils 16).
Trang web **bắt buộc đăng nhập** (password hoặc PIN 4 số). Tài khoản mặc định:
| User | Password | Nhóm |
|------|----------|------|
| Admin | admin | Administrators (full quyền) |
| User | user | Users (dashboard write, còn lại read) |
| Distributor | distributor | Distributors (full quyền) |
| User | Password | Nhóm |
| ----------- | ----------- | ------------------------------------- |
| Admin | admin | Administrators (full quyền) |
| User | user | Users (dashboard write, còn lại read) |
| Distributor | distributor | Distributors (full quyền) |
PIN chỉ dùng với nhóm **Users** sau khi admin gán (`PUT /api/users/:id`).
Chi tiết auth & permissions: [`docs/Reference_guide.md` §2](docs/Reference_guide.md#2-đăng-nhập-và-phân-quyền).
Chi tiết auth & permissions: `docs/Reference_guide.md` [§2](docs/Reference_guide.md#2-đăng-nhập-và-phân-quyền).
## Test tự động
@@ -214,4 +243,4 @@ Benchmark trong container (cần `docker compose up -d`):
# hoặc: ./scripts/lm.sh bench http
```
CI: GitHub Actions workflow `.github/workflows/test.yml`.
CI: GitHub Actions workflow `.github/workflows/test.yml`.

391
docs/AMR_SLAM_Thang_May.md Normal file
View File

@@ -0,0 +1,391 @@
---
title: "AMR SLAM Đa Tầng Qua Thang Máy"
subtitle: "Ghi chú nguyên lý và chức năng thang máy"
author: "RD Odomety"
date: "10/07/2026"
lang: vi
---
# Tổng quan
Tài liệu này tổng hợp nguyên lý điều hướng **AMR (Autonomous Mobile Robot)** sử dụng **SLAM** để di chuyển giữa các tầng thông qua thang máy, và vai trò/chức năng của thang máy trong hệ thống.
Đây là bài toán **điều hướng đa tầng (multi-floor navigation)** — không chỉ là SLAM thuần túy. SLAM giải quyết "robot đang ở đâu trên bản đồ", còn thang máy thêm lớp **điều phối hệ thống tòa nhà + chuyển đổi ngữ cảnh giữa các tầng**.
---
# Phần 1: Nguyên lý AMR SLAM di chuyển qua thang máy
## 1.1 Kiến trúc tổng quan
Các thành phần chính trong hệ thống:
| Thành phần | Vai trò |
|---|---|
| **SLAM / Localization** | Định vị trên bản đồ từng tầng |
| **Global planner** | Lập kế hoạch đường đi xuyên tầng |
| **Elevator interface** | Gọi thang, mở cửa, giữ cửa, nhận trạng thái |
| **Mission / State machine** | Điều phối toàn bộ quy trình lên/xuống tầng |
| **Map management** | Quản lý nhiều bản đồ, chuyển tầng |
Luồng tương tác:
```
AMR + SLAM → Navigation / Mission → Elevator Manager
Elevator Controller / BMS
Map Switch / Relocalization
```
## 1.2 Cách biểu diễn bản đồ đa tầng
### A. Nhiều bản đồ độc lập (phổ biến nhất)
- Mỗi tầng một map riêng (2D occupancy grid hoặc graph).
- Các map **không nối liên tục về mặt hình học** — chỉ liên kết qua **elevator node**.
- Ví dụ: `Floor_1.map`, `Floor_2.map`, mỗi map có điểm `elevator_A_in`, `elevator_A_out`.
### B. Một bản đồ 3D / multi-layer
- Một map chứa nhiều "layer" (mỗi layer = một tầng).
- Phù hợp khi dùng SLAM 3D hoặc hệ thống thương mại (MiR, Omron, Geek+…).
### C. Topological graph (đồ thị)
- **Node** = điểm quan trọng (phòng, thang máy, cửa).
- **Edge** = đường đi hoặc **kết nối thang máy giữa các tầng**.
- Global planner chạy trên graph, local planner chạy trên map 2D từng tầng.
## 1.3 Quy trình điển hình (State Machine)
```
[Tầng nguồn] [Thang máy] [Tầng đích]
| | |
Navigate --> Wait at door --> Call elevator --> Enter --> Ride -->
Align in cabin --> Door close --> Moving --> Door open --> Exit -->
Relocalize on new floor --> Navigate to goal
```
### Các trạng thái chi tiết
1. **Navigate to elevator waiting point** — Robot đi đến vị trí chờ trước cửa thang.
2. **Request elevator** — Gửi yêu cầu qua API/BMS: tầng đích, ID thang, ưu tiên.
3. **Wait for arrival** — Chờ thang đến đúng tầng, cửa mở, cabin trống.
4. **Enter elevator** — Local navigation vào cabin (line following, QR marker, hoặc map nhỏ trong cabin).
5. **Inside cabin — localization bị "mù"** — Điểm then chốt kỹ thuật (xem mục 1.4).
6. **Ride to target floor** — Robot đứng yên; hệ thống theo dõi trạng thái thang.
7. **Exit elevator** — Ra khỏi cabin, đến điểm relocalization đã định sẵn.
8. **Relocalize on new floor** — Xác định lại vị trí trên map tầng mới.
9. **Continue mission** — Điều hướng bình thường trên tầng đích.
## 1.4 Thách thức SLAM / Localization trong thang máy
### Vấn đề cốt lõi
Trong cabin, môi trường **thay đổi liên tục** (cửa đóng/mở, cabin di chuyển, người vào ra). LiDAR scan matching dễ **drift** hoặc **nhảy pose sai**.
### Cách xử lý thực tế
| Giai đoạn | Chiến lược |
|---|---|
| **Trước khi vào** | Localization bình thường (AMCL, Cartographer, SLAM Toolbox…) |
| **Trong cabin** | **Tắt hoặc "đóng băng" SLAM**; chỉ dùng odometry ngắn hoặc IMU |
| **Khi ra cabin** | **Relocalization** bằng scan matching với map tầng mới |
| **Backup** | QR / AprilTag / UWB tại cửa thang hai đầu |
### Vai trò của Odometry
Trong cabin, **wheel odometry + IMU** giữ ước lượng tương đối ngắn (vài mét). Tuy nhiên odometry **không đủ** để biết đã lên tầng nào — cần **tín hiệu từ thang máy** (floor reached).
Luồng điển hình:
```
SLAM (tin cậy) → Odom only (trong thang) → SLAM relocalize (tầng mới)
```
## 1.5 Tích hợp thang máy (Elevator Integration)
### Mức độ tích hợp
**Mức 1 — Passive (đơn giản)**
- Robot chỉ chờ cửa mở (sensor hoặc camera), không điều khiển thang.
- Phù hợp thang công cộng, khó triển khai công nghiệp.
**Mức 2 — API với BMS / EMS (phổ biến)**
- Giao thức: Modbus, BACnet, OPC-UA, REST API, MQTT.
- Robot gửi: `call(floor=5)`, `hold_door()`, `release()`.
- Thang trả: `current_floor`, `door_open`, `moving`, `fault`.
**Mức 3 — Dedicated robot elevator**
- Thang riêng cho robot (Kone, Otis, Schindler có giải pháp robot elevator).
-**robot mode**: giữ cửa lâu hơn, điều khiển từng bước.
### Tín hiệu thường cần
```
Robot → Elevator: call_floor, destination_floor, hold_door, cancel
Elevator → Robot: current_floor, door_status, ready, in_service, fault_code
```
## 1.6 Global planning xuyên tầng
Planner không tính đường liên tục 3D. Thường là **2 tầng lập kế hoạch**:
1. **Topological planner** (đồ thị):
```
Room_A (F1) → Elevator_1_wait (F1) → [elevator_edge] → Elevator_1_exit (F3) → Room_B (F3)
```
2. **Local planner** (từng tầng): DWA, TEB, Nav2 trên map 2D của tầng đó.
**Elevator edge** là "cạnh ảo": chi phí = thời gian chờ thang + thời gian di chuyển + rủi ro lỗi.
## 1.7 Relocalization khi sang tầng mới
Sau khi ra khỏi thang, robot **không biết chính xác pose** trên map tầng mới (drift trong cabin).
| Phương pháp | Mô tả |
|---|---|
| **Scan matching** | So khớp LiDAR với map tầng mới (AMCL global localization) |
| **Known exit pose** | Điểm ra cố định; initial pose gần đúng |
| **Fiducial marker** | QR/AprilTag tại cửa thang |
| **Feature matching** | Đặc trưng quanh khu vực cửa thang |
Quy trình an toàn: robot ra đến **relocalization zone** → dừng → xác nhận pose (confidence > ngưỡng) → mới tiếp tục.
## 1.8 Luồng dữ liệu tổng thể
```
Mission: "Deliver to Room 502 (Floor 5)"
|
v
+------------------+
| Task Scheduler | --> Chọn route: F1 -> Elevator B -> F5
+--------+---------+
|
+----+----+
v v
+--------+ +--------------+
| Nav2 | | Elevator FSM |
| (local)| | (call/wait/ |
| | | enter/exit) |
+---+----+ +------+-------+
| |
v v
+--------+ +-----------+
| SLAM / | | Elevator |
| AMCL | | API/BMS |
+---+----+ +-----------+
|
v
+----------+
| Odometry | <- dung khi SLAM khong tin cay (trong cabin)
| + IMU |
+----------+
```
## 1.9 Các vấn đề thực tế cần lưu ý
1. **An toàn**: E-stop, phát hiện người trong cabin, không chen khi cửa sắp đóng.
2. **Đồng bộ thời gian**: Timestamp map, odometry và trạng thái thang phải nhất quán.
3. **Nhiều robot**: Queue thang, tránh hai robot cùng gọi một cabin.
4. **Map maintenance**: Thay đổi nội thất từng tầng → cập nhật map riêng.
5. **Khe cửa thang / ngưỡng**: Robot phải vượt qua được; thường cần map chi tiết vùng cửa.
6. **Mất mạng**: Timeout khi gọi thang; retry và fallback.
7. **Chuẩn hóa tọa độ**: Mỗi tầng một hệ tọa độ; chỉ nối qua elevator node, không cộng trực tiếp tọa độ giữa các tầng.
---
# Phần 2: Chức năng thang máy trong điều hướng AMR đa tầng
## 2.1 Vai trò vật lý vs vai trò logic
| Lớp | Thang máy làm gì |
|---|---|
| **Vật lý** | Chuyển robot từ tầng A sang tầng B |
| **Logic (navigation)** | Cạnh nối giữa hai map độc lập (`Floor_A` ↔ `Floor_B`) |
| **Điều phối** | Tài nguyên dùng chung — nhiều robot có thể tranh nhau |
| **An toàn** | Vùng nguy hiểm: cửa, khe, cabin chuyển động |
Với SLAM: **thang máy là ranh giới mà localization không còn đáng tin**. Robot "nhảy" từ một không gian định vị được sang không gian khác, còn thang đảm nhiệm phần ở giữa.
## 2.2 Chức năng theo góc nhìn hệ thống
### A. Vận chuyển (Transport)
Chức năng cơ bản: đưa cabin từ tầng nguồn đến tầng đích.
Robot **không tự biết** đã lên tầng nào nếu không có tín hiệu từ thang (`current_floor`, `arrived_at_destination`).
### B. Cửa và vùng truy cập (Access control)
Thang quyết định **khi nào robot được vào/ra**:
- Cửa mở đủ lâu để robot vào (thường cần **hold door**)
- Cabin dừng đúng tầng, không lệch sàn quá ngưỡng
- Không đóng cửa khi có vật cản (light curtain, cảm biến cửa)
### C. Đồng bộ với robot (Synchronization)
```
Robot FSM Elevator FSM
----------- --------------
GO_TO_ELEVATOR IDLE
WAIT_CALL --> RECEIVE_CALL
WAIT_DOOR_OPEN <-- ARRIVED + DOOR_OPEN
ENTERING <-> DOOR_HELD
INSIDE --> GO_TO_DEST_FLOOR
WAIT_EXIT <-- ARRIVED + DOOR_OPEN
EXITING <-> DOOR_HELD
RELOCALIZE RELEASE / IDLE
```
### D. Tài nguyên dùng chung (Resource management)
- **Queue** yêu cầu
- **Reservation** (giữ thang cho robot)
- **Priority** (robot giao hàng vs robot dọn phòng)
## 2.3 Interface: Robot ↔ Thang
### Robot → Thang (Commands)
| Lệnh | Mục đích |
|---|---|
| `call_elevator(floor)` | Gọi thang đến tầng robot đang đứng |
| `request_ride(dest_floor)` | Đặt đích sau khi đã vào cabin |
| `hold_door()` | Giữ cửa mở khi robot đang vào/ra |
| `release_door()` | Cho phép đóng cửa |
| `cancel()` | Hủy khi nhiệm vụ thất bại |
| `ping / heartbeat` | Giữ session, phát hiện mất kết nối |
### Thang → Robot (Status / Events)
| Tín hiệu | Robot dùng để |
|---|---|
| `current_floor` | Biết thang đang ở đâu |
| `door_open / door_closed` | Quyết định vào/ra |
| `moving / stopped` | Biết cabin đang chạy hay dừng |
| `at_destination` | Xác nhận đã đến tầng đích |
| `ready_for_robot` | Cabin sẵn sàng (trống, không lỗi) |
| `fault / maintenance` | Dừng mission, chọn thang khác |
| `passenger_present` | Có người — có thể chờ hoặc hủy |
## 2.4 Các chế độ vận hành thang với robot
| Chế độ | Mô tả |
|---|---|
| **Thang công cộng** | Robot chỉ quan sát, khó triển khai công nghiệp |
| **Robot mode** | API với BMS, giữ cửa lâu, ưu tiên cabin trống — phổ biến nhất |
| **Thang chuyên dụng** | Chỉ robot dùng, điều khiển hoàn toàn qua API |
## 2.5 Chức năng thang tại từng giai đoạn mission
### Giai đoạn 1: Trước cửa thang (tầng nguồn)
**Thang:** Nhận call, di chuyển cabin, mở cửa, báo ready.
**Robot:** Đi đến `elevator_wait_point`, chờ `door_open` + `current_floor == my_floor`.
### Giai đoạn 2: Vào cabin
**Thang:** `hold_door` trong khi robot vào.
**Robot:** Tắt/hạ độ tin cậy SLAM, dùng odometry ngắn, gửi `request_ride(dest_floor)` khi vào xong.
### Giai đoạn 3: Trong cabin (đang chạy)
**Thang:** Báo `moving`, `current_floor`, `at_destination` + `door_open`.
**Robot:** Đứng yên, **không cố SLAM**, chờ tín hiệu thang.
### Giai đoạn 4: Ra cabin (tầng đích)
**Thang:** Giữ cửa mở cho đến khi robot báo `exited`.
**Robot:** Ra đến relocalization zone, load map tầng đích, relocalize, sau đó `release_door`.
## 2.6 Thang như "cạnh" trong đồ thị điều hướng
```
Floor 1: [Room] --- [Elev1_Wait] ====ELEVATOR==== [Elev1_Exit]
|
Floor 3: [Room] --- [Elev1_Wait] ====ELEVATOR==== [Elev1_Exit]
|
Floor 5: [Room] --- [Elev1_Wait] ====ELEVATOR==== [Elev1_Exit]
```
**Thuộc tính của cạnh thang:**
- **Cost** = thời gian chờ + thời gian chạy + độ tin cậy
- **Constraint** = chỉ đi được khi `elevator.available == true`
- **Precondition** = robot tại `wait_point`, thang `ready`
- **Postcondition** = robot tại `exit_point` tầng đích, đã relocalize
## 2.7 Chức năng phụ quan trọng
- **Timeout & retry** — Gọi thang quá lâu → đổi thang hoặc hủy mission
- **Interlock an toàn** — Robot trong cabin, thang fault, lệch inside_pose
- **Multi-robot coordination** — Queue, gán elevator_id, tránh tranh cabin
- **Ghi log / audit** — Debug khi SLAM lệch sau khi ra thang
## 2.8 Phân chia trách nhiệm
| Việc | Ai làm |
|---|---|
| Biết vị trí trên hành lang từng tầng | Robot (SLAM/AMCL) |
| Lập đường trong tầng | Robot (local planner) |
| Vào/ra cabin không va chạm | Robot (local nav + odometry) |
| Relocalize sau khi ra | Robot |
| Chọn thang nào trong tòa nhà | Fleet / mission manager |
| Mở cửa, chạy tầng, báo trạng thái | Thang (qua BMS/API) |
## 2.9 Ví dụ luồng sự kiện đầy đủ
```
1. Mission: F2 → F5 qua Elevator B
2. Robot nav đến Elev_B_wait (F2) [SLAM hoạt động]
3. Robot → Elev: call(2)
4. Elev → Robot: moving_to_2
5. Elev → Robot: arrived, door_open
6. Robot → Elev: hold_door
7. Robot nav vào cabin (2m) [odom chính, SLAM phụ]
8. Robot → Elev: entered, ride_to(5)
9. Robot → Elev: release_door (cho đóng)
10. Elev: moving 2→5
11. Elev → Robot: floor=3,4... (optional)
12. Elev → Robot: arrived_5, door_open
13. Robot → Elev: hold_door
14. Robot nav ra + relocalize (F5) [SLAM bật lại]
15. Robot → Elev: exited, release_door
16. Robot nav đến đích trên map F5
```
---
# Tóm tắt
## Ba trụ cột của hệ thống
1. **Perception / Localization** — biết vị trí trên từng tầng
2. **Building integration** — điều khiển và đọc trạng thái thang máy
3. **Mission orchestration** — quy trình vào/ra thang và chuyển map
## Sáu chức năng cốt lõi của thang máy
1. **Transport** — chuyển tầng vật lý
2. **Gateway** — nối hai không gian/map SLAM tách biệt
3. **Access** — kiểm soát cửa và thời điểm vào/ra
4. **Signaling** — cung cấp trạng thái mà SLAM không suy ra được
5. **Resource** — tài nguyên dùng chung cần đặt chỗ và xếp hàng
6. **Safety envelope** — vùng vận hành có rủi ro cao, cần interlock
## Kết luận
> **AMR multi-floor không phải một SLAM map khổng lồ 3D**, mà là **nhiều map 2D + đồ thị kết nối qua thang máy + state machine điều phối thang + relocalization khi đổi tầng**.
Thang máy là **cầu nối có điều kiện** giữa các map — robot SLAM điều hướng trong từng tầng, còn thang đảm nhiệm **chuyển tầng + đồng bộ trạng thái + kiểm soát cửa**; localization được **tạm ngưng trong cabin****khôi phục sau khi ra**.

BIN
docs/AMR_SLAM_Thang_May.pdf Normal file

Binary file not shown.

View File

@@ -125,8 +125,9 @@ Rail trái → flyout submenu → content (`www/nav.js`).
| Module | Flyout (section → page) | File JS | Trạng thái |
|--------|-------------------------|---------|------------|
| **Dashboards** | Danh sách động → `dashboard` | `dashboard.js` | ✅ |
| **Setup** | missions, maps, sounds, transitions, users, user-groups, io-modules, paths, path-guides, build-robot → `config` | `*.js` | 🔶 |
| **Monitoring** | monitoring-log → `monitoring` | — | |
| **Setup** | missions, maps, sounds, transitions, users, user-groups, io-modules, paths, path-guides | `*.js` | 🔶 |
| **Robot+** | robot → `config`, simulation → `simulation` | `app.js` (config), — | 🔶 |
| **Monitoring** | analytics (mặc định), system log, error logs, hardware, safety, mission log → `monitoring` | — | ✅ |
| **System** | integrations → `integrations` | `integrations.js` | 🔶 |
| **Help** | help-api → `help` | — | ⏳ |
@@ -228,8 +229,415 @@ List, upload, nghe, play trên robot, CRUD (không xóa system sound). API: `/ap
### 4.5 Setup — Transitions — ✅
> **Tham chiếu MiR** (xem [SOURCES_MiR.md](SOURCES_MiR.md)):
> - MiR robot Reference Guide **rev 1.9** (03/2019, SW 2.6.0) — §4.1.4.3 *Switch Map*, §4.4 *Transitions*
> - MiR robot Reference Guide **v2.2** (02/2021) — bản sửa cách diễn đạt, tr. 43 & 9092
> - **MiRFleet** Reference Guide SW 2.5.0 (01/2019) — §4.3 *Elevators*
> - **MiR Fleet Enterprise** v1.2 (01/2025) — tr. 107108 *Transitions*, tr. 325329 *Create transitions / Ramps / Elevators*
List, filter, pager, CRUD dialog (site, from/to map, start/goal position, mission). API: `/api/transitions`. File: `www/transitions.js`.
#### Nguyên lý hoạt động
**Mục đích.** Transition xử lý việc robot chuyển từ map này sang map khác trong **cùng một site** (ví dụ hai nhà xưởng kề nhau, mỗi nơi một map). Khi đã cấu hình, mission chỉ cần `move_to_position` tới đích trên map khác; hệ thống tự chèn chuỗi chuyển map — người lập mission không phải gọi `switch_map` thủ công ở mọi chỗ.
**Thành phần của một transition**
| Trường | Ý nghĩa |
|--------|---------|
| Site | Cả hai map phải thuộc cùng site |
| From map / To map | Map nguồn → map đích (chiều A→B và B→A là hai bản ghi / hai mission riêng) |
| Start position | Position kiểu robot trên **map nguồn**, tại điểm hai map chồng lên nhau |
| Goal position | Position kiểu robot trên **map đích**, **cùng vị trí vật lý** với Start (tên khác nhau, tọa độ thế giới trùng) |
| Mission | Mission chuyển map (thường chứa action `switch_map` và các bước liên quan cửa/PLC nếu cần) |
#### Phân tích chức năng lệnh Switch map (MiR §4.1 / dùng trong §4.4.1)
**Định nghĩa (MiR).** `Switch map` đổi bản đồ navigation đang dùng trong một mission khi site có nhiều map. Hai map phải có vùng overlap để robot định vị lại trên map mới. Action này là **nền tảng** của Transition: khi Transition đã cấu hình, hệ thống tự chèn switch khi mission chỉ lệnh đi sang position thuộc map khác.
> **Đính chính thuật ngữ (v1.9 → v2.2).** Bản 1.9 viết móc nối nhầm sang *Goal position*; bản **v2.2 sửa lại** cho đúng: điểm overlap phải khớp với **Entry position** của chính action Switch map.
>
> - v1.9: *"…physically overlaps the **Goal position** you select here"*
> - v2.2: *"The Switch map action must be preceded by a Move action to the position in the current map that physically overlaps the **Entry position in the other map** that the robot is switching to."*
>
> → Khi đối chiếu tài liệu cũ, hiểu **Goal (transition) ≡ Entry (action Switch map)** — cùng một điểm vật lý.
**Việc Switch map làm (ý nghĩa chức năng)**
| Khía cạnh | Nội dung |
|-----------|----------|
| Input ngữ cảnh | Robot đang đứng tại điểm chồng trên map **from** (đã `move` tới Start trước đó) |
| Thay đổi trạng thái | Đặt map **to** làm map active (bản đồ planner / localization) |
| Định vị lại sau đổi map | Dùng Entry/Goal position trên map **to** (cùng chỗ vật lý với Start trên map from) để khớp pose trên map mới |
| Đầu ra | Các lệnh `move_to_position` tiếp theo resolve theo map mới |
Không di chuyển cơ học xuyên tường/tầng: robot **đứng yên (hoặc gần điểm chồng)**, đổi “hệ tọa độ map”, rồi tiếp tục mission trên map đích.
**Mission Transition cần *mấy* Switch map? — MiR đổi quan điểm theo thời gian**
Đây là điểm **hai thế hệ tài liệu MiR nói khác nhau**, cần biết để không cấu hình thừa:
| Nguồn | Yêu cầu | Nguyên văn |
|-------|---------|------------|
| Reference Guide 1.9 (2019) | **Hai** Switch map | *"must include two Switch map actions: the first… the 'from' map and the defined start position, and the second… the 'to' map and the defined goal position"* |
| Reference Guide v2.2 (2021) | **Hai** Switch map | giữ nguyên diễn đạt trên |
| **Fleet Enterprise v1.2 (2025)** | **Một** Switch map | *"A transition mission that contains **a** Switch map action"* (tr. 107) · *"A transition mission must consist of **a** Switch map action that specifies which map the robot must switch to and which position it starts from"* (tr. 328) |
Mô hình **hai** action (đời cũ):
| # | Switch map | Ý nghĩa |
|---|------------|---------|
| 1 | From map + Start position | Định vị lại / khẳng định đang ở điểm chồng trên map nguồn trước khi đổi |
| 2 | To map + Entry (= Goal) position | Đổi sang map đích và định vị lại tại cùng điểm vật lý |
**Kết luận cho robot_app:** thiết kế hiện tại (**một** `switch_map` → map đích, Start/Goal do bản ghi Transition + runtime lo) **trùng khớp với hướng dẫn mới nhất của MiR (2025)**, không phải sai lệch so với chuẩn. Action đầu trong mô hình cũ chỉ mang tính "khẳng định vị trí" — runtime robot_app đã đảm bảo bằng bước *đi tới Start* trước khi chạy mission TR.
**Trường trên MiR (action editor)**
| Trường | Nội dung |
|--------|----------|
| **Entry Position** | Position trên map **to** — điểm robot "bắt đầu" sau khi chuyển map (tương đương Goal của Transition) |
| Biến | v2.2 cho phép chọn **XYZ icon** để đặt Entry Position thành **biến mission** (robot_app: chưa có) |
**Trên robot_app hiện tại**
| | MiR | robot_app |
|--|-----|-----------|
| Tham số UI | Entry position (+ ngữ cảnh from/to trong transition mission) | Chủ yếu `map_id` (`switch_map`) |
| Runtime | Đổi map nav + localize tại entry | `setActiveMap` / ghi `robot_runtime.active_map_id` |
| Vai trò với Transition | Nằm trong mission TR; auto-transition chèn quanh Start/Goal | Mission TR chạy `switch_map`; sau đó queue vẫn `setActiveMap(to)` và move Goal |
| Số `switch_map` trong mission TR | 1.9 / v2.2: **2** · Fleet Enterprise 2025: **1** | **1** `switch_map` → map đích; Start/Goal do Transition + runtime lo → **khớp chuẩn 2025** |
Triển khai: `MissionQueue` xử lý `type == "switch_map"``setActiveMapOnRobotRuntimeDoc`; auto-transition thêm một lần set map active sau mission TR.
**Khi nào dùng Switch map**
1. **Trong mission Transition** (khuyến nghị) — cùng I/O thang / cửa nếu cần.
2. **Trong mission vận hành thủ công** — ít dùng nếu đã có Transition (trùng / dễ lệch so với auto).
3. **Không thay** `move_to_position`: Switch map không đi từ A đến B; chỉ đổi map tại điểm chồng.
**Điều kiện thành công**
- Đã tới vùng overlap (Start) trước khi đổi map.
- Start/Goal cùng điểm vật lý, tên khác, map khác, cùng site.
- Map đích tồn tại; sau switch, localization khớp Goal/Entry.
**Điều kiện vật lý.** Start và Goal phải nằm đúng một điểm chồng (overlap) giữa hai map — robot dừng ngắn ở đó để định vị lại trên map mới trước khi tiếp tục đi.
**Luồng runtime (robot_app)** — khi mission đang chạy gặp `move_to_position` / `adjust_localization` mà position thuộc map khác map đang active:
```
Map A (đang active) Map B (đích)
───────────────── ─────────────
… đi bình thường
Position đích ∈ Map B?
Tìm transition A → B (TransitionStore.findBetween)
│ không có → lỗi "No transition configured"
1. Di chuyển tới Start (trên Map A)
2. Chạy Transition mission (không cho auto-transition lồng nhau)
3. switch_map → đặt Map B active
4. Định vị / di chuyển tới Goal (trên Map B)
5. Tiếp tục move_to_position tới đích gốc trên Map B
```
Triển khai: `MissionQueue::runAutoTransitionIfNeeded` trong `src/mission/mission_queue.cpp`. Transition mission chạy với `allow_auto_transition = false` để tránh vòng lặp.
**Tóm tắt hành vi người dùng.** Sau khi tạo transition trong UI (MiR §4.4.1): thiết kế mission như môi trường một map; khi queue chạy tới lệnh đi sang map khác, robot tự dừng tại điểm chồng, chạy mission chuyển map, đổi map, rồi tiếp tục — chỉ “nhìn thấy” khoảng dừng ngắn khi chuyển.
#### Trường hợp đặc biệt: khu chuyển map là thang máy
Hai tầng không “chồng sàn” như hai nhà xưởng. Điểm giao là **khu thang máy**. Với **thứ tự runtime hiện tại** (`đi tới Start` → chạy mission TR → `switch_map` → đi Goal), không nên đặt Start ngay trong cabin nếu cửa còn đóng — robot sẽ cố đi vào cabin **trước** khi gọi thang.
##### MiR chính thức nói gì về thang máy
Hai sản phẩm Fleet của MiR xử lý thang máy **ngược nhau** — đây là căn cứ quan trọng cho lựa chọn thiết kế của robot_app:
| Sản phẩm | Hỗ trợ thang máy | Cách làm |
|----------|------------------|----------|
| **MiRFleet SW 2.5.0** (2019) | ✅ **Có sẵn** (§4.3 *Elevators*) | Fleet tự "takes over", đưa robot vào/ra cabin đúng tầng và **tự đổi map nội bộ** |
| **MiR Fleet Enterprise v1.2** (2025) | ❌ **Đã bỏ** | *"MiR Fleet Enterprise **does not support elevators**. Use MiR Fleet Integration API to define your own elevator control."* |
Hướng dẫn của bản 2025 khi cần thang máy (tr. 311):
> *"For elevators, you must make a **custom elevator control system**. You can create **transition positions inside the elevator** on the map for each floor and **set up transitions for each floor shift**."*
**Cách robot_app đang làm — mission TR tự viết logic I/O gọi thang + `switch_map`, transition riêng cho mỗi chiều/tầng — chính là cách MiR khuyến nghị ở bản mới nhất.** Không phải giải pháp chắp vá.
**Mô hình elevator của MiRFleet 2.5.0** (tham khảo để đặt tên/bố trí position — xác nhận bố cục bên dưới là đúng chuẩn):
| Trường MiRFleet | Ý nghĩa | Tương ứng robot_app |
|-----------------|---------|---------------------|
| **Position in the elevator** | Position robot **bên trong cabin** | `Cabin_A` / `Cabin_B` |
| **Position in front of the elevator** | Position **ngoài cửa**, nơi robot **chờ thang tới và cửa mở** | `Tr_Start_AB` / `Tr_Goal_AB` |
| **Entry mission** / **Exit mission** | MiR tách **hai** mission: vào thang và ra thang | robot_app gộp trong **một** mission TR |
| **Floor** | Số tầng — **bắt buộc là số** (tầng trệt = `0`, hầm = `-1`) | Quy ước đặt tên map |
| **IP address** | IP hệ thống thang (do nhà tích hợp thang cung cấp) | Cấu hình I/O module |
| **Turn in place** | Cho phép robot xoay trong cabin hay không | Nới collision trong cabin |
| **Door** | 1 = xoay 180° ra cùng cửa vào · 2 = không xoay, ra cửa đối diện | Thiết kế Cabin/Goal theo hướng |
Lưu ý MiRFleet: map mỗi tầng **phải chứa hai elevator position** (trong cabin + trước cửa) thì mới chuyển map được — trùng với bố trí 4 position của robot_app.
**Bố trí position khuyến nghị (thang máy + robot_app)**
| Position | Map | Vị trí | Vai trò |
|----------|-----|--------|---------|
| Pos_A | Map_A | Điểm làm việc | Đích/điểm xuất phát vận hành |
| **Tr_Start_AB** | Map_A | **Trước cửa thang** (chờ) | Start của Transition — runtime đi tới đây trước |
| Cabin_A | Map_A | **Trong cabin** | Dùng trong mission TR (vào thang) |
| Cabin_B | Map_B | **Trong cabin** (cùng XY Cabin_A) | Định vị lại sau `switch_map` |
| **Tr_Goal_AB** | Map_B | **Trước cửa thang** tầng B | Goal của Transition — ra thang / chờ cửa |
| Pos_B | Map_B | Điểm làm việc | Đích vận hành |
**Cách viết mission**
1) Mission vận hành `Go_A_to_B` — chỉ việc “đi làm”, **không** viết cửa/cabin:
| # | Action | Position |
|---|--------|----------|
| 1 | `move_to_position` | Pos_A *(tuỳ chọn)* |
| 2 | `move_to_position` | **Pos_B** |
Khi tới bước 2, runtime thấy Pos_B ∈ Map_B → tự chạy Transition A→B rồi mới tới Pos_B.
2) Mission Transition `TR_MapA_to_MapB`**toàn bộ** cửa → vào → đi tầng → ra:
| # | Action | Nội dung |
|---|--------|----------|
| 1 | `set_digital_output` / PLC | Gọi thang / chọn tầng A (nếu cần) |
| 2 | `wait_digital_input` | Chờ thang tới, **cửa mở** |
| 3 | `move_to_position` | **Cabin_A** (vào thang) |
| 4 | I/O | Đóng cửa, lệnh đi **tầng B** |
| 5 | `wait_digital_input` | Chờ tới tầng B, cửa mở |
| 6 | `switch_map` | **Map_B** |
| 7 | `adjust_localization` hoặc `move_to_position` | **Cabin_B** (định vị lại trên map mới) |
| 8 | `move_to_position` | Ra gần cửa / **Tr_Goal_AB** *(tuỳ chọn — runtime cũng đi Goal sau)* |
3) Transition record: Start = `Tr_Start_AB`, Goal = `Tr_Goal_AB`, Mission = `TR_MapA_to_MapB`.
**Chuỗi thực tế khi bấm Dashboard**
```
Pos_A
→ (ops) move Pos_B kích hoạt Transition
→ runtime: tới Tr_Start_AB (cửa tầng A)
→ TR: gọi thang → vào Cabin_A → đi tầng → switch_map → Cabin_B → ra cửa
→ runtime: tới Tr_Goal_AB (cửa tầng B)
→ (ops) tiếp tục tới Pos_B
```
**Lưu ý**
- Cabin_A / Cabin_B vẫn là điểm **định vị lại** an toàn trong cabin; Tr_Start/Goal đặt **ngoài cửa** cho khớp thứ tự “đi Start trước khi chạy TR”.
- Chiều B→A: mission TR + transition riêng.
- I/O module thang phải cấu hình trước (Setup → I/O modules).
- Cabin hẹp: nới collision / speed zone trong cabin.
**Lưu ý vận hành**
- Cần **hai** transition (và thường hai mission) nếu muốn đi hai chiều A↔B.
- Chưa có transition A→B mà mission yêu cầu position trên B → mission **fail**.
- Transition được export/import theo site bundle cùng maps.
- Quyền: resource `maps` (xem ma trận auth §2).
- Qua thang: lỗi I/O / tín hiệu cửa không tới → mission dừng ở bước Wait; không tự retry tầng khác trừ khi viết Try/Catch (chưa đủ trên robot_app).
**Nguyên tắc thiết kế map & transition** (MiR Fleet Enterprise 2025, tr. 325)
| Nguyên tắc | Lý do |
|------------|-------|
| Cắt map tại nơi có **một lối vào duy nhất** | Robot buộc phải đi qua transition position để đổi map → cắt ở chỗ thắt cổ chai là tự nhiên nhất |
| Được phép tạo **nhiều transition** giữa cùng cặp map | Nhưng mỗi cái tốn công cấu hình + bảo trì → chỉ thêm khi thật cần |
| **Bắt buộc** transition cho **cả hai chiều** | Hai chiều có thể **cùng** một chỗ hoặc **khác** chỗ |
| Transition hướng **theo chiều luồng giao thông** | Tránh robot đi ngược dòng tại điểm giao |
| Muốn kiểm soát chỗ robot chuyển vùng → **chia nhỏ map** | Kết hợp map zone để điều tiết planner/traffic |
**Ramp (dốc nối tầng).** MiR xếp ramp cùng nhóm với thang máy — *"Transitions are often also used when changing floors with ramps or elevators."* Nếu robot không tự plan qua dốc, MiR khuyên thêm **Access zone** phủ lên vạch đỏ phát hiện sàn ở chân dốc. robot_app: chưa có zone loại Access.
#### Hướng dẫn cài đặt: gọi robot từ điểm A (map A) → điểm B (map B)
Mục tiêu: operator bấm một nút trên Dashboard → robot đi từ **Pos_A** trên **Map_A** tới **Pos_B** trên **Map_B**. Transition A→B chạy tự động khi mission yêu cầu position sang map khác.
##### Bước 1 — Thiết kế map
1. **Setup → Maps**: tạo (hoặc chọn) **một Site** chứa cả Map_A và Map_B.
2. Tạo **Map_A****Map_B**; upload ảnh / occupancy / YAML nếu có; vẽ floor, tường, forbidden…
3. Trên **Map_A** tạo các robot position:
- **Pos_A** — điểm xuất phát (điểm A).
- **Tr_Start_AB** — điểm chồng / chờ chuyển map (sàn kề: vùng overlap; **thang máy: trước cửa thang**).
- *(Thang máy)* **Cabin_A** — trong cabin, dùng trong mission TR.
4. Trên **Map_B** tạo:
- **Tr_Goal_AB** — điểm chồng tương ứng (thang máy: trước cửa tầng B).
- *(Thang máy)* **Cabin_B** — trong cabin, cùng XY với Cabin_A.
- **Pos_B** — điểm đích (điểm B).
5. Lưu map. Đảm bảo map active ban đầu là Map_A khi robot đang đứng ở Pos_A (định vị / Adjust localization nếu cần).
> **Cách tạo cặp Start/Goal cho *đúng cùng một điểm vật lý*** — quy trình MiR Fleet Enterprise 2025 (tr. 327). Đây là bước dễ sai nhất: gõ tay toạ độ hai map hầu như luôn lệch.
>
> | # | Thao tác |
> |---|----------|
> | 1 | Định vị robot chuẩn trên **Map_A** (Adjust localization) |
> | 2 | **Lái robot tới đúng điểm overlap** giữa hai map |
> | 3 | Xoay robot **hướng đúng chiều đi vào khu vực mới** |
> | 4 | ⚠️ **Từ đây KHÔNG di chuyển robot nữa** cho tới hết bước 7 |
> | 5 | Trên Map_A: tạo Robot position **từ vị trí hiện tại của robot** → `Tr_Start_AB` |
> | 6 | **Kích hoạt Map_B**, định vị lại robot **mà không dời robot** |
> | 7 | Trên Map_B: tạo Robot position **từ vị trí hiện tại** → `Tr_Goal_AB` |
>
> Đặt tên position thể hiện rõ nó thuộc transition nào. MiR: *"The transitions must point in the same direction as the traffic flow."*
> Thang máy: logic gọi thang / vàora cabin viết trong mission TR (Bước 2), không viết trong mission vận hành.
##### Bước 2 — Tạo mission chuyển map (dùng cho Transition)
**Setup → Missions** → tạo mission riêng, ví dụ `TR_MapA_to_MapB` (không phải mission vận hành hàng ngày).
Nội dung tối thiểu (sàn kề nhau):
| # | Action | Tham số gợi ý |
|---|--------|----------------|
| 1 | `switch_map` | Map = Map_B |
| 2 | (tuỳ chọn) `adjust_localization` / `move_to_position` | Tr_Goal_AB |
Nội dung kiểu thang máy — xem bảng đầy đủ ở mục *Khu chuyển map là thang máy*; tóm tắt:
`gọi thang → chờ cửa → move Cabin_A → đi tầng → chờ cửa → switch_map Map_B → Cabin_B → (ra cửa)`
Lưu mission. Chiều ngược B→A cần mission + transition riêng nếu muốn đi hai chiều.
##### Bước 3 — Tạo Transition
**Setup → Transitions → Create transition:**
| Trường | Giá trị |
|--------|---------|
| Site | Site chứa Map_A, Map_B |
| From map | Map_A |
| To map | Map_B |
| Start position | Tr_Start_AB |
| Goal position | Tr_Goal_AB |
| Mission | `TR_MapA_to_MapB` |
Lưu. Không có bản ghi này → mission vận hành sẽ lỗi *No transition configured* khi đi sang Map_B.
##### Bước 4 — Tạo mission vận hành A → B
**Setup → Missions** → tạo ví dụ `Go_A_to_B`:
| # | Action | Tham số |
|---|--------|---------|
| 1 | `move_to_position` | Pos_A (tuỳ chọn — đảm bảo đứng đúng điểm A) |
| 2 | `move_to_position` | **Pos_B** |
Chỉ cần gọi Pos_B trên Map_B. Runtime tự:
`… → tới Tr_Start_AB → chạy TR_MapA_to_MapB → switch map → Tr_Goal_AB → tới Pos_B`.
Không nhúng toàn bộ logic chuyển map vào `Go_A_to_B` (trừ khi cố ý không dùng Transition).
##### Bước 5 — Dashboard gọi robot
1. **Dashboards** → tạo / mở dashboard (vd. *Operator*).
2. Chế độ **Edit****Add widget**:
- `mission_button` — chọn mission `Go_A_to_B`, đặt nhãn nút (vd. «A → B»).
- Nên thêm `mission_queue`, `pause_continue`, (tuỳ chọn) `map` / `mission_action_log`.
3. Gán **Permissions** user group được phép xem/dùng dashboard.
4. Lưu → chuyển **Operate**.
Operator bấm nút → `POST /api/mission_queue` → robot_app chạy queue. Có thể kiểm tra tại Setup → Missions (panel queue) hoặc Monitoring → Mission log.
##### Sơ đồ tổng thể
```
[Map design] Pos_A, Tr_Start_AB trên Map_A
Tr_Goal_AB, Pos_B trên Map_B
[Mission TR] TR_MapA_to_MapB (switch_map ± I/O thang)
[Transition] Map_A → Map_B, Start/Goal, gắn mission TR
[Mission ops] Go_A_to_B: move → Pos_A?, move → Pos_B
[Dashboard] mission_button → queue → robot chạy A→B (auto transition)
```
##### Kiểm tra nhanh
| Kiểm tra | Kỳ vọng |
|----------|---------|
| Bấm nút Dashboard | Entry xuất hiện trên mission queue |
| Robot đang Map_A, đích Pos_B | Log: Auto transition Map_A → Map_B |
| Thiếu Transition | Lỗi *No transition configured* |
| Sai I/O thang | Dừng ở Wait for input trong mission TR |
##### Logic vận hành (runtime)
Thứ tự khi operator gọi robot đi **Pos_A (Map_A) → Pos_B (Map_B)**:
```
Operator bấm mission_button (Go_A_to_B)
POST /api/mission_queue → entry status = pending
MissionQueue worker (FIFO)
lấy entry pending đầu tiên → status = executing
runner.state = running
Thực thi từng action của Go_A_to_B
```
**Action 1 — `move_to_position` Pos_A** (nếu có)
1. Resolve Pos_A; so sánh `map_id` với map đang active.
2. Cùng Map_A → đi thẳng tới Pos_A; cập nhật `last_position_id`.
3. Khác map → kích hoạt auto-transition trước (xem dưới), rồi mới hoàn tất move.
**Action 2 — `move_to_position` Pos_B** (điểm mấu chốt)
1. Resolve Pos_B → thuộc **Map_B**; active đang là **Map_A** → khác map.
2. Gọi `runAutoTransitionIfNeeded(Map_A, Map_B)`:
- Không tìm thấy transition A→B → **fail** (`No transition configured`).
- Có transition:
1. Di chuyển tới **Start** (`Tr_Start_AB`) trên Map_A
2. Chạy mission gắn transition (`TR_MapA_to_MapB`) với `allow_auto_transition=false` (không lồng transition)
— trong đó: I/O thang (nếu có), `switch_map`, …
3. Runtime đặt map active = **Map_B** (sau mission TR)
4. Di chuyển tới / định vị lại tại **Goal** (`Tr_Goal_AB`) trên Map_B
3. Sau transition: hoàn tất `move_to_position` tới **Pos_B** trên Map_B.
4. `last_position_id` = Pos_B.
**Kết thúc mission**
| Kết quả | Hành vi |
|---------|---------|
| Thành công | entry = finished / done; runner → idle (nếu hết queue); ghi Mission log (persisted) |
| Cancel (topbar / pause_continue) | dừng giữa action; entry hủy |
| Lỗi (thiếu transition, I/O, switch_map…) | runner.state = error; ghi error log |
**Pause / Continue / Cancel**
- Queue chỉ lấy entry `pending` khi không `paused`.
- Pause: dừng nhận / tạm dừng runner; Continue: tiếp tục.
- Cancel: hủy mission đang `executing`.
**Song song nhiều lệnh**
- Nhiều lần bấm nút → nhiều entry **pending** xếp hàng; chỉ **một** mission chạy tại một thời điểm (FIFO).
**Topbar / Dashboard phản ánh**
- Strip mission + state: từ `GET /api/robot/status` / runner.
- Widget `mission_queue`: danh sách pending/executing.
- Widget `mission_action_log`: vài dòng log action đang chạy.
- Monitoring → Mission log: lịch sử sau khi chạy xong.
### 4.6 Setup — Users — ✅
List, filter, CRUD, PIN 4 số. API: `/api/users`. File: `www/users.js`.
@@ -276,11 +684,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 +757,22 @@ File: `www/integrations.js`.
**Chưa có:** Settings, Backups, PLC registers UI, Processes, Software versions; sửa trigger/schedule (chỉ add/delete).
### 6.1 System — Settings — ✅
Trang Settings theo style MiR (card/section + Apply). Frontend: `www/settings.js`.
**Nhóm Logging & retention:**
- `retention_mission_runs`: số mission runs lịch sử tối đa (bounded history; ảnh hưởng Mission log Phase 3/4).
- `retention_system_log`: số dòng system log tối đa.
- `retention_error_logs`: số dòng error log tối đa.
- `log_level`: `error|warn|info|debug` (hiện mới dùng làm setting; chưa ảnh hưởng sâu tới backend logging).
**API:**
- `GET /api/settings`
- `PUT /api/settings`
**Data:** document SQLite `settings`. Retention được áp dụng khi ghi log/runs mới (trim tự động).
---
## 7. Help — ⏳
@@ -333,13 +804,13 @@ Xác thực: session cookie hoặc Bearer (ưu tiên session web); tích hợp n
## 9. Tóm tắt triển khai
```
✅ Hoàn thiện tốt: Sign in, Users, User groups, Sounds, Transitions, Path guides
✅ Hoàn thiện tốt: Sign in, Users, User groups, Sounds, Transitions, Path guides, Monitoring
🔶 Đang dùng được: Dashboard, Missions, Maps, I/O, Paths, Integrations, Topbar, Build robot
⏳ Placeholder: Monitoring, Help, Robot summary widget, PLC tab
⏳ Placeholder: Help, Robot summary widget, PLC tab
❌ Chưa bắt đầu: Shelf types, Analytics/Error logs/Hardware/Safety, System Settings/Backups
```
**File frontend chính:** `index.html`, `nav.js`, `topbar.js`, `auth.js`, `app.js`, `dashboard.js`, `missions.js`, `maps.js`, `map-editor.js`, `sounds.js`, `transitions.js`, `users.js`, `user-groups.js`, `io-modules.js`, `paths.js`, `path-guides.js`, `integrations.js`, `style.css`, `i18n.js`.
**File frontend chính:** `index.html`, `nav.js`, `topbar.js`, `auth.js`, `app.js`, `dashboard.js`, `missions.js`, `maps.js`, `map-editor.js`, `sounds.js`, `transitions.js`, `users.js`, `user-groups.js`, `io-modules.js`, `paths.js`, `path-guides.js`, `integrations.js`, `monitoring.js`, `style.css`, `i18n.js`.
---

31
docs/SOURCES_MiR.md Normal file
View File

@@ -0,0 +1,31 @@
# Nguồn tài liệu MiR (Mobile Industrial Robots)
Tải ngày **2026-07-23**. Chủ đề nghiên cứu: **action Switch map / Transitions / điều hướng đa tầng**.
## Tài liệu đã tải
| File | Mô tả | Phiên bản / Ngày | Nguồn |
|------|-------|------------------|-------|
| `Reference guide.pdf` | MiR robot Reference Guide — tài liệu gốc, đầy đủ nhất về Switch map & Transitions (mục 4.1.4.3, 4.4) | rev 1.9 — 03/2019 (SW 2.6.0) | [cdn.kyklo.co](https://cdn.kyklo.co/assets/W1siZiIsIjIwMTkvMTAvMTYvMTgvNTgvNTYvMGNkOTJiZGQtZGYzMS00OTVmLThlMGEtMWUwODhiNzQ1ODgwL01pUiUyMC0lMjBNaVIlMjByb2JvdCUyMHJlZmVyZW5jZSUyMGd1aWRlLnBkZiJdXQ?sha=9b51843b3f3b3c7f) |
| `mir_reference_guide_2.2_2021_en.pdf` | MiR robot Reference Guide bản mới hơn | v2.2 — 02/2021 | [supportportal.mobile-industrial-robots.com](https://supportportal.mobile-industrial-robots.com/support-files/manuals/html/en/ref_guide_robots_2.2/content/_resources/pdf/mir%C2%A0robot%20reference%20guide%202.2_en.pdf) |
| `mir_fleet_enterprise_1.2_2025_en.pdf` | MiR Fleet Enterprise — điều hướng đa tầng, thang máy, REST API | v1.2 — 01/2025 | [jk.de](https://jk.de/media/a2/50/ce/1738939330/mir_fleet_enterprise_documentation_1.2_en.pdf?ts=1738939330) |
| `mir_fleet_reference_guide_sw2.5.0_2019_en.pdf` | MiRFleet Reference Guide — mục Elevators, quản lý nhiều robot/nhiều map | SW 2.5.0 — 01/2019 | [iptech1.com](https://iptech1.com/wp-content/uploads/2019/01/mirfleet_reference_guide_sw250_rev10.pdf) |
| `mir250_user_guide_11_en.pdf` | MiR250 User Guide (đã có sẵn từ trước) | v1.1 | — |
| `mir_software_official_page.html` | Trang chính thức MiR Software (xác nhận tính năng "switch maps fast") | — | [mobile-industrial-robots.com](https://mobile-industrial-robots.com/solutions/mir-applications/mir-software) |
| `neff_mir_positions_and_missions.html` | Hướng dẫn thực hành: Positions & Missions với MiR AMR | — | [neffautomation.com](https://neffautomation.com/blog/mir-amrs-positions-and-missions) |
## Mật độ từ khóa (số lần xuất hiện)
| File | `switch map` | `elevator` | `transition` |
|------|--------------|------------|--------------|
| `mir_reference_guide_2.2_2021_en.pdf` | 10 | 3 | 25 |
| `mir_fleet_enterprise_1.2_2025_en.pdf` | 2 | 11 | 70 |
| `mir_fleet_reference_guide_sw2.5.0_2019_en.pdf` | 9 | 28 | 21 |
→ Tra cứu **Switch map action**: ưu tiên `Reference guide.pdf` (1.9) và `mir_reference_guide_2.2_2021_en.pdf`.
→ Tra cứu **thang máy / đa tầng**: ưu tiên `mir_fleet_reference_guide_sw2.5.0_2019_en.pdf``mir_fleet_enterprise_1.2_2025_en.pdf`.
## Ghi chú
- Bản v1.9 trên `cdn.kyklo.co` trả về **HTTP 403** khi tải bằng curl, nhưng file `Reference guide.pdf` sẵn có trong thư mục đã đúng là bản này (đã xác minh trang bìa: *Revision 1.9 / 2019/03*).
- `manuals.plus` (Getting started, MiR200 REST API) chặn truy cập tự động (**403**) — cần mở thủ công bằng trình duyệt nếu cần.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,8 @@
#include "mission/mission_scheduler.hpp"
#include "mission/mission_store.hpp"
#include "mission/modbus_trigger_service.hpp"
#include "settings/settings_service.hpp"
#include "monitoring/monitoring_service.hpp"
#include "robot/robot_runtime.hpp"
#include "server/api_server.hpp"
#include "server/static_file_server.hpp"
@@ -77,7 +79,11 @@ int RobotApp::run()
MissionStore mission_store(database);
MissionQueue mission_queue(database, map_store, transition_store, mission_store, io_module_service, io_zone_runtime,
path_service);
SettingsService settings(database);
MonitoringService monitoring(database);
RobotRuntime robot_runtime(database, mission_queue);
robot_runtime.setMonitoring(&monitoring);
mission_queue.setMonitoring(&monitoring);
SiteStore site_store(database);
site_store.ensureDefaultSiteId();
SoundStore sound_store(database);
@@ -120,7 +126,9 @@ int RobotApp::run()
path_store,
path_guide_store,
path_service,
dashboard_store);
dashboard_store,
monitoring,
settings);
api.registerRoutes(svr);
auth.registerRoutes(svr);
StaticFileServer::mount(svr, www_root_);

View File

@@ -225,7 +225,8 @@ std::optional<std::string> AuthService::resourceForApiPath(const std::string& pa
return "missions";
if (path.rfind("/api/triggers", 0) == 0 || path.rfind("/api/schedules", 0) == 0 ||
path.rfind("/api/robots", 0) == 0 || path.rfind("/api/fleet", 0) == 0 ||
path.rfind("/api/modbus", 0) == 0 || path.rfind("/api/v2.0.0/", 0) == 0)
path.rfind("/api/modbus", 0) == 0 || path.rfind("/api/v2.0.0/", 0) == 0 ||
path.rfind("/api/settings", 0) == 0)
return "integrations";
if (path.rfind("/api/dashboards", 0) == 0)
return "dashboard";

View File

@@ -2,6 +2,7 @@
#include "io/io_module_service.hpp"
#include "io/io_zone_runtime.hpp"
#include "monitoring/monitoring_service.hpp"
#include "path/path_service.hpp"
#include "mission/mission_store.hpp"
#include "mission/position_resolver.hpp"
@@ -489,6 +490,11 @@ void MissionQueue::runMissionActions(nlohmann::json& entry)
setRunnerState("idle", "Hoàn thành: " + entry.value("mission_name", "Mission"));
saveUnlocked();
}
if (monitoring_)
{
std::string mon_err;
monitoring_->recordMissionRun(entry, mon_err);
}
}
catch (const MissionCancelled&)
{
@@ -501,9 +507,15 @@ void MissionQueue::runMissionActions(nlohmann::json& entry)
setRunnerState("idle", "Đã hủy: " + entry.value("mission_name", "Mission"));
saveUnlocked();
}
if (monitoring_)
{
std::string mon_err;
monitoring_->recordMissionRun(entry, mon_err);
}
}
catch (...)
{
entry["log"] = log;
entry["status"] = "failed";
entry["finished_at"] = IdUtil::nowIso8601();
{
@@ -511,6 +523,16 @@ void MissionQueue::runMissionActions(nlohmann::json& entry)
setRunnerState("error", "Lỗi khi chạy: " + entry.value("mission_name", "Mission"));
saveUnlocked();
}
if (monitoring_)
{
{
std::string mon_err;
monitoring_->recordMissionRun(entry, mon_err);
}
monitoring_->logError("/Mission",
"Mission failed: " + entry.value("mission_name", "Mission"),
nlohmann::json{{"entry_id", entry.value("id", "")}, {"log", log}});
}
}
cancel_ = false;
}

View File

@@ -17,6 +17,7 @@ class IoModuleService;
class IoZoneRuntime;
class MapStore;
class MissionStore;
class MonitoringService;
class PathService;
class TransitionStore;
@@ -46,6 +47,8 @@ public:
bool resume(std::string& err);
bool cancel(std::string& err);
void setMonitoring(MonitoringService* monitoring) { monitoring_ = monitoring; }
private:
enum class LoopControl { None, Break, Continue };
@@ -56,6 +59,7 @@ private:
IoModuleService& io_modules_;
IoZoneRuntime& io_zones_;
PathService& paths_;
MonitoringService* monitoring_ = nullptr;
PositionResolver position_resolver_;
mutable std::recursive_mutex mu_;
nlohmann::json queue_;

View 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

View 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

View File

@@ -1,6 +1,7 @@
#include "robot/robot_runtime.hpp"
#include "mission/mission_queue.hpp"
#include "monitoring/monitoring_service.hpp"
#include "storage/database.hpp"
#include "util/id_util.hpp"
@@ -17,7 +18,7 @@ constexpr const char* kDefaultMessage = "Waiting for new missions...";
} // namespace
RobotRuntime::RobotRuntime(Database& db, MissionQueue& mission_queue)
: db_(db), mission_queue_(mission_queue)
: db_(db), mission_queue_(mission_queue), last_tick_(std::chrono::steady_clock::now())
{
load();
ensureDefaultsUnlocked();
@@ -67,6 +68,8 @@ void RobotRuntime::ensureDefaultsUnlocked()
state_["active_map_id"] = nullptr;
if (!state_.contains("pose") || !state_["pose"].is_object())
state_["pose"] = {{"x", 0.0}, {"y", 0.0}, {"yaw", 0.0}};
if (!state_.contains("safety") || !state_["safety"].is_object())
state_["safety"] = {{"emergency_stop", "released"}, {"front_scanner", "free"}, {"rear_scanner", "free"}};
saveUnlocked();
}
@@ -110,6 +113,7 @@ nlohmann::json RobotRuntime::buildStatusUnlocked() const
{"cmd_angular", state_.value("cmd_angular", 0.0)},
{"active_map_id", state_.contains("active_map_id") ? state_["active_map_id"] : nullptr},
{"pose", state_.contains("pose") ? state_["pose"] : nlohmann::json::object({{"x", 0.0}, {"y", 0.0}, {"yaw", 0.0}})},
{"safety", state_.contains("safety") ? state_["safety"] : nlohmann::json::object()},
{"runner", runner},
{"queue_pending", pending},
{"updated_at", state_.value("updated_at", "")}};
@@ -135,6 +139,8 @@ bool RobotRuntime::start(std::string& err)
state_["message"] = "Robot running";
state_["updated_at"] = IdUtil::nowIso8601();
saveUnlocked();
if (monitoring_)
monitoring_->logSystem("ok", "Robot", "Robot started");
const std::string runner_state = mission_queue_.runnerStatus().value("state", "idle");
if (runner_state == "paused")
@@ -152,6 +158,8 @@ bool RobotRuntime::pause(std::string& err)
state_["cmd_angular"] = 0.0;
state_["updated_at"] = IdUtil::nowIso8601();
saveUnlocked();
if (monitoring_)
monitoring_->logSystem("warn", "Robot", "Robot paused");
const std::string runner_state = mission_queue_.runnerStatus().value("state", "idle");
if (runner_state == "running" || runner_state == "paused")
@@ -168,6 +176,8 @@ bool RobotRuntime::resetError(std::string& err)
state_["message"] = kDefaultMessage;
state_["updated_at"] = IdUtil::nowIso8601();
saveUnlocked();
if (monitoring_)
monitoring_->logSystem("ok", "Robot", "Error state cleared");
return true;
}
@@ -288,6 +298,11 @@ bool RobotRuntime::queueSoundPlay(const std::string& sound_id, int volume, std::
void RobotRuntime::tick()
{
std::lock_guard<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 joy = state_.value("joystick_engaged", false);
double battery = state_.value("battery_percent", 54.0);
@@ -300,6 +315,13 @@ void RobotRuntime::tick()
battery = std::min(100.0, battery + 0.005);
state_["battery_percent"] = static_cast<int>(std::lround(battery));
if (monitoring_)
{
nlohmann::json status = buildStatusUnlocked();
monitoring_->onRobotTick(status, dt);
}
saveUnlocked();
}

View File

@@ -2,6 +2,7 @@
#include <nlohmann/json.hpp>
#include <chrono>
#include <mutex>
#include <string>
@@ -9,12 +10,15 @@ namespace lm {
class Database;
class MissionQueue;
class MonitoringService;
class RobotRuntime
{
public:
explicit RobotRuntime(Database& db, MissionQueue& mission_queue);
void setMonitoring(MonitoringService* monitoring) { monitoring_ = monitoring; }
nlohmann::json status() const;
bool start(std::string& err);
bool pause(std::string& err);
@@ -29,8 +33,10 @@ public:
private:
Database& db_;
MissionQueue& mission_queue_;
MonitoringService* monitoring_ = nullptr;
mutable std::mutex mu_;
nlohmann::json state_;
std::chrono::steady_clock::time_point last_tick_;
void load();
void saveUnlocked() const;

View 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

View File

@@ -3,6 +3,7 @@
#include "domain/layout_profile.hpp"
#include "domain/layout_schema.hpp"
#include "mission/mission_enqueue.hpp"
#include "monitoring/monitoring_service.hpp"
#include "util/http_util.hpp"
#include "util/id_util.hpp"
#include "util/string_util.hpp"
@@ -25,7 +26,9 @@ ApiServer::ApiServer(StateRepository& repo,
PathStore& path_store,
PathGuideStore& path_guide_store,
PathService& path_service,
DashboardStore& dashboard_store)
DashboardStore& dashboard_store,
MonitoringService& monitoring,
SettingsService& settings)
: repo_(repo),
mission_queue_(mission_queue),
mission_store_(mission_store),
@@ -41,7 +44,9 @@ ApiServer::ApiServer(StateRepository& repo,
path_store_(path_store),
path_guide_store_(path_guide_store),
path_service_(path_service),
dashboard_store_(dashboard_store)
dashboard_store_(dashboard_store),
monitoring_(monitoring),
settings_(settings)
{
}
@@ -567,6 +572,8 @@ void ApiServer::registerRoutes(httplib::Server& svr)
registerPathRoutes(svr);
registerPathGuideRoutes(svr);
registerDashboardRoutes(svr);
registerMonitoringRoutes(svr);
registerSettingsRoutes(svr);
}
} // namespace lm

View File

@@ -9,6 +9,8 @@
#include "robot/robot_runtime.hpp"
#include "storage/dashboard_store.hpp"
#include "storage/map_store.hpp"
#include "monitoring/monitoring_service.hpp"
#include "settings/settings_service.hpp"
#include "storage/site_store.hpp"
#include "storage/sound_store.hpp"
#include "storage/io_module_store.hpp"
@@ -39,7 +41,9 @@ public:
PathStore& path_store,
PathGuideStore& path_guide_store,
PathService& path_service,
DashboardStore& dashboard_store);
DashboardStore& dashboard_store,
MonitoringService& monitoring,
SettingsService& settings);
void registerRoutes(httplib::Server& svr);
@@ -60,6 +64,8 @@ private:
PathGuideStore& path_guide_store_;
PathService& path_service_;
DashboardStore& dashboard_store_;
MonitoringService& monitoring_;
SettingsService& settings_;
bool enqueueRequest(const nlohmann::json& request, httplib::Response& res, int status_code = 201);
std::optional<nlohmann::json> enqueueMission(const nlohmann::json& request, std::string& err);
@@ -74,6 +80,8 @@ private:
void registerPathRoutes(httplib::Server& svr);
void registerPathGuideRoutes(httplib::Server& svr);
void registerDashboardRoutes(httplib::Server& svr);
void registerMonitoringRoutes(httplib::Server& svr);
void registerSettingsRoutes(httplib::Server& svr);
};
} // namespace lm

View 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

View 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

View 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

View File

@@ -185,6 +185,29 @@ CREATE TABLE IF NOT EXISTS dashboard_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
active_dashboard_id TEXT
);
CREATE TABLE IF NOT EXISTS mission_runs (
id TEXT PRIMARY KEY,
mission_id TEXT NOT NULL,
mission_name TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'ui',
status TEXT NOT NULL,
message TEXT NOT NULL DEFAULT '',
started_at TEXT,
finished_at TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS mission_run_actions (
id TEXT PRIMARY KEY,
run_id TEXT NOT NULL,
seq INTEGER NOT NULL,
action TEXT NOT NULL,
state TEXT NOT NULL,
message TEXT NOT NULL DEFAULT '',
ts TEXT,
FOREIGN KEY (run_id) REFERENCES mission_runs(id) ON DELETE CASCADE
);
)SQL";
} // namespace
@@ -508,6 +531,41 @@ bool Database::applySchemaMigrations(std::string& err)
setMeta("schema_version", "9");
}
ver = getMeta("schema_version").value_or("1");
if (ver == "9")
{
if (!execSql(db_,
"CREATE TABLE IF NOT EXISTS mission_runs ("
"id TEXT PRIMARY KEY, "
"mission_id TEXT NOT NULL, "
"mission_name TEXT NOT NULL, "
"source TEXT NOT NULL DEFAULT 'ui', "
"status TEXT NOT NULL, "
"message TEXT NOT NULL DEFAULT '', "
"started_at TEXT, "
"finished_at TEXT, "
"created_at TEXT NOT NULL"
")",
err))
return false;
if (!execSql(db_,
"CREATE TABLE IF NOT EXISTS mission_run_actions ("
"id TEXT PRIMARY KEY, "
"run_id TEXT NOT NULL, "
"seq INTEGER NOT NULL, "
"action TEXT NOT NULL, "
"state TEXT NOT NULL, "
"message TEXT NOT NULL DEFAULT '', "
"ts TEXT, "
"FOREIGN KEY (run_id) REFERENCES mission_runs(id) ON DELETE CASCADE"
")",
err))
return false;
setMeta("schema_version", "10");
}
return true;
}

View 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

View 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

View File

@@ -10,6 +10,7 @@ const pageConfigEl = el("pageConfig");
const pageMapsEl = el("pageMaps");
const pageMissionsEl = el("pageMissions");
const pageIntegrationsEl = el("pageIntegrations");
const pageSettingsEl = el("pageSettings");
const pageSoundsEl = el("pageSounds");
const pageTransitionsEl = el("pageTransitions");
const pageUsersEl = el("pageUsers");
@@ -19,6 +20,7 @@ const pagePathsEl = el("pagePaths");
const pagePathGuidesEl = el("pagePathGuides");
const pageMonitoringEl = el("pageMonitoring");
const pageHelpEl = el("pageHelp");
const pageSimulationEl = el("pageSimulation");
const contentEl = document.querySelector(".content");
const contentRightEl = el("contentRight");
const overviewBackendEl = el("overviewBackend");
@@ -131,7 +133,7 @@ const state = {
};
function setActivePage(page) {
const valid = ["dashboard", "config", "maps", "missions", "sounds", "transitions", "user-groups", "io-modules", "paths", "path-guides", "users", "integrations", "monitoring", "help"];
const valid = ["dashboard", "config", "maps", "missions", "sounds", "transitions", "user-groups", "io-modules", "paths", "path-guides", "users", "settings", "integrations", "monitoring", "simulation", "help"];
let p = valid.includes(page) ? page : "missions";
if (window.AuthApp && !window.AuthApp.canAccessPage(p)) {
const fallback = valid.find((v) => window.AuthApp.canAccessPage(v));
@@ -149,8 +151,10 @@ function setActivePage(page) {
if (pagePathsEl) pagePathsEl.hidden = p !== "paths";
if (pagePathGuidesEl) pagePathGuidesEl.hidden = p !== "path-guides";
if (pageUsersEl) pageUsersEl.hidden = p !== "users";
if (pageSettingsEl) pageSettingsEl.hidden = p !== "settings";
if (pageIntegrationsEl) pageIntegrationsEl.hidden = p !== "integrations";
if (pageMonitoringEl) pageMonitoringEl.hidden = p !== "monitoring";
if (pageSimulationEl) pageSimulationEl.hidden = p !== "simulation";
if (pageHelpEl) pageHelpEl.hidden = p !== "help";
if (configSplitterEl) configSplitterEl.hidden = p !== "config";
if (contentRightEl) contentRightEl.hidden = p !== "config";
@@ -166,8 +170,10 @@ function setActivePage(page) {
contentEl.classList.toggle("content--paths", p === "paths");
contentEl.classList.toggle("content--path-guides", p === "path-guides");
contentEl.classList.toggle("content--users", p === "users");
contentEl.classList.toggle("content--settings", p === "settings");
contentEl.classList.toggle("content--integrations", p === "integrations");
contentEl.classList.toggle("content--monitoring", p === "monitoring");
contentEl.classList.toggle("content--simulation", p === "simulation");
contentEl.classList.toggle("content--help", p === "help");
}
if (p === "missions" && window.MissionsApp) window.MissionsApp.onPageShow();
@@ -191,6 +197,10 @@ function setActivePage(page) {
else if (window.DashboardApp?.onPageHide) window.DashboardApp.onPageHide();
if (p === "integrations" && window.IntegrationsApp) window.IntegrationsApp.onPageShow();
else if (window.IntegrationsApp?.onPageHide) window.IntegrationsApp.onPageHide();
if (p === "settings" && window.SettingsApp) window.SettingsApp.onPageShow();
else if (window.SettingsApp?.onPageHide) window.SettingsApp.onPageHide();
if (p === "monitoring" && window.MonitoringApp) window.MonitoringApp.onPageShow();
else if (window.MonitoringApp?.onPageHide) window.MonitoringApp.onPageHide();
window.NavApp?.syncFromPage?.(p);
try {
localStorage.setItem("activePage", p);

View File

@@ -143,7 +143,7 @@
}
function canAccessPage(page) {
if (page === "config") return isDistributor();
if (page === "config" || page === "simulation") return isDistributor();
const map = {
dashboard: "dashboard",

View File

@@ -64,8 +64,10 @@
"nav.expand": "Mở menu",
"nav.dashboards": "Dashboards",
"nav.setup": "Setup",
"nav.robot-plus": "Robot+",
"nav.monitoring": "Monitoring",
"nav.system": "System",
"nav.settings": "Settings",
"nav.help": "Help",
"nav.logout": "Log out",
"nav.dashboard": "Dashboard",
@@ -79,8 +81,14 @@
"nav.paths": "Paths",
"nav.path-guides": "Path guides",
"nav.users": "Users",
"nav.build-robot": "Build Robot",
"nav.robot": "Robot",
"nav.simulation": "Simulation",
"nav.monitoring-log": "System log",
"nav.analytics": "Analytics",
"nav.error-logs": "Error logs",
"nav.hardware-health": "Hardware health",
"nav.safety-system": "Safety system",
"nav.mission-log": "Mission log",
"nav.integrations": "Tích hợp",
"nav.help-api": "API documentation",
@@ -884,9 +892,154 @@
"integrations.dialog.schedule.startTime": "Thời gian bắt đầu",
"integrations.schedule.runNow": "Chạy ngay",
"monitoring.log.title": "System log",
"monitoring.log.subtitle": "Monitoring → System log — nhật ký hệ thống (đang phát triển).",
"monitoring.log.placeholder": "Tính năng monitoring sẽ hiển thị log robot, cảnh báo và lịch sử mission tại đây.",
"monitoring.comingSoon": "Tính năng đang phát triển.",
"monitoring.refresh": "Làm mới",
"monitoring.clearFilters": "Xóa bộ lọc",
"monitoring.filterLabel": "Lọc:",
"monitoring.itemsFound": "{n} mục",
"monitoring.pageOf": "Trang {page} / {total}",
"monitoring.analytics.title": "Analytics",
"monitoring.analytics.subtitle": "Quãng đường robot đã chạy theo thời gian.",
"monitoring.analytics.helpTitle": "Trợ giúp Analytics",
"monitoring.analytics.helpBody": "Biểu đồ quãng đường (mét) theo ngày hoặc tháng. Chọn khoảng thời gian hoặc dùng nút preset. Dữ liệu tích lũy khi robot di chuyển (joystick/mission).",
"monitoring.analytics.startDate": "Start date",
"monitoring.analytics.endDate": "End date",
"monitoring.analytics.grouping": "Grouping",
"monitoring.analytics.perDay": "Per day",
"monitoring.analytics.perMonth": "Per month",
"monitoring.analytics.chartType": "Chart",
"monitoring.analytics.barChart": "Bar graph",
"monitoring.analytics.accumulated": "Accumulated",
"monitoring.analytics.presetWeek": "Current week",
"monitoring.analytics.preset7d": "Last 7 days",
"monitoring.analytics.preset30d": "Last 30 days",
"monitoring.analytics.preset365d": "Last 365 days",
"monitoring.analytics.totalPeriod": "Kỳ đã chọn: {n} m · Tổng tích lũy: {lifetime} m",
"monitoring.analytics.empty": "Không có dữ liệu quãng đường trong kỳ này.",
"monitoring.systemLog.title": "System log",
"monitoring.systemLog.subtitle": "Sự kiện từ các thành phần hệ thống.",
"monitoring.systemLog.filterPlaceholder": "Lọc theo module, message hoặc state...",
"monitoring.systemLog.colState": "State",
"monitoring.systemLog.colModule": "Module",
"monitoring.systemLog.colMessage": "Message",
"monitoring.systemLog.colTime": "Time",
"monitoring.systemLog.empty": "Chưa có sự kiện system log.",
"monitoring.systemLog.emptyFilter": "Không có sự kiện khớp bộ lọc.",
"monitoring.errorLogs.title": "Error logs",
"monitoring.errorLogs.subtitle": "Danh sách lỗi hệ thống đã phát hiện.",
"monitoring.errorLogs.filterPlaceholder": "Lọc theo mô tả hoặc module...",
"monitoring.errorLogs.colDescription": "Description",
"monitoring.errorLogs.colModule": "Module",
"monitoring.errorLogs.colTime": "Time",
"monitoring.errorLogs.colFunctions": "Functions",
"monitoring.errorLogs.generate": "Generate log",
"monitoring.errorLogs.deleteAll": "Delete all",
"monitoring.errorLogs.deleteAllConfirm": "Xóa toàn bộ error log?",
"monitoring.errorLogs.download": "Download",
"monitoring.errorLogs.delete": "Delete",
"monitoring.errorLogs.empty": "Chưa có lỗi được ghi.",
"monitoring.errorLogs.emptyFilter": "Không có lỗi khớp bộ lọc.",
"monitoring.hardware.title": "Hardware health",
"monitoring.hardware.subtitle": "Tình trạng phần cứng robot.",
"monitoring.hardware.empty": "Không có dữ liệu phần cứng.",
"monitoring.safety.title": "Safety system",
"monitoring.safety.subtitle": "Trạng thái E-stop và laser scanner (live).",
"monitoring.safety.estop": "Emergency stop",
"monitoring.safety.frontScanner": "Front scanner",
"monitoring.safety.rearScanner": "Rear scanner",
"monitoring.safety.estopReleased": "Released",
"monitoring.safety.estopActivated": "Activated",
"monitoring.safety.scannerFree": "Free",
"monitoring.safety.scannerBlocked": "Blocked",
"monitoring.missionLog.title": "Mission log",
"monitoring.missionLog.subtitle": "Mission đã chạy và đang chạy.",
"monitoring.missionLog.helpTitle": "Trợ giúp Mission log",
"monitoring.missionLog.helpBody": "Danh sách mission đã chạy/đang chạy. Bấm biểu tượng mắt để xem action log chi tiết. Phase 3: lịch sử được lưu bền vững (không mất khi xóa queue).",
"monitoring.missionLog.filterPlaceholder": "Lọc theo tên mission hoặc state...",
"monitoring.missionLog.colMission": "Mission",
"monitoring.missionLog.colState": "State",
"monitoring.missionLog.colMessage": "Message",
"monitoring.missionLog.colStart": "Start time",
"monitoring.missionLog.colRanFor": "Ran for",
"monitoring.missionLog.colStartedBy": "Started by",
"monitoring.missionLog.colFunctions": "Functions",
"monitoring.missionLog.empty": "Chưa có mission trong log.",
"monitoring.missionLog.emptyFilter": "Không có mission khớp bộ lọc.",
"monitoring.missionLog.viewActions": "Xem action log",
"monitoring.missionLog.clearHistory": "Xóa lịch sử",
"monitoring.missionLog.clearHistoryConfirm": "Xóa toàn bộ lịch sử mission log?",
"monitoring.missionLog.deleteRun": "Xóa",
"monitoring.missionLog.deleteRunConfirm": "Xóa mission này khỏi lịch sử?",
"monitoring.missionLog.downloadRun": "Tải log",
"monitoring.missionLog.running": "Đang chạy…",
"monitoring.missionLog.stateCompleted": "Hoàn thành",
"monitoring.missionLog.stateFailed": "Thất bại",
"monitoring.missionLog.stateCancelled": "Đã hủy",
"monitoring.missionLog.state.executing": "Executing",
"monitoring.missionLog.state.completed": "Completed",
"monitoring.missionLog.state.failed": "Failed",
"monitoring.missionLog.state.cancelled": "Cancelled",
"monitoring.missionLog.source.ui": "User interface",
"monitoring.missionLog.source.modbus": "Modbus",
"monitoring.missionLog.source.api": "REST API",
"monitoring.missionLog.source.schedule": "Scheduler",
"monitoring.actionLog.title": "Mission action log",
"monitoring.actionLog.back": "← Mission log",
"monitoring.actionLog.subtitleMission": "Mission: {name}",
"monitoring.actionLog.filterPlaceholder": "Lọc theo action, state hoặc message...",
"monitoring.actionLog.colAction": "Action",
"monitoring.actionLog.colState": "State",
"monitoring.actionLog.colMessage": "Message",
"monitoring.actionLog.colStart": "Start time",
"monitoring.actionLog.colRanFor": "Ran for",
"monitoring.actionLog.empty": "Chưa có action nào trong log.",
"monitoring.actionLog.emptyFilter": "Không có action khớp bộ lọc.",
"monitoring.actionLog.missingEntry": "Không tìm thấy mission.",
"monitoring.actionLog.state.executing": "Executing",
"monitoring.actionLog.level.info": "Info",
"monitoring.actionLog.level.warn": "Warning",
"monitoring.actionLog.level.error": "Error",
"monitoring.actionLog.level.user": "User",
"monitoring.actionLog.download": "Download",
"settings.title": "Settings",
"settings.subtitle": "System → Settings — cấu hình hệ thống robot.",
"settings.loading": "Đang tải settings…",
"settings.loaded": "Đã tải.",
"settings.apply": "Apply",
"settings.back": "Quay lại",
"settings.saving": "Đang lưu…",
"settings.saved": "Đã lưu.",
"settings.saveFailed": "Lưu thất bại.",
"settings.loggingRetention.title": "Logging & retention",
"settings.loggingRetention.subtitle": "Giới hạn lịch sử và mức độ log.",
"settings.retention.missionRuns": "Mission history (runs)",
"settings.retention.missionRunsHint": "Số lượng mission runs được lưu (lịch sử có giới hạn).",
"settings.retention.systemLog": "System log entries",
"settings.retention.systemLogHint": "Số dòng system log tối đa lưu trong DB.",
"settings.retention.errorLogs": "Error log entries",
"settings.retention.errorLogsHint": "Số dòng error log tối đa lưu trong DB.",
"settings.logLevel": "Log level",
"settings.logLevelHint": "Điều khiển mức độ chi tiết của system log.",
"settings.comingSoon.title": "More settings",
"settings.comingSoon.subtitle": "Time, network, security và backups sẽ nằm ở đây.",
"settings.tile.mapping": "Mapping",
"settings.tile.mappingSub": "Chỉnh cấu hình mapping.",
"settings.tile.errorHandling": "Error handling",
"settings.tile.errorHandlingSub": "Chỉnh xử lý lỗi của robot.",
"settings.tile.battery": "Battery",
"settings.tile.batterySub": "Cấu hình cho battery.",
"settings.tile.wifi": "WiFi",
"settings.tile.wifiSub": "Cấu hình kết nối WiFi.",
"settings.tile.dateTime": "Date & time",
"settings.tile.dateTimeSub": "Thiết lập thời gian hệ thống.",
"settings.tile.advanced": "Advanced",
"settings.tile.advancedSub": "Retention và logging.",
"simulation.title": "Simulation",
"simulation.subtitle": "Robot+ → Simulation — mô phỏng robot trong môi trường ảo.",
"simulation.comingSoon": "Tính năng đang phát triển.",
"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.",
@@ -952,8 +1105,10 @@
"nav.expand": "Expand menu",
"nav.dashboards": "Dashboards",
"nav.setup": "Setup",
"nav.robot-plus": "Robot+",
"nav.monitoring": "Monitoring",
"nav.system": "System",
"nav.settings": "Settings",
"nav.help": "Help",
"nav.logout": "Log out",
"nav.dashboard": "Dashboard",
@@ -967,8 +1122,14 @@
"nav.paths": "Paths",
"nav.path-guides": "Path guides",
"nav.users": "Users",
"nav.build-robot": "Build Robot",
"nav.robot": "Robot",
"nav.simulation": "Simulation",
"nav.monitoring-log": "System log",
"nav.analytics": "Analytics",
"nav.error-logs": "Error logs",
"nav.hardware-health": "Hardware health",
"nav.safety-system": "Safety system",
"nav.mission-log": "Mission log",
"nav.integrations": "Integrations",
"nav.help-api": "API documentation",
@@ -1772,9 +1933,154 @@
"integrations.dialog.schedule.startTime": "Start time",
"integrations.schedule.runNow": "Run now",
"monitoring.log.title": "System log",
"monitoring.log.subtitle": "Monitoring → System log — system log (coming soon).",
"monitoring.log.placeholder": "Monitoring will show robot logs, alerts and mission history here.",
"monitoring.comingSoon": "Feature coming soon.",
"monitoring.refresh": "Refresh",
"monitoring.clearFilters": "Clear filters",
"monitoring.filterLabel": "Filter:",
"monitoring.itemsFound": "{n} item(s) found",
"monitoring.pageOf": "Page {page} of {total}",
"monitoring.analytics.title": "Analytics",
"monitoring.analytics.subtitle": "Driven distance over time.",
"monitoring.analytics.helpTitle": "Analytics help",
"monitoring.analytics.helpBody": "Chart of driven distance (meters) per day or month. Select a date range or use a preset. Distance accumulates when the robot moves (joystick/missions).",
"monitoring.analytics.startDate": "Start date",
"monitoring.analytics.endDate": "End date",
"monitoring.analytics.grouping": "Grouping",
"monitoring.analytics.perDay": "Per day",
"monitoring.analytics.perMonth": "Per month",
"monitoring.analytics.chartType": "Chart",
"monitoring.analytics.barChart": "Bar graph",
"monitoring.analytics.accumulated": "Accumulated",
"monitoring.analytics.presetWeek": "Current week",
"monitoring.analytics.preset7d": "Last 7 days",
"monitoring.analytics.preset30d": "Last 30 days",
"monitoring.analytics.preset365d": "Last 365 days",
"monitoring.analytics.totalPeriod": "Selected period: {n} m · Lifetime: {lifetime} m",
"monitoring.analytics.empty": "No distance data for this period.",
"monitoring.systemLog.title": "System log",
"monitoring.systemLog.subtitle": "Events from operating system components.",
"monitoring.systemLog.filterPlaceholder": "Filter by module, message or state...",
"monitoring.systemLog.colState": "State",
"monitoring.systemLog.colModule": "Module",
"monitoring.systemLog.colMessage": "Message",
"monitoring.systemLog.colTime": "Time",
"monitoring.systemLog.empty": "No system log entries yet.",
"monitoring.systemLog.emptyFilter": "No entries match the filter.",
"monitoring.errorLogs.title": "Error logs",
"monitoring.errorLogs.subtitle": "Detected system errors.",
"monitoring.errorLogs.filterPlaceholder": "Filter by description or module...",
"monitoring.errorLogs.colDescription": "Description",
"monitoring.errorLogs.colModule": "Module",
"monitoring.errorLogs.colTime": "Time",
"monitoring.errorLogs.colFunctions": "Functions",
"monitoring.errorLogs.generate": "Generate log",
"monitoring.errorLogs.deleteAll": "Delete all",
"monitoring.errorLogs.deleteAllConfirm": "Delete all error log entries?",
"monitoring.errorLogs.download": "Download",
"monitoring.errorLogs.delete": "Delete",
"monitoring.errorLogs.empty": "No errors logged.",
"monitoring.errorLogs.emptyFilter": "No errors match the filter.",
"monitoring.hardware.title": "Hardware health",
"monitoring.hardware.subtitle": "Robot hardware component status.",
"monitoring.hardware.empty": "No hardware data.",
"monitoring.safety.title": "Safety system",
"monitoring.safety.subtitle": "Live emergency stop and laser scanner status.",
"monitoring.safety.estop": "Emergency stop",
"monitoring.safety.frontScanner": "Front scanner",
"monitoring.safety.rearScanner": "Rear scanner",
"monitoring.safety.estopReleased": "Released",
"monitoring.safety.estopActivated": "Activated",
"monitoring.safety.scannerFree": "Free",
"monitoring.safety.scannerBlocked": "Blocked",
"monitoring.missionLog.title": "Mission log",
"monitoring.missionLog.subtitle": "Executed and currently running missions.",
"monitoring.missionLog.helpTitle": "Mission log help",
"monitoring.missionLog.helpBody": "Lists missions that have run or are running. Click the eye icon for the action log. Phase 3: history is persisted (clearing the queue does not delete history).",
"monitoring.missionLog.filterPlaceholder": "Filter by mission name or state...",
"monitoring.missionLog.colMission": "Mission",
"monitoring.missionLog.colState": "State",
"monitoring.missionLog.colMessage": "Message",
"monitoring.missionLog.colStart": "Start time",
"monitoring.missionLog.colRanFor": "Ran for",
"monitoring.missionLog.colStartedBy": "Started by",
"monitoring.missionLog.colFunctions": "Functions",
"monitoring.missionLog.empty": "No missions in the log yet.",
"monitoring.missionLog.emptyFilter": "No missions match the filter.",
"monitoring.missionLog.viewActions": "View action log",
"monitoring.missionLog.clearHistory": "Clear history",
"monitoring.missionLog.clearHistoryConfirm": "Clear all mission log history?",
"monitoring.missionLog.deleteRun": "Delete",
"monitoring.missionLog.deleteRunConfirm": "Delete this mission from history?",
"monitoring.missionLog.downloadRun": "Download log",
"monitoring.missionLog.running": "Running…",
"monitoring.missionLog.stateCompleted": "Completed",
"monitoring.missionLog.stateFailed": "Failed",
"monitoring.missionLog.stateCancelled": "Cancelled",
"monitoring.missionLog.state.executing": "Executing",
"monitoring.missionLog.state.completed": "Completed",
"monitoring.missionLog.state.failed": "Failed",
"monitoring.missionLog.state.cancelled": "Cancelled",
"monitoring.missionLog.source.ui": "User interface",
"monitoring.missionLog.source.modbus": "Modbus",
"monitoring.missionLog.source.api": "REST API",
"monitoring.missionLog.source.schedule": "Scheduler",
"monitoring.actionLog.title": "Mission action log",
"monitoring.actionLog.back": "← Mission log",
"monitoring.actionLog.subtitleMission": "Mission: {name}",
"monitoring.actionLog.filterPlaceholder": "Filter by action, state or message...",
"monitoring.actionLog.colAction": "Action",
"monitoring.actionLog.colState": "State",
"monitoring.actionLog.colMessage": "Message",
"monitoring.actionLog.colStart": "Start time",
"monitoring.actionLog.colRanFor": "Ran for",
"monitoring.actionLog.empty": "No actions logged for this mission.",
"monitoring.actionLog.emptyFilter": "No actions match the filter.",
"monitoring.actionLog.missingEntry": "Mission not found.",
"monitoring.actionLog.state.executing": "Executing",
"monitoring.actionLog.level.info": "Info",
"monitoring.actionLog.level.warn": "Warning",
"monitoring.actionLog.level.error": "Error",
"monitoring.actionLog.level.user": "User",
"monitoring.actionLog.download": "Download",
"settings.title": "Settings",
"settings.subtitle": "System → Settings — global robot configuration.",
"settings.loading": "Loading settings…",
"settings.loaded": "Loaded.",
"settings.apply": "Apply",
"settings.back": "Back",
"settings.saving": "Saving…",
"settings.saved": "Saved.",
"settings.saveFailed": "Save failed.",
"settings.loggingRetention.title": "Logging & retention",
"settings.loggingRetention.subtitle": "Tune history limits and log level.",
"settings.retention.missionRuns": "Mission history (runs)",
"settings.retention.missionRunsHint": "Max persisted mission runs (bounded history).",
"settings.retention.systemLog": "System log entries",
"settings.retention.systemLogHint": "Max number of system log rows stored.",
"settings.retention.errorLogs": "Error log entries",
"settings.retention.errorLogsHint": "Max number of error log rows stored.",
"settings.logLevel": "Log level",
"settings.logLevelHint": "Controls verbosity of system logs.",
"settings.comingSoon.title": "More settings",
"settings.comingSoon.subtitle": "Time, network, security and backups will appear here.",
"settings.tile.mapping": "Mapping",
"settings.tile.mappingSub": "Mapping configuration.",
"settings.tile.errorHandling": "Error handling",
"settings.tile.errorHandlingSub": "Robot error handling configuration.",
"settings.tile.battery": "Battery",
"settings.tile.batterySub": "Battery configuration.",
"settings.tile.wifi": "WiFi",
"settings.tile.wifiSub": "WiFi connection configuration.",
"settings.tile.dateTime": "Date & time",
"settings.tile.dateTimeSub": "Set system time.",
"settings.tile.advanced": "Advanced",
"settings.tile.advancedSub": "Retention and logging.",
"simulation.title": "Simulation",
"simulation.subtitle": "Robot+ → Simulation — simulate the robot in a virtual environment.",
"simulation.comingSoon": "Feature in development.",
"help.api.title": "API documentation",
"help.api.subtitle": "Help → API — MiR v2.0.0 REST reference for external integration.",

View File

@@ -108,6 +108,18 @@
</svg>
<span class="mirNavRailLabel" data-i18n="nav.setup">Setup</span>
</button>
<button type="button" class="mirNavRailItem" data-module="robot-plus">
<svg class="mirNavRailIcon" viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
<rect x="4" y="9" width="14" height="9" rx="2.2" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
<path d="M11 7v2" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
<path d="M9.5 8.5 11 6.8 12.5 8.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="11" cy="13.5" r="2" fill="none" stroke="currentColor" stroke-width="1.6"/>
<circle cx="6.5" cy="19" r="1.3" fill="none" stroke="currentColor" stroke-width="1.5"/>
<circle cx="15.5" cy="19" r="1.3" fill="none" stroke="currentColor" stroke-width="1.5"/>
<path d="M18 4.5v5M15.5 7h5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
</svg>
<span class="mirNavRailLabel" data-i18n="nav.robot-plus">Robot+</span>
</button>
<button type="button" class="mirNavRailItem" data-module="monitoring">
<svg class="mirNavRailIcon" viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
<path d="M3 3v18h18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
@@ -2378,18 +2390,379 @@ GET /api/v2.0.0/status</pre>
</div>
</div>
<div class="page" id="pageSettings" data-page-content="settings" hidden>
<div class="mapsMirPage">
<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>
<h2 class="mapsMirCardTitle" data-i18n="settings.loggingRetention.title">Logging & retention</h2>
<p class="mapsMirCardSub" data-i18n="settings.loggingRetention.subtitle">Tune history limits and log level.</p>
</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>
</header>
<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>
</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>
<section class="card">
<div class="cardHeader">
<div>
<div class="cardTitle" data-i18n="monitoring.log.title">System log</div>
<div class="cardSub" data-i18n="monitoring.log.subtitle">Monitoring → System log — nhật ký hệ thống (đang phát triển).</div>
<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="cardBody">
<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="monAnalyticsSummary">
<span id="analyticsTotalLabel" class="monAnalyticsTotal"></span>
</div>
</section>
<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">&laquo;</button>
<button type="button" class="mapsMirPageBtn" id="systemLogPagePrev">&lsaquo;</button>
<span id="systemLogPageLabel" class="mapsMirPageLabel">Page 1 of 1</span>
<button type="button" class="mapsMirPageBtn" id="systemLogPageNext">&rsaquo;</button>
<button type="button" class="mapsMirPageBtn" id="systemLogPageLast">&raquo;</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">&laquo;</button>
<button type="button" class="mapsMirPageBtn" id="missionLogPagePrev" aria-label="Previous page">&lsaquo;</button>
<span id="missionLogPageLabel" class="mapsMirPageLabel">Page 1 of 1</span>
<button type="button" class="mapsMirPageBtn" id="missionLogPageNext" aria-label="Next page">&rsaquo;</button>
<button type="button" class="mapsMirPageBtn" id="missionLogPageLast" aria-label="Last page">&raquo;</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">&laquo;</button>
<button type="button" class="mapsMirPageBtn" id="actionLogPagePrev" aria-label="Previous page">&lsaquo;</button>
<span id="actionLogPageLabel" class="mapsMirPageLabel">Page 1 of 1</span>
<button type="button" class="mapsMirPageBtn" id="actionLogPageNext" aria-label="Next page">&rsaquo;</button>
<button type="button" class="mapsMirPageBtn" id="actionLogPageLast" aria-label="Last page">&raquo;</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 class="page" id="pageSimulation" data-page-content="simulation" hidden>
<div class="mapsMirPage">
<header class="mapsMirHeader">
<div class="mapsMirHeaderText">
<h1 class="mapsMirTitle" data-i18n="simulation.title">Simulation</h1>
<p class="mapsMirSubtitle" data-i18n="simulation.subtitle">Robot+ → Simulation — mô phỏng robot trong môi trường ảo.</p>
</div>
</header>
<p class="mapsMirEmpty" data-i18n="simulation.comingSoon">Tính năng đang phát triển.</p>
</div>
</div>
<div class="page" id="pageHelp" data-page-content="help" hidden>
@@ -2783,6 +3156,8 @@ GET /api/v2.0.0/status</pre>
<script src="/topbar.js"></script>
<script src="/dashboard.js"></script>
<script src="/integrations.js"></script>
<script src="/settings.js"></script>
<script src="/monitoring.js"></script>
<script src="/app.js"></script>
</body>
</html>

927
www/monitoring.js Normal file
View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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 });
})();

View File

@@ -22,14 +22,29 @@
{ section: "io-modules", page: "io-modules" },
{ section: "paths", page: "paths" },
{ section: "path-guides", page: "path-guides" },
{ section: "build-robot", page: "config" },
],
},
"robot-plus": {
items: [
{ section: "robot", page: "config" },
{ section: "simulation", page: "simulation" },
],
},
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: {
items: [{ section: "integrations", page: "integrations" }],
items: [
{ section: "settings", page: "settings" },
{ section: "integrations", page: "integrations" },
],
},
help: {
items: [{ section: "help-api", page: "help" }],
@@ -38,7 +53,8 @@
const PAGE_NAV = {
dashboard: { module: "dashboards", section: "dashboard-list" },
config: { module: "setup", section: "build-robot" },
config: { module: "robot-plus", section: "robot" },
simulation: { module: "robot-plus", section: "simulation" },
maps: { module: "setup", section: "maps" },
missions: { module: "setup", section: "missions" },
sounds: { module: "setup", section: "sounds" },
@@ -49,7 +65,8 @@
"path-guides": { module: "setup", section: "path-guides" },
users: { module: "setup", section: "users" },
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" },
};
@@ -191,6 +208,7 @@
updateRailUI();
navigateToPage(page);
if (page === "dashboard") window.DashboardApp?.handleNav?.(section);
if (page === "monitoring") window.MonitoringApp?.showSection?.(section);
}
function syncDashboardSection(section) {
@@ -212,6 +230,7 @@
saveState();
updateRailUI();
if (page === "dashboard") window.DashboardApp?.handleNav?.(activeSection);
if (page === "monitoring") window.MonitoringApp?.showSection?.(activeSection);
}
function toggleFlyout() {
@@ -247,8 +266,10 @@
const savedMod = localStorage.getItem(STORAGE_MODULE);
const savedSec = localStorage.getItem(STORAGE_SECTION);
const savedFlyout = localStorage.getItem(STORAGE_FLYOUT);
if (savedMod && MODULES[savedMod]) activeModule = savedMod;
if (savedSec) activeSection = savedSec;
if (savedMod === "build") activeModule = "robot-plus";
else if (savedMod && MODULES[savedMod]) activeModule = savedMod;
if (savedSec === "build-robot") activeSection = "robot";
else if (savedSec) activeSection = savedSec;
if (savedFlyout === "0") flyoutOpen = false;
} catch {
/* ignore */
@@ -310,5 +331,6 @@
selectSection,
toggleFlyout,
refreshFlyout,
getActiveSection: () => activeSection,
};
})();

148
www/settings.js Normal file
View 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 });
})();

View File

@@ -1018,6 +1018,7 @@ canvas {
.content.content--sounds,
.content.content--integrations,
.content.content--monitoring,
.content.content--simulation,
.content.content--help {
grid-template-columns: minmax(0, 1fr);
max-width: none;
@@ -1030,6 +1031,7 @@ canvas {
.content.content--sounds > .page,
.content.content--integrations > .page,
.content.content--monitoring > .page,
.content.content--simulation > .page,
.content.content--help > .page {
width: 100%;
min-width: 0;
@@ -3503,6 +3505,62 @@ body.auth-readonly-integrations .integrationToolbar .btn.primary { pointer-event
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 {
display: inline-flex;
align-items: center;
@@ -3940,6 +3998,139 @@ body.auth-readonly-io-modules .ioModulesMirRow .ioModuleDeleteBtn { pointer-even
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)); }
.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 {
color: var(--mir-green, #5cb85c);
display: block;