Compare commits

...

5 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
e0e864ea13 Thêm hướng dẫn vào tài liệu
Some checks failed
Test / test (push) Has been cancelled
2026-06-24 17:09:58 +07:00
4b702756bb Đổi tên phần mềm từ lidar_manager_app thành robot_app 2026-06-24 16:31:28 +07:00
48 changed files with 10272 additions and 469 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

@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.16)
project(lidar_manager_web LANGUAGES CXX)
project(robot_app LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -29,9 +29,9 @@ endif()
find_package(SQLite3 REQUIRED)
add_executable(lidar_manager_web
add_executable(robot_app
src/main.cpp
src/app/lidar_manager_app.cpp
src/app/robot_app.cpp
src/util/file_util.cpp
src/util/string_util.cpp
src/util/id_util.cpp
@@ -51,10 +51,12 @@ add_executable(lidar_manager_web
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(lidar_manager_web
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,20 +76,22 @@ add_executable(lidar_manager_web
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(lidar_manager_web PRIVATE Threads::Threads SQLite::SQLite3)
target_link_libraries(robot_app PRIVATE Threads::Threads SQLite::SQLite3)
target_include_directories(lidar_manager_web PRIVATE
target_include_directories(robot_app PRIVATE
"${CMAKE_CURRENT_SOURCE_DIR}/src"
)
target_include_directories(lidar_manager_web SYSTEM PRIVATE
target_include_directories(robot_app SYSTEM PRIVATE
"${cpp_httplib_SOURCE_DIR}"
"${nlohmann_json_SOURCE_DIR}/single_include"
)
target_compile_definitions(lidar_manager_web PRIVATE
target_compile_definitions(robot_app PRIVATE
_DEFAULT_SOURCE
)
@@ -112,24 +117,24 @@ if(BUILD_TESTING)
src/validation/sensor_validator.cpp
)
add_executable(lidar_manager_tests
add_executable(robot_app_tests
tests/test_mission_enqueue.cpp
tests/test_mission_store.cpp
tests/test_sensor_validator.cpp
${LM_TEST_LIB_SOURCES}
)
target_include_directories(lidar_manager_tests PRIVATE
target_include_directories(robot_app_tests PRIVATE
"${CMAKE_CURRENT_SOURCE_DIR}/src"
)
target_include_directories(lidar_manager_tests SYSTEM PRIVATE
target_include_directories(robot_app_tests SYSTEM PRIVATE
"${nlohmann_json_SOURCE_DIR}/single_include"
)
target_compile_definitions(lidar_manager_tests PRIVATE
target_compile_definitions(robot_app_tests PRIVATE
TEST_FIXTURE_DIR="${CMAKE_CURRENT_SOURCE_DIR}/tests/fixtures/data"
)
target_link_libraries(lidar_manager_tests PRIVATE GTest::gtest_main SQLite::SQLite3)
target_link_libraries(robot_app_tests PRIVATE GTest::gtest_main SQLite::SQLite3)
include(GoogleTest)
gtest_discover_tests(lidar_manager_tests WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR})
add_test(NAME unit COMMAND lidar_manager_tests)
gtest_discover_tests(robot_app_tests WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR})
add_test(NAME unit COMMAND robot_app_tests)
endif()

View File

@@ -30,12 +30,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
WORKDIR /app
COPY --from=build /src/build/lidar_manager_web /app/lidar_manager_web
COPY --from=build /src/build/robot_app /app/robot_app
COPY www ./www
RUN mkdir -p data/maps data/sounds data/recordings
EXPOSE 8080
ENTRYPOINT ["/app/lidar_manager_web"]
ENTRYPOINT ["/app/robot_app"]
CMD ["8080", "/app/www", "/app/data/RBS.db"]

242
README.md
View File

@@ -1,121 +1,219 @@
# Robot App Web (RBS)
Chức năng:
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)`.
**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`
- Database SQLite: `data/RBS.db` (WAL mode). Thư mục media: `data/maps/`, `data/sounds/`, `data/recordings/`
- 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) |
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`.
**Site import/export** (Setup → Maps): bundle JSON gồm `maps`, `io_modules`, `transitions`, `paths``POST /api/sites/{site_id}/import`.
## Build
```bash
cd /home/robotics/RD/RBS
# Ubuntu/Debian: sudo apt install libsqlite3-dev
# Ubuntu/Debian: sudo apt install libsqlite3-dev cmake build-essential
cmake -S . -B build
cmake --build build -j
```
Binary: `build/robot_app`
## Run
Chạy mặc định port 8080, phục vụ static từ `www/`, dữ liệu SQLite tại `data/RBS.db`:
Chạy mặc định port 8080, phục vụ static từ `www/`, database tại `data/RBS.db`:
```bash
./build/lidar_manager_web
./build/robot_app
```
Hoặc chỉ định:
Hoặc chỉ định đủ tham số:
```bash
./build/lidar_manager_web 8080 ./www ./data/RBS.db
./build/robot_app <port> <www_dir> <db_path>
# ví dụ:
./build/robot_app 8080 ./www ./data/RBS.db
```
Mở trình duyệt: `http://localhost:8080/`
### API Maps & Sounds (SQLite)
Tắt auth cho dev/test:
| Method | Endpoint | Mô tả |
|--------|----------|-------|
| GET | `/api/maps` | Danh sách map |
| POST | `/api/maps` | Tạo map (JSON metadata) |
| GET/PUT/DELETE | `/api/maps/{id}` | CRUD map |
| GET/POST | `/api/maps/{id}/image` | Tải/xem ảnh map (file trong `data/maps/{id}/`) |
| GET | `/api/sounds` | Danh sách sound |
| POST | `/api/sounds` | Tạo sound |
| GET/PUT/DELETE | `/api/sounds/{id}` | CRUD sound |
| GET/POST | `/api/sounds/{id}/file` | Tải/upload file âm thanh |
| POST | `/api/sounds/{id}/play` | Phát sound trên robot (volume 0100) |
| GET/POST | `/api/transitions` | Danh sách / tạo transition (`?site_id=`) |
| GET/PUT/DELETE | `/api/transitions/{id}` | CRUD transition |
| GET/PUT | `/api/dashboards` | Dashboard (server-side, thay localStorage) |
| GET | `/api/recordings` | Stub — trả về `[]` (Phase sau) |
```bash
LM_AUTH_DISABLED=1 ./build/robot_app
```
**Transitions (MiR §4.4):** Setup → Transitions — cấu hình chuyển map (from/to, start/goal position, mission có `switch_map`). 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: di chuyển tới start position → chạy transition mission → chuyển map → di chuyển tới goal position → tiếp tục bước gốc. Position trong mission editor lấy từ zones `type: position` trên từng map.
### Đăng nhập (Signing in — MiR §2.1)
Trang web **bắt buộc đăng nhập**. Hai tab: tên/mật khẩu hoặc **Mã PIN** (keypad 4 số). Tài khoản mặc định (trong `data/RBS.db`, seed lần đầu):
| 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 4 chữ số chỉ dùng được với user thuộc nhóm **Users** sau khi admin gán PIN (`PUT /api/users/:id`).
Tắt auth cho dev/test: `LM_AUTH_DISABLED=1 ./build/lidar_manager_web …`
Tài liệu đầy đủ: [`docs/Reference_guide.md` §2.1](docs/Reference_guide.md#21-signing-in).
## Docker (giới hạn 2 CPU, 4 GB RAM)
Mô phỏng cấu hình controller tối thiểu SICK (Dual-Core, 4 GB) trên máy dev:
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` |
```bash
cd /home/robotics/RD/RBS
./scripts/lm.sh docker up
# hoặc: sudo docker compose up --build -d
# hoặc:
sudo docker compose up --build -d
```
Kiểm tra giới hạn:
Sau khi đổi tên service/container, nếu port 8080 bị chiếm bởi container cũ:
```bash
sudo docker compose down --remove-orphans
sudo docker compose up --build -d
```
Kiểm tra / dừng:
```bash
./scripts/lm.sh docker stats
```
Dừng:
```bash
./scripts/lm.sh docker down
```
Dữ liệu layout vẫn lưu tại `data/` trên host (volume mount).
Kiểm tra tài nguyên trong container:
```bash
# Vào shell container
./scripts/lm.sh docker shell
# Trong container, thử:
htop # CPU/RAM (q để thoát)
free -h # RAM
nproc # số CPU nhìn thấy
ps aux # process
cat /proc/meminfo | head
```
Dữ liệu lưu trên host qua volume `./data:/app/data`.
```bash
./scripts/lm.sh docker shell # shell trong container
./scripts/lm.sh docker htop
./scripts/lm.sh docker stats
```
## 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` |
## 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 |
### 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 |
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 |
**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ề `[]` |
Modbus TCP server: port **5502** (mission triggers + action coils 16).
### Đăng nhập
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) |
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).
## Test tự động
Chạy toàn bộ: unit C++ (GTest), API smoke (`curl`), pytest integration.
```bash
cd /home/robotics/RD/RBS
chmod +x scripts/lm.sh scripts/test/*.sh
./scripts/lm.sh test run
```
@@ -125,24 +223,24 @@ Chỉ unit test C++:
```bash
cmake -S . -B build -DBUILD_TESTING=ON
cmake --build build -j
ctest --test-dir build --output-on-failure
./build/robot_app_tests
# hoặc: ctest --test-dir build --output-on-failure
```
Chỉ API smoke (server đang chạy, dùng fixture `tests/fixtures/data/`):
Chỉ API smoke (server đang chạy, fixture `tests/fixtures/data/`):
```bash
./build/lidar_manager_web 18080 www tests/fixtures/data/state.json &
./build/robot_app 18080 www tests/fixtures/data/state.json &
./scripts/lm.sh test smoke http://127.0.0.1:18080
```
Fixture mission id mặc định: `testmission00001` (`tests/fixtures/data/missions.json`).
Benchmark hiệu năng trong container (cần `docker compose up -d`):
Benchmark trong container (cần `docker compose up -d`):
```bash
./scripts/lm.sh docker bench
# hoặc chỉ HTTP: ./scripts/lm.sh bench http
# hoặc: ./scripts/lm.sh bench http
```
CI: GitHub Actions workflow `.github/workflows/test.yml`.
CI: GitHub Actions workflow `.github/workflows/test.yml`.

View File

@@ -1,10 +1,10 @@
name: rbs
services:
lidar-manager:
robot-app:
build: .
image: lidar-manager-web:RBS
container_name: lidar-manager-limited
image: robot-app:RBS
container_name: robot-app-limited
ports:
- "8080:8080"
volumes:

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.

File diff suppressed because it is too large Load Diff

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

@@ -38,6 +38,6 @@ scripts/
|------|----------|---------|
| `LM_URL` | `http://127.0.0.1:8080` | URL container |
| `LM_TEST_PORT` | `18080` | Port server tạm khi `test run` |
| `LM_CONTAINER` | `lidar-manager-limited` | Tên container |
| `LM_CONTAINER` | `robot-app-limited` | Tên container |
| `TEST_BASE_URL` | — | Base URL cho pytest |
| `BENCH_REQUESTS` | `100` | Số request mỗi endpoint benchmark |

View File

@@ -13,5 +13,5 @@ echo
bench_http_suite "$BASE"
echo
echo "=== Process ==="
ps -C lidar_manager_web -o pid,rss,vsz,pcpu,pmem,etime,cmd 2>/dev/null \
|| pgrep -af '[./]lidar_manager_web' | grep -v pgrep || true
ps -C robot_app -o pid,rss,vsz,pcpu,pmem,etime,cmd 2>/dev/null \
|| pgrep -af '[./]robot_app' | grep -v pgrep || true

View File

@@ -4,7 +4,7 @@
_lm_lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LM_ROOT="$(cd "$_lm_lib_dir/../.." && pwd)"
LM_SCRIPTS="$(cd "$_lm_lib_dir/.." && pwd)"
LM_CONTAINER="${LM_CONTAINER:-lidar-manager-limited}"
LM_CONTAINER="${LM_CONTAINER:-robot-app-limited}"
LM_URL="${LM_URL:-http://127.0.0.1:8080}"
LM_TEST_PORT="${LM_TEST_PORT:-18080}"

View File

@@ -7,7 +7,7 @@ source "$(dirname "$0")/../lib/common.sh"
cd "$LM_ROOT"
PORT="${TEST_PORT:-$LM_TEST_PORT}"
BASE="http://127.0.0.1:${PORT}"
BIN="${LM_ROOT}/build/lidar_manager_web"
BIN="${LM_ROOT}/build/robot_app"
DATA_DIR="$(mktemp -d)"
SERVER_PID=""
@@ -28,7 +28,7 @@ cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON
cmake --build build -j
echo "==> C++ unit tests (GTest)"
./build/lidar_manager_tests
./build/robot_app_tests
echo "==> Prepare isolated data directory"
cp -a tests/fixtures/data/. "$DATA_DIR/"

View File

@@ -1,4 +1,4 @@
#include "app/lidar_manager_app.hpp"
#include "app/robot_app.hpp"
#include "auth/auth_service.hpp"
#include "io/io_module_service.hpp"
@@ -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"
@@ -46,14 +48,12 @@ std::filesystem::path resolveDataPath(std::filesystem::path data_path)
} // namespace
LidarManagerApp::LidarManagerApp(int port,
std::filesystem::path www_root,
std::filesystem::path data_path)
RobotApp::RobotApp(int port, std::filesystem::path www_root, std::filesystem::path data_path)
: port_(port), www_root_(std::move(www_root)), data_path_(std::move(data_path))
{
}
int LidarManagerApp::run()
int RobotApp::run()
{
data_path_ = resolveDataPath(data_path_);
const std::filesystem::path data_dir = data_path_.parent_path();
@@ -79,7 +79,11 @@ int LidarManagerApp::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);
@@ -122,13 +126,15 @@ int LidarManagerApp::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_);
std::fprintf(stderr,
"lidar_manager_web listening on http://0.0.0.0:%d (www=%s, db=%s, maps=%s, sounds=%s)\n",
"robot_app listening on http://0.0.0.0:%d (www=%s, db=%s, maps=%s, sounds=%s)\n",
port_,
www_root_.string().c_str(),
database.dbPath().string().c_str(),

View File

@@ -4,10 +4,10 @@
namespace lm {
class LidarManagerApp
class RobotApp
{
public:
LidarManagerApp(int port, std::filesystem::path www_root, std::filesystem::path data_path);
RobotApp(int port, std::filesystem::path www_root, std::filesystem::path data_path);
int run();

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

@@ -1,4 +1,4 @@
#include "app/lidar_manager_app.hpp"
#include "app/robot_app.hpp"
#include <cstdlib>
#include <filesystem>
@@ -10,6 +10,6 @@ int main(int argc, char** argv)
const std::filesystem::path data_path =
(argc >= 4) ? std::filesystem::path(argv[3]) : std::filesystem::path("data/RBS.db");
lm::LidarManagerApp app(port, www_root, data_path);
lm::RobotApp app(port, www_root, data_path);
return app.run();
}

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

@@ -1,4 +1,4 @@
"""Integration tests for lidar_manager_web REST API."""
"""Integration tests for robot_app REST API."""
from __future__ import annotations

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;