Compare commits
5 Commits
773341fc84
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c8fbaf9484 | |||
| 67e4ad4b62 | |||
| 77ea3cc361 | |||
| e0e864ea13 | |||
| 4b702756bb |
15
.vscode/launch.json
vendored
Normal file
15
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"name": "Launch Chrome against localhost",
|
||||
"url": "http://localhost:8080",
|
||||
"webRoot": "${workspaceFolder}"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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"]
|
||||
|
||||
240
README.md
240
README.md
@@ -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 0–100) |
|
||||
| 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 0–100) |
|
||||
| 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 1001–2000) |
|
||||
| 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 1–6).
|
||||
|
||||
### Đă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`.
|
||||
|
||||
|
||||
@@ -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
391
docs/AMR_SLAM_Thang_May.md
Normal 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).
|
||||
- Có **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** và **khôi phục sau khi ra**.
|
||||
BIN
docs/AMR_SLAM_Thang_May.pdf
Normal file
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
31
docs/SOURCES_MiR.md
Normal 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` và `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.
|
||||
BIN
docs/Slamtec_Robot_Comparison.xlsx
Normal file
BIN
docs/Slamtec_Robot_Comparison.xlsx
Normal file
Binary file not shown.
BIN
docs/mir_fleet_enterprise_1.2_2025_en.pdf
Normal file
BIN
docs/mir_fleet_enterprise_1.2_2025_en.pdf
Normal file
Binary file not shown.
BIN
docs/mir_fleet_reference_guide_sw2.5.0_2019_en.pdf
Normal file
BIN
docs/mir_fleet_reference_guide_sw2.5.0_2019_en.pdf
Normal file
Binary file not shown.
BIN
docs/mir_reference_guide_2.2_2021_en.pdf
Normal file
BIN
docs/mir_reference_guide_2.2_2021_en.pdf
Normal file
Binary file not shown.
3
docs/mir_software_official_page.html
Normal file
3
docs/mir_software_official_page.html
Normal file
File diff suppressed because one or more lines are too long
5384
docs/neff_mir_positions_and_missions.html
Normal file
5384
docs/neff_mir_positions_and_missions.html
Normal file
File diff suppressed because it is too large
Load Diff
BIN
docs/ĐỀ XUẤT MỞ DỰ ÁN NGHIÊN CỨU BASE ROBOT DỊCH VỤ.docx
Normal file
BIN
docs/ĐỀ XUẤT MỞ DỰ ÁN NGHIÊN CỨU BASE ROBOT DỊCH VỤ.docx
Normal file
Binary file not shown.
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -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/"
|
||||
|
||||
@@ -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(),
|
||||
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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_;
|
||||
|
||||
577
src/monitoring/monitoring_service.cpp
Normal file
577
src/monitoring/monitoring_service.cpp
Normal file
@@ -0,0 +1,577 @@
|
||||
#include "monitoring/monitoring_service.hpp"
|
||||
|
||||
#include "storage/database.hpp"
|
||||
#include "storage/mission_run_store.hpp"
|
||||
#include "storage/state_repository.hpp"
|
||||
#include "util/id_util.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <ctime>
|
||||
#include <iomanip>
|
||||
#include <sstream>
|
||||
|
||||
namespace lm {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr size_t kMaxActionRing = 120;
|
||||
|
||||
std::string formatDateYmd(int y, int m, int d)
|
||||
{
|
||||
std::ostringstream oss;
|
||||
oss << std::setfill('0') << y << '-' << std::setw(2) << m << '-' << std::setw(2) << d;
|
||||
return oss.str();
|
||||
}
|
||||
|
||||
bool parseYmd(const std::string& s, int& y, int& m, int& d)
|
||||
{
|
||||
if (s.size() != 10 || s[4] != '-' || s[7] != '-')
|
||||
return false;
|
||||
try
|
||||
{
|
||||
y = std::stoi(s.substr(0, 4));
|
||||
m = std::stoi(s.substr(5, 2));
|
||||
d = std::stoi(s.substr(8, 2));
|
||||
return m >= 1 && m <= 12 && d >= 1 && d <= 31;
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
std::time_t toTimeUtc(int y, int m, int d)
|
||||
{
|
||||
std::tm tm{};
|
||||
tm.tm_year = y - 1900;
|
||||
tm.tm_mon = m - 1;
|
||||
tm.tm_mday = d;
|
||||
tm.tm_hour = 12;
|
||||
return std::mktime(&tm);
|
||||
}
|
||||
|
||||
std::string addDays(const std::string& ymd, int delta)
|
||||
{
|
||||
int y = 0, m = 0, d = 0;
|
||||
if (!parseYmd(ymd, y, m, d))
|
||||
return ymd;
|
||||
std::time_t t = toTimeUtc(y, m, d);
|
||||
t += static_cast<std::time_t>(delta) * 86400;
|
||||
std::tm* utc = std::gmtime(&t);
|
||||
if (!utc)
|
||||
return ymd;
|
||||
return formatDateYmd(utc->tm_year + 1900, utc->tm_mon + 1, utc->tm_mday);
|
||||
}
|
||||
|
||||
std::string monthKey(const std::string& ymd)
|
||||
{
|
||||
if (ymd.size() >= 7)
|
||||
return ymd.substr(0, 7);
|
||||
return ymd;
|
||||
}
|
||||
|
||||
std::string statusLabel(const std::string& status)
|
||||
{
|
||||
if (status == "ok")
|
||||
return "OK";
|
||||
if (status == "warn")
|
||||
return "Warning";
|
||||
if (status == "error")
|
||||
return "Error";
|
||||
return status;
|
||||
}
|
||||
|
||||
nlohmann::json component(const std::string& id,
|
||||
const std::string& name,
|
||||
const std::string& status,
|
||||
const std::string& message = "")
|
||||
{
|
||||
return {{"id", id},
|
||||
{"name", name},
|
||||
{"status", status},
|
||||
{"status_label", statusLabel(status)},
|
||||
{"message", message},
|
||||
{"children", nlohmann::json::array()}};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
MonitoringService::MonitoringService(Database& db) : db_(db)
|
||||
{
|
||||
loadUnlocked();
|
||||
seedIfEmptyUnlocked();
|
||||
last_tick_ = std::chrono::steady_clock::now();
|
||||
}
|
||||
|
||||
void MonitoringService::loadUnlocked()
|
||||
{
|
||||
analytics_ = nlohmann::json::object({{"total_meters", 0.0}, {"daily", nlohmann::json::object()}});
|
||||
system_log_ = nlohmann::json::array();
|
||||
error_logs_ = nlohmann::json::array();
|
||||
action_ring_ = nlohmann::json::array();
|
||||
|
||||
nlohmann::json parsed;
|
||||
if (db_.getDocument("monitoring_analytics", parsed) && parsed.is_object())
|
||||
analytics_ = parsed;
|
||||
if (!analytics_.contains("daily") || !analytics_["daily"].is_object())
|
||||
analytics_["daily"] = nlohmann::json::object();
|
||||
if (!analytics_.contains("total_meters"))
|
||||
analytics_["total_meters"] = 0.0;
|
||||
|
||||
if (db_.getDocument("monitoring_system_log", parsed) && parsed.is_array())
|
||||
system_log_ = parsed;
|
||||
if (db_.getDocument("monitoring_error_logs", parsed) && parsed.is_array())
|
||||
error_logs_ = parsed;
|
||||
if (db_.getDocument("monitoring_action_ring", parsed) && parsed.is_array())
|
||||
action_ring_ = parsed;
|
||||
}
|
||||
|
||||
void MonitoringService::saveAnalyticsUnlocked() const
|
||||
{
|
||||
db_.setDocument("monitoring_analytics", analytics_);
|
||||
}
|
||||
|
||||
void MonitoringService::saveSystemLogUnlocked() const
|
||||
{
|
||||
db_.setDocument("monitoring_system_log", system_log_);
|
||||
}
|
||||
|
||||
void MonitoringService::saveErrorLogsUnlocked() const
|
||||
{
|
||||
db_.setDocument("monitoring_error_logs", error_logs_);
|
||||
}
|
||||
|
||||
void MonitoringService::trimSystemLogUnlocked()
|
||||
{
|
||||
int keep = 2000;
|
||||
nlohmann::json s;
|
||||
if (db_.getDocument("settings", s) && s.is_object())
|
||||
keep = std::clamp(s.value("retention_system_log", 2000), 100, 20000);
|
||||
while (system_log_.is_array() && system_log_.size() > keep)
|
||||
system_log_.erase(system_log_.begin());
|
||||
}
|
||||
|
||||
void MonitoringService::trimErrorLogsUnlocked()
|
||||
{
|
||||
int keep = 200;
|
||||
nlohmann::json s;
|
||||
if (db_.getDocument("settings", s) && s.is_object())
|
||||
keep = std::clamp(s.value("retention_error_logs", 200), 10, 5000);
|
||||
while (error_logs_.is_array() && error_logs_.size() > keep)
|
||||
error_logs_.erase(error_logs_.begin());
|
||||
}
|
||||
|
||||
void MonitoringService::trimActionRingUnlocked()
|
||||
{
|
||||
while (action_ring_.is_array() && action_ring_.size() > static_cast<int>(kMaxActionRing))
|
||||
action_ring_.erase(action_ring_.begin());
|
||||
}
|
||||
|
||||
void MonitoringService::seedIfEmptyUnlocked()
|
||||
{
|
||||
if (!system_log_.is_array() || system_log_.empty())
|
||||
{
|
||||
logSystem("ok", "Application", "Robot application started");
|
||||
logSystem("ok", "Database", "Configuration database loaded");
|
||||
}
|
||||
|
||||
if (!analytics_["daily"].is_object() || analytics_["daily"].empty())
|
||||
{
|
||||
const std::string today = todayKey();
|
||||
for (int i = 6; i >= 0; --i)
|
||||
{
|
||||
const std::string day = addDays(today, -i);
|
||||
analytics_["daily"][day] = 42.5 + (6 - i) * 11.3;
|
||||
analytics_["total_meters"] = analytics_.value("total_meters", 0.0) + analytics_["daily"][day].get<double>();
|
||||
}
|
||||
saveAnalyticsUnlocked();
|
||||
}
|
||||
}
|
||||
|
||||
std::string MonitoringService::todayKey()
|
||||
{
|
||||
const std::string iso = IdUtil::nowIso8601();
|
||||
return dateKeyFromIso(iso);
|
||||
}
|
||||
|
||||
std::string MonitoringService::dateKeyFromIso(const std::string& iso)
|
||||
{
|
||||
if (iso.size() >= 10)
|
||||
return iso.substr(0, 10);
|
||||
return todayKey();
|
||||
}
|
||||
|
||||
void MonitoringService::recordDistance(double meters)
|
||||
{
|
||||
if (!std::isfinite(meters) || meters <= 0.0)
|
||||
return;
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
const std::string day = todayKey();
|
||||
const double daily = analytics_["daily"].value(day, 0.0);
|
||||
analytics_["daily"][day] = daily + meters;
|
||||
analytics_["total_meters"] = analytics_.value("total_meters", 0.0) + meters;
|
||||
saveAnalyticsUnlocked();
|
||||
}
|
||||
|
||||
void MonitoringService::logSystem(const std::string& state, const std::string& module, const std::string& message)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
if (!system_log_.is_array())
|
||||
system_log_ = nlohmann::json::array();
|
||||
system_log_.push_back({{"id", IdUtil::newId()},
|
||||
{"ts", IdUtil::nowIso8601()},
|
||||
{"state", state},
|
||||
{"module", module},
|
||||
{"message", message}});
|
||||
trimSystemLogUnlocked();
|
||||
saveSystemLogUnlocked();
|
||||
}
|
||||
|
||||
void MonitoringService::logError(const std::string& module,
|
||||
const std::string& description,
|
||||
const nlohmann::json& context)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
if (!error_logs_.is_array())
|
||||
error_logs_ = nlohmann::json::array();
|
||||
error_logs_.push_back({{"id", IdUtil::newId()},
|
||||
{"ts", IdUtil::nowIso8601()},
|
||||
{"module", module},
|
||||
{"description", description},
|
||||
{"context", context.is_null() ? nlohmann::json::object() : context}});
|
||||
trimErrorLogsUnlocked();
|
||||
saveErrorLogsUnlocked();
|
||||
|
||||
if (!system_log_.is_array())
|
||||
system_log_ = nlohmann::json::array();
|
||||
system_log_.push_back({{"id", IdUtil::newId()},
|
||||
{"ts", IdUtil::nowIso8601()},
|
||||
{"state", "error"},
|
||||
{"module", module},
|
||||
{"message", description}});
|
||||
trimSystemLogUnlocked();
|
||||
saveSystemLogUnlocked();
|
||||
}
|
||||
|
||||
void MonitoringService::recordActionSnapshot(const std::string& action, const std::string& message)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
if (!action_ring_.is_array())
|
||||
action_ring_ = nlohmann::json::array();
|
||||
action_ring_.push_back({{"ts", IdUtil::nowIso8601()}, {"action", action}, {"message", message}});
|
||||
trimActionRingUnlocked();
|
||||
db_.setDocument("monitoring_action_ring", action_ring_);
|
||||
}
|
||||
|
||||
void MonitoringService::onRobotTick(const nlohmann::json& robot_state, double delta_seconds)
|
||||
{
|
||||
if (!std::isfinite(delta_seconds) || delta_seconds <= 0.0)
|
||||
return;
|
||||
|
||||
const bool running = robot_state.value("motion", "paused") == "running";
|
||||
const double linear = std::abs(robot_state.value("cmd_linear", 0.0));
|
||||
const double angular = std::abs(robot_state.value("cmd_angular", 0.0));
|
||||
if (running && (linear > 0.01 || angular > 0.01))
|
||||
{
|
||||
const double meters = (linear * 0.6 + angular * 0.15) * delta_seconds;
|
||||
recordDistance(meters);
|
||||
recordActionSnapshot("Motion", "linear=" + std::to_string(linear) + " angular=" + std::to_string(angular));
|
||||
}
|
||||
|
||||
const auto runner = robot_state.value("runner", nlohmann::json::object());
|
||||
const std::string cur = runner.value("current_action", "");
|
||||
if (!cur.empty())
|
||||
recordActionSnapshot(cur, runner.value("message", cur));
|
||||
}
|
||||
|
||||
nlohmann::json MonitoringService::analytics(const std::string& start_date,
|
||||
const std::string& end_date,
|
||||
const std::string& grouping) const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
const std::string today = todayKey();
|
||||
std::string start = start_date.empty() ? addDays(today, -6) : start_date;
|
||||
std::string end = end_date.empty() ? today : end_date;
|
||||
if (start > end)
|
||||
std::swap(start, end);
|
||||
|
||||
const bool per_month = grouping == "month";
|
||||
nlohmann::json buckets = nlohmann::json::array();
|
||||
double total = 0.0;
|
||||
double accumulated = 0.0;
|
||||
nlohmann::json accumulated_series = nlohmann::json::array();
|
||||
|
||||
std::string cursor = start;
|
||||
while (cursor <= end)
|
||||
{
|
||||
const std::string key = per_month ? monthKey(cursor) : cursor;
|
||||
double meters = 0.0;
|
||||
if (per_month)
|
||||
{
|
||||
if (analytics_.contains("daily") && analytics_["daily"].is_object())
|
||||
{
|
||||
for (auto it = analytics_["daily"].begin(); it != analytics_["daily"].end(); ++it)
|
||||
{
|
||||
if (monthKey(it.key()) == key)
|
||||
meters += it.value().get<double>();
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
meters = analytics_["daily"].value(cursor, 0.0);
|
||||
}
|
||||
|
||||
if (!per_month || buckets.empty() || buckets.back().value("key", "") != key)
|
||||
{
|
||||
accumulated += meters;
|
||||
total += meters;
|
||||
buckets.push_back({{"key", key},
|
||||
{"label", key},
|
||||
{"date", cursor},
|
||||
{"meters", meters},
|
||||
{"accumulated", accumulated}});
|
||||
accumulated_series.push_back({{"date", cursor}, {"meters", accumulated}});
|
||||
}
|
||||
else
|
||||
{
|
||||
buckets.back()["meters"] = buckets.back().value("meters", 0.0) + meters;
|
||||
total += meters;
|
||||
accumulated += meters;
|
||||
buckets.back()["accumulated"] = accumulated;
|
||||
if (!accumulated_series.empty())
|
||||
accumulated_series.back()["meters"] = accumulated;
|
||||
}
|
||||
|
||||
cursor = addDays(cursor, 1);
|
||||
if (per_month)
|
||||
{
|
||||
while (cursor <= end && monthKey(cursor) == key)
|
||||
cursor = addDays(cursor, 1);
|
||||
}
|
||||
}
|
||||
|
||||
return {{"start", start},
|
||||
{"end", end},
|
||||
{"grouping", per_month ? "month" : "day"},
|
||||
{"total_meters", total},
|
||||
{"lifetime_meters", analytics_.value("total_meters", 0.0)},
|
||||
{"buckets", buckets},
|
||||
{"accumulated", accumulated_series}};
|
||||
}
|
||||
|
||||
nlohmann::json MonitoringService::systemLog() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
nlohmann::json items = nlohmann::json::array();
|
||||
if (!system_log_.is_array())
|
||||
return {{"items", items}};
|
||||
for (auto it = system_log_.rbegin(); it != system_log_.rend(); ++it)
|
||||
items.push_back(*it);
|
||||
return {{"items", items}};
|
||||
}
|
||||
|
||||
nlohmann::json MonitoringService::errorLogs() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
nlohmann::json items = nlohmann::json::array();
|
||||
if (!error_logs_.is_array())
|
||||
return {{"items", items}};
|
||||
for (auto it = error_logs_.rbegin(); it != error_logs_.rend(); ++it)
|
||||
items.push_back(*it);
|
||||
return {{"items", items}};
|
||||
}
|
||||
|
||||
std::optional<nlohmann::json> MonitoringService::errorLogById(const std::string& id) const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
if (!error_logs_.is_array())
|
||||
return std::nullopt;
|
||||
for (const auto& e : error_logs_)
|
||||
{
|
||||
if (e.value("id", "") == id)
|
||||
return e;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
bool MonitoringService::deleteErrorLog(const std::string& id)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
if (!error_logs_.is_array())
|
||||
return false;
|
||||
for (auto it = error_logs_.begin(); it != error_logs_.end(); ++it)
|
||||
{
|
||||
if ((*it).value("id", "") == id)
|
||||
{
|
||||
error_logs_.erase(it);
|
||||
saveErrorLogsUnlocked();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void MonitoringService::deleteAllErrorLogs()
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
error_logs_ = nlohmann::json::array();
|
||||
saveErrorLogsUnlocked();
|
||||
}
|
||||
|
||||
nlohmann::json MonitoringService::generateUserLog(const nlohmann::json& robot_status,
|
||||
const nlohmann::json& runner_status)
|
||||
{
|
||||
nlohmann::json context = {{"robot", robot_status},
|
||||
{"runner", runner_status},
|
||||
{"actions", action_ring_}};
|
||||
logError("/User", "User generated log", context);
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
if (error_logs_.is_array() && !error_logs_.empty())
|
||||
return error_logs_.back();
|
||||
return nlohmann::json::object();
|
||||
}
|
||||
|
||||
nlohmann::json MonitoringService::safetyStatus(const nlohmann::json& robot_status) const
|
||||
{
|
||||
const auto safety = robot_status.value("safety", nlohmann::json::object());
|
||||
return {{"emergency_stop", safety.value("emergency_stop", "released")},
|
||||
{"front_scanner", safety.value("front_scanner", "free")},
|
||||
{"rear_scanner", safety.value("rear_scanner", "free")},
|
||||
{"updated_at", robot_status.value("updated_at", "")}};
|
||||
}
|
||||
|
||||
nlohmann::json MonitoringService::hardwareHealth(const StateRepository& repo,
|
||||
const nlohmann::json& robot_status) const
|
||||
{
|
||||
const std::string health = robot_status.value("health", "ok");
|
||||
const int battery = robot_status.value("battery_percent", 0);
|
||||
const bool charging = robot_status.value("battery_charging", false);
|
||||
const auto safety = safetyStatus(robot_status);
|
||||
|
||||
const std::string computer_status = health == "error" ? "error" : "ok";
|
||||
nlohmann::json computer_components = nlohmann::json::array();
|
||||
computer_components.push_back(
|
||||
component("main", "Main computer", computer_status, "CPU and system services"));
|
||||
|
||||
std::string motor_status = "ok";
|
||||
if (robot_status.value("runner", nlohmann::json::object()).value("state", "") == "error")
|
||||
motor_status = "warn";
|
||||
nlohmann::json motor_components = nlohmann::json::array();
|
||||
motor_components.push_back(component("left", "Left motor controller", motor_status));
|
||||
motor_components.push_back(component("right", "Right motor controller", motor_status));
|
||||
|
||||
std::string power_status = "ok";
|
||||
const std::string power_msg = charging ? "Charging" : "Discharging";
|
||||
if (battery < 10)
|
||||
power_status = "error";
|
||||
else if (battery < 25)
|
||||
power_status = "warn";
|
||||
nlohmann::json power_components = nlohmann::json::array();
|
||||
power_components.push_back(
|
||||
component("battery", "Battery", power_status, std::to_string(battery) + "% — " + power_msg));
|
||||
|
||||
std::string safety_group = "ok";
|
||||
if (safety.value("emergency_stop", "") == "activated" || safety.value("front_scanner", "") == "blocked" ||
|
||||
safety.value("rear_scanner", "") == "blocked")
|
||||
safety_group = "error";
|
||||
nlohmann::json safety_components = nlohmann::json::array();
|
||||
safety_components.push_back(
|
||||
component("estop", "Emergency stop", safety.value("emergency_stop", "") == "activated" ? "error" : "ok"));
|
||||
safety_components.push_back(
|
||||
component("front_laser", "Front laser scanner", safety.value("front_scanner", "") == "blocked" ? "error" : "ok"));
|
||||
safety_components.push_back(
|
||||
component("rear_laser", "Rear laser scanner", safety.value("rear_scanner", "") == "blocked" ? "error" : "ok"));
|
||||
|
||||
nlohmann::json sensor_components = nlohmann::json::array();
|
||||
const auto& state = repo.app().state;
|
||||
if (state.contains("lidars") && state["lidars"].is_array())
|
||||
{
|
||||
int idx = 0;
|
||||
for (const auto& lidar : state["lidars"])
|
||||
{
|
||||
const std::string name = lidar.value("name", "LiDAR " + std::to_string(++idx));
|
||||
sensor_components.push_back(
|
||||
component(lidar.value("id", "lidar_" + std::to_string(idx)), name, "ok", "Configured"));
|
||||
}
|
||||
}
|
||||
if (state.contains("imus") && state["imus"].is_array())
|
||||
{
|
||||
int idx = 0;
|
||||
for (const auto& imu : state["imus"])
|
||||
{
|
||||
const std::string name = imu.value("name", "IMU " + std::to_string(++idx));
|
||||
sensor_components.push_back(component(imu.value("id", "imu_" + std::to_string(idx)), name, "ok", "Configured"));
|
||||
}
|
||||
}
|
||||
if (sensor_components.empty())
|
||||
sensor_components.push_back(
|
||||
component("lidar_front", "Front LiDAR", "warn", "No sensors configured in Build robot"));
|
||||
|
||||
std::string sensors_status = "ok";
|
||||
for (const auto& c : sensor_components)
|
||||
{
|
||||
if (c.value("status", "") == "error")
|
||||
sensors_status = "error";
|
||||
else if (c.value("status", "") == "warn" && sensors_status == "ok")
|
||||
sensors_status = "warn";
|
||||
}
|
||||
|
||||
auto group = [](const std::string& id, const std::string& name, const std::string& status, nlohmann::json components) {
|
||||
return nlohmann::json{{"id", id},
|
||||
{"name", name},
|
||||
{"status", status},
|
||||
{"status_label", statusLabel(status)},
|
||||
{"components", std::move(components)}};
|
||||
};
|
||||
|
||||
nlohmann::json groups = nlohmann::json::array();
|
||||
groups.push_back(group("computer", "Computer", computer_status, computer_components));
|
||||
groups.push_back(group("motors", "Motors", motor_status, motor_components));
|
||||
groups.push_back(group("power", "Power system", power_status, power_components));
|
||||
groups.push_back(group("safety", "Safety system", safety_group, safety_components));
|
||||
groups.push_back(group("sensors", "Sensors", sensors_status, sensor_components));
|
||||
groups.push_back(group("modbus", "Modbus", "ok",
|
||||
nlohmann::json::array({component("tcp", "Modbus TCP server", "ok", "Port 5502")})));
|
||||
|
||||
return {{"groups", groups}, {"updated_at", robot_status.value("updated_at", "")}};
|
||||
}
|
||||
|
||||
bool MonitoringService::recordMissionRun(const nlohmann::json& queue_entry, std::string& err)
|
||||
{
|
||||
MissionRunStore store(db_);
|
||||
return store.insertFromQueueEntry(queue_entry, err);
|
||||
}
|
||||
|
||||
nlohmann::json MonitoringService::missionRuns(int limit) const
|
||||
{
|
||||
MissionRunStore store(db_);
|
||||
return store.list(limit);
|
||||
}
|
||||
|
||||
std::optional<nlohmann::json> MonitoringService::missionRun(const std::string& run_id) const
|
||||
{
|
||||
MissionRunStore store(db_);
|
||||
return store.findRun(run_id);
|
||||
}
|
||||
|
||||
std::optional<nlohmann::json> MonitoringService::missionRunActions(const std::string& run_id) const
|
||||
{
|
||||
MissionRunStore store(db_);
|
||||
return store.actionsForRun(run_id);
|
||||
}
|
||||
|
||||
bool MonitoringService::deleteMissionRun(const std::string& run_id, std::string& err)
|
||||
{
|
||||
MissionRunStore store(db_);
|
||||
return store.deleteRun(run_id, err);
|
||||
}
|
||||
|
||||
bool MonitoringService::clearMissionRuns(std::string& err)
|
||||
{
|
||||
MissionRunStore store(db_);
|
||||
return store.clearAll(err);
|
||||
}
|
||||
|
||||
} // namespace lm
|
||||
68
src/monitoring/monitoring_service.hpp
Normal file
68
src/monitoring/monitoring_service.hpp
Normal file
@@ -0,0 +1,68 @@
|
||||
#pragma once
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <chrono>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
namespace lm {
|
||||
|
||||
class Database;
|
||||
class StateRepository;
|
||||
|
||||
class MonitoringService
|
||||
{
|
||||
public:
|
||||
explicit MonitoringService(Database& db);
|
||||
|
||||
void recordDistance(double meters);
|
||||
void logSystem(const std::string& state, const std::string& module, const std::string& message);
|
||||
void logError(const std::string& module, const std::string& description, const nlohmann::json& context = nullptr);
|
||||
void recordActionSnapshot(const std::string& action, const std::string& message);
|
||||
void onRobotTick(const nlohmann::json& robot_state, double delta_seconds);
|
||||
|
||||
nlohmann::json analytics(const std::string& start_date,
|
||||
const std::string& end_date,
|
||||
const std::string& grouping) const;
|
||||
nlohmann::json systemLog() const;
|
||||
nlohmann::json errorLogs() const;
|
||||
nlohmann::json hardwareHealth(const StateRepository& repo, const nlohmann::json& robot_status) const;
|
||||
nlohmann::json safetyStatus(const nlohmann::json& robot_status) const;
|
||||
|
||||
// Phase 3: persisted mission runs (MiR-like Mission log history)
|
||||
bool recordMissionRun(const nlohmann::json& queue_entry, std::string& err);
|
||||
nlohmann::json missionRuns(int limit = 200) const;
|
||||
std::optional<nlohmann::json> missionRun(const std::string& run_id) const;
|
||||
std::optional<nlohmann::json> missionRunActions(const std::string& run_id) const;
|
||||
bool deleteMissionRun(const std::string& run_id, std::string& err);
|
||||
bool clearMissionRuns(std::string& err);
|
||||
|
||||
std::optional<nlohmann::json> errorLogById(const std::string& id) const;
|
||||
bool deleteErrorLog(const std::string& id);
|
||||
void deleteAllErrorLogs();
|
||||
nlohmann::json generateUserLog(const nlohmann::json& robot_status, const nlohmann::json& runner_status);
|
||||
|
||||
private:
|
||||
Database& db_;
|
||||
mutable std::mutex mu_;
|
||||
nlohmann::json analytics_;
|
||||
nlohmann::json system_log_;
|
||||
nlohmann::json error_logs_;
|
||||
nlohmann::json action_ring_;
|
||||
std::chrono::steady_clock::time_point last_tick_;
|
||||
|
||||
void loadUnlocked();
|
||||
void saveAnalyticsUnlocked() const;
|
||||
void saveSystemLogUnlocked() const;
|
||||
void saveErrorLogsUnlocked() const;
|
||||
void trimSystemLogUnlocked();
|
||||
void trimErrorLogsUnlocked();
|
||||
void trimActionRingUnlocked();
|
||||
void seedIfEmptyUnlocked();
|
||||
static std::string todayKey();
|
||||
static std::string dateKeyFromIso(const std::string& iso);
|
||||
};
|
||||
|
||||
} // namespace lm
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "robot/robot_runtime.hpp"
|
||||
|
||||
#include "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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
141
src/server/api_monitoring_routes.cpp
Normal file
141
src/server/api_monitoring_routes.cpp
Normal file
@@ -0,0 +1,141 @@
|
||||
#include "server/api_server.hpp"
|
||||
|
||||
#include "monitoring/monitoring_service.hpp"
|
||||
#include "util/http_util.hpp"
|
||||
|
||||
namespace lm {
|
||||
|
||||
void ApiServer::registerMonitoringRoutes(httplib::Server& svr)
|
||||
{
|
||||
svr.Get("/api/monitoring/mission_runs", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
int limit = 200;
|
||||
try
|
||||
{
|
||||
if (req.has_param("limit"))
|
||||
limit = std::stoi(req.get_param_value("limit"));
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
limit = 200;
|
||||
}
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
res.body = monitoring_.missionRuns(limit).dump();
|
||||
});
|
||||
|
||||
svr.Get(R"(/api/monitoring/mission_runs/([^/]+)/actions)", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
const auto items = monitoring_.missionRunActions(req.matches[1]);
|
||||
if (!items)
|
||||
return HttpUtil::jsonError(res, 404, "mission run not found");
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
res.body = items->dump();
|
||||
});
|
||||
|
||||
svr.Get(R"(/api/monitoring/mission_runs/([^/]+)/download)", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
const std::string id = req.matches[1];
|
||||
const auto run = monitoring_.missionRun(id);
|
||||
const auto actions = monitoring_.missionRunActions(id);
|
||||
if (!run || !actions)
|
||||
return HttpUtil::jsonError(res, 404, "mission run not found");
|
||||
|
||||
robot_runtime_.tick();
|
||||
const auto robot = robot_runtime_.status();
|
||||
|
||||
nlohmann::json bundle = {{"run", *run}, {"actions", (*actions).value("items", nlohmann::json::array())}, {"robot_snapshot", robot}};
|
||||
res.set_header("Content-Type", "application/octet-stream");
|
||||
res.set_header("Content-Disposition", "attachment; filename=\"mission_run_" + id + ".json\"");
|
||||
res.body = bundle.dump(2);
|
||||
});
|
||||
|
||||
svr.Delete(R"(/api/monitoring/mission_runs/([^/]+))", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
std::string err;
|
||||
if (!monitoring_.deleteMissionRun(req.matches[1], err))
|
||||
return HttpUtil::jsonError(res, 404, err.empty() ? "mission run not found" : err);
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
res.body = nlohmann::json{{"ok", true}}.dump();
|
||||
});
|
||||
|
||||
svr.Delete("/api/monitoring/mission_runs", [this](const httplib::Request&, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
std::string err;
|
||||
if (!monitoring_.clearMissionRuns(err))
|
||||
return HttpUtil::jsonError(res, 400, err);
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
res.body = nlohmann::json{{"ok", true}}.dump();
|
||||
});
|
||||
|
||||
svr.Get("/api/monitoring/analytics", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
const std::string start = req.get_param_value("start");
|
||||
const std::string end = req.get_param_value("end");
|
||||
const std::string grouping = req.get_param_value("grouping");
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
res.body = monitoring_.analytics(start, end, grouping).dump();
|
||||
});
|
||||
|
||||
svr.Get("/api/monitoring/system_log", [this](const httplib::Request&, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
res.body = monitoring_.systemLog().dump();
|
||||
});
|
||||
|
||||
svr.Get("/api/monitoring/error_logs", [this](const httplib::Request&, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
res.body = monitoring_.errorLogs().dump();
|
||||
});
|
||||
|
||||
svr.Delete("/api/monitoring/error_logs", [this](const httplib::Request&, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
monitoring_.deleteAllErrorLogs();
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
res.body = nlohmann::json{{"ok", true}}.dump();
|
||||
});
|
||||
|
||||
svr.Post("/api/monitoring/error_logs/generate", [this](const httplib::Request&, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
robot_runtime_.tick();
|
||||
const auto status = robot_runtime_.status();
|
||||
const auto entry = monitoring_.generateUserLog(status, status.value("runner", nlohmann::json::object()));
|
||||
res.status = 201;
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
res.body = entry.dump();
|
||||
});
|
||||
|
||||
svr.Get(R"(/api/monitoring/error_logs/([^/]+)/download)", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
const auto entry = monitoring_.errorLogById(req.matches[1]);
|
||||
if (!entry)
|
||||
return HttpUtil::jsonError(res, 404, "error log not found");
|
||||
res.set_header("Content-Type", "application/octet-stream");
|
||||
res.set_header("Content-Disposition", "attachment; filename=\"error_log_" + req.matches[1].str() + ".json\"");
|
||||
res.body = entry->dump(2);
|
||||
});
|
||||
|
||||
svr.Delete(R"(/api/monitoring/error_logs/([^/]+))", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
if (!monitoring_.deleteErrorLog(req.matches[1]))
|
||||
return HttpUtil::jsonError(res, 404, "error log not found");
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
res.body = nlohmann::json{{"ok", true}}.dump();
|
||||
});
|
||||
|
||||
svr.Get("/api/monitoring/hardware_health", [this](const httplib::Request&, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
robot_runtime_.tick();
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
res.body = monitoring_.hardwareHealth(repo_, robot_runtime_.status()).dump();
|
||||
});
|
||||
|
||||
svr.Get("/api/monitoring/safety", [this](const httplib::Request&, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
robot_runtime_.tick();
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
res.body = monitoring_.safetyStatus(robot_runtime_.status()).dump();
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace lm
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "domain/layout_profile.hpp"
|
||||
#include "domain/layout_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
|
||||
|
||||
@@ -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
|
||||
|
||||
35
src/server/api_settings_routes.cpp
Normal file
35
src/server/api_settings_routes.cpp
Normal file
@@ -0,0 +1,35 @@
|
||||
#include "server/api_server.hpp"
|
||||
|
||||
#include "util/http_util.hpp"
|
||||
|
||||
namespace lm {
|
||||
|
||||
void ApiServer::registerSettingsRoutes(httplib::Server& svr)
|
||||
{
|
||||
svr.Get("/api/settings", [this](const httplib::Request&, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
res.body = settings_.get().dump();
|
||||
});
|
||||
|
||||
svr.Put("/api/settings", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
HttpUtil::addCors(res);
|
||||
nlohmann::json body;
|
||||
try
|
||||
{
|
||||
body = nlohmann::json::parse(req.body);
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
return HttpUtil::jsonError(res, 400, "invalid JSON");
|
||||
}
|
||||
std::string err;
|
||||
if (!settings_.put(body, err))
|
||||
return HttpUtil::jsonError(res, 400, err);
|
||||
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||
res.body = settings_.get().dump();
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace lm
|
||||
|
||||
110
src/settings/settings_service.cpp
Normal file
110
src/settings/settings_service.cpp
Normal file
@@ -0,0 +1,110 @@
|
||||
#include "settings/settings_service.hpp"
|
||||
|
||||
#include "storage/database.hpp"
|
||||
|
||||
namespace lm {
|
||||
|
||||
SettingsService::SettingsService(Database& db) : db_(db)
|
||||
{
|
||||
loadUnlocked();
|
||||
ensureDefaultsUnlocked();
|
||||
}
|
||||
|
||||
void SettingsService::loadUnlocked()
|
||||
{
|
||||
settings_ = nlohmann::json::object();
|
||||
nlohmann::json parsed;
|
||||
if (db_.getDocument("settings", parsed) && parsed.is_object())
|
||||
settings_ = parsed;
|
||||
}
|
||||
|
||||
int SettingsService::clampInt(int v, int lo, int hi)
|
||||
{
|
||||
if (v < lo)
|
||||
return lo;
|
||||
if (v > hi)
|
||||
return hi;
|
||||
return v;
|
||||
}
|
||||
|
||||
void SettingsService::ensureDefaultsUnlocked()
|
||||
{
|
||||
if (!settings_.is_object())
|
||||
settings_ = nlohmann::json::object();
|
||||
if (!settings_.contains("retention_mission_runs"))
|
||||
settings_["retention_mission_runs"] = 2000;
|
||||
if (!settings_.contains("retention_system_log"))
|
||||
settings_["retention_system_log"] = 2000;
|
||||
if (!settings_.contains("retention_error_logs"))
|
||||
settings_["retention_error_logs"] = 200;
|
||||
if (!settings_.contains("log_level"))
|
||||
settings_["log_level"] = "info";
|
||||
db_.setDocument("settings", settings_);
|
||||
}
|
||||
|
||||
nlohmann::json SettingsService::sanitize(const nlohmann::json& payload)
|
||||
{
|
||||
nlohmann::json out = nlohmann::json::object();
|
||||
const int mission_runs = clampInt(payload.value("retention_mission_runs", 2000), 10, 20000);
|
||||
const int system_log = clampInt(payload.value("retention_system_log", 2000), 100, 20000);
|
||||
const int error_logs = clampInt(payload.value("retention_error_logs", 200), 10, 5000);
|
||||
std::string lvl = payload.value("log_level", std::string("info"));
|
||||
if (lvl != "error" && lvl != "warn" && lvl != "info" && lvl != "debug")
|
||||
lvl = "info";
|
||||
out["retention_mission_runs"] = mission_runs;
|
||||
out["retention_system_log"] = system_log;
|
||||
out["retention_error_logs"] = error_logs;
|
||||
out["log_level"] = lvl;
|
||||
return out;
|
||||
}
|
||||
|
||||
nlohmann::json SettingsService::get() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
return settings_;
|
||||
}
|
||||
|
||||
bool SettingsService::put(const nlohmann::json& payload, std::string& err)
|
||||
{
|
||||
if (!payload.is_object())
|
||||
{
|
||||
err = "payload must be an object";
|
||||
return false;
|
||||
}
|
||||
const nlohmann::json sanitized = sanitize(payload);
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
settings_ = sanitized;
|
||||
if (!db_.setDocument("settings", settings_))
|
||||
{
|
||||
err = "failed to persist settings";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
int SettingsService::retentionMissionRuns() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
return clampInt(settings_.value("retention_mission_runs", 2000), 10, 20000);
|
||||
}
|
||||
|
||||
int SettingsService::retentionSystemLog() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
return clampInt(settings_.value("retention_system_log", 2000), 100, 20000);
|
||||
}
|
||||
|
||||
int SettingsService::retentionErrorLogs() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
return clampInt(settings_.value("retention_error_logs", 200), 10, 5000);
|
||||
}
|
||||
|
||||
std::string SettingsService::logLevel() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
return settings_.value("log_level", std::string("info"));
|
||||
}
|
||||
|
||||
} // namespace lm
|
||||
|
||||
37
src/settings/settings_service.hpp
Normal file
37
src/settings/settings_service.hpp
Normal file
@@ -0,0 +1,37 @@
|
||||
#pragma once
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
|
||||
namespace lm {
|
||||
|
||||
class Database;
|
||||
|
||||
class SettingsService
|
||||
{
|
||||
public:
|
||||
explicit SettingsService(Database& db);
|
||||
|
||||
nlohmann::json get() const;
|
||||
bool put(const nlohmann::json& payload, std::string& err);
|
||||
|
||||
int retentionMissionRuns() const;
|
||||
int retentionSystemLog() const;
|
||||
int retentionErrorLogs() const;
|
||||
std::string logLevel() const;
|
||||
|
||||
private:
|
||||
Database& db_;
|
||||
mutable std::mutex mu_;
|
||||
nlohmann::json settings_;
|
||||
|
||||
void loadUnlocked();
|
||||
void ensureDefaultsUnlocked();
|
||||
static nlohmann::json sanitize(const nlohmann::json& payload);
|
||||
static int clampInt(int v, int lo, int hi);
|
||||
};
|
||||
|
||||
} // namespace lm
|
||||
|
||||
@@ -185,6 +185,29 @@ CREATE TABLE IF NOT EXISTS dashboard_state (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
385
src/storage/mission_run_store.cpp
Normal file
385
src/storage/mission_run_store.cpp
Normal file
@@ -0,0 +1,385 @@
|
||||
#include "storage/mission_run_store.hpp"
|
||||
|
||||
#include "storage/database.hpp"
|
||||
#include "util/id_util.hpp"
|
||||
|
||||
#include <sqlite3.h>
|
||||
|
||||
namespace lm {
|
||||
|
||||
MissionRunStore::MissionRunStore(Database& db) : db_(db) {}
|
||||
|
||||
namespace {
|
||||
|
||||
bool execSql(sqlite3* db, const char* sql, std::string& err)
|
||||
{
|
||||
char* msg = nullptr;
|
||||
const int rc = sqlite3_exec(db, sql, nullptr, nullptr, &msg);
|
||||
if (rc != SQLITE_OK)
|
||||
{
|
||||
err = msg ? msg : sqlite3_errstr(rc);
|
||||
sqlite3_free(msg);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool trimRuns(sqlite3* db, int keep_last, std::string& err)
|
||||
{
|
||||
if (keep_last <= 0)
|
||||
return true;
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db,
|
||||
"DELETE FROM mission_runs WHERE id NOT IN ("
|
||||
"SELECT id FROM mission_runs ORDER BY COALESCE(started_at, created_at) DESC LIMIT ?1"
|
||||
")",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
{
|
||||
err = sqlite3_errmsg(db);
|
||||
return false;
|
||||
}
|
||||
sqlite3_bind_int(stmt, 1, keep_last);
|
||||
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
|
||||
sqlite3_finalize(stmt);
|
||||
if (!ok)
|
||||
err = sqlite3_errmsg(db);
|
||||
return ok;
|
||||
}
|
||||
|
||||
int settingsRetentionMissionRuns(Database& db)
|
||||
{
|
||||
nlohmann::json s;
|
||||
if (db.getDocument("settings", s) && s.is_object())
|
||||
{
|
||||
const int v = s.value("retention_mission_runs", 2000);
|
||||
if (v >= 10 && v <= 20000)
|
||||
return v;
|
||||
}
|
||||
return 2000;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
static std::string safeString(const nlohmann::json& obj, const char* key, const std::string& fallback = "")
|
||||
{
|
||||
if (!obj.is_object())
|
||||
return fallback;
|
||||
if (!obj.contains(key))
|
||||
return fallback;
|
||||
if (obj[key].is_string())
|
||||
return obj[key].get<std::string>();
|
||||
if (obj[key].is_null())
|
||||
return fallback;
|
||||
return obj[key].dump();
|
||||
}
|
||||
|
||||
bool MissionRunStore::insertFromQueueEntry(const nlohmann::json& queue_entry, std::string& err)
|
||||
{
|
||||
if (!queue_entry.is_object())
|
||||
{
|
||||
err = "queue entry must be an object";
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::string run_id = safeString(queue_entry, "id");
|
||||
if (run_id.empty())
|
||||
{
|
||||
err = "queue entry id missing";
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::string mission_id = safeString(queue_entry, "mission_id");
|
||||
const std::string mission_name = safeString(queue_entry, "mission_name", "Mission");
|
||||
const std::string source = safeString(queue_entry, "source", "ui");
|
||||
const std::string status = safeString(queue_entry, "status", "unknown");
|
||||
const std::string started_at =
|
||||
queue_entry.contains("started_at") && queue_entry["started_at"].is_string() ? queue_entry["started_at"].get<std::string>() : "";
|
||||
const std::string finished_at =
|
||||
queue_entry.contains("finished_at") && queue_entry["finished_at"].is_string() ? queue_entry["finished_at"].get<std::string>() : "";
|
||||
|
||||
const auto log = queue_entry.contains("log") && queue_entry["log"].is_array() ? queue_entry["log"] : nlohmann::json::array();
|
||||
std::string message = "";
|
||||
if (status == "executing")
|
||||
{
|
||||
message = "Running";
|
||||
}
|
||||
else if (!log.empty() && log.back().is_object() && log.back().contains("message"))
|
||||
{
|
||||
message = safeString(log.back(), "message");
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3* db = db_.handle();
|
||||
if (!db)
|
||||
{
|
||||
err = "database not initialized";
|
||||
return false;
|
||||
}
|
||||
|
||||
execSql(db, "BEGIN", err);
|
||||
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db,
|
||||
"INSERT OR REPLACE INTO mission_runs"
|
||||
"(id, mission_id, mission_name, source, status, message, started_at, finished_at, created_at)"
|
||||
"VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
{
|
||||
err = sqlite3_errmsg(db);
|
||||
execSql(db, "ROLLBACK", err);
|
||||
return false;
|
||||
}
|
||||
const std::string now = IdUtil::nowIso8601();
|
||||
sqlite3_bind_text(stmt, 1, run_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, mission_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 3, mission_name.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 4, source.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 5, status.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 6, message.c_str(), -1, SQLITE_TRANSIENT);
|
||||
if (started_at.empty())
|
||||
sqlite3_bind_null(stmt, 7);
|
||||
else
|
||||
sqlite3_bind_text(stmt, 7, started_at.c_str(), -1, SQLITE_TRANSIENT);
|
||||
if (finished_at.empty())
|
||||
sqlite3_bind_null(stmt, 8);
|
||||
else
|
||||
sqlite3_bind_text(stmt, 8, finished_at.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 9, now.c_str(), -1, SQLITE_TRANSIENT);
|
||||
|
||||
const bool ok_insert = sqlite3_step(stmt) == SQLITE_DONE;
|
||||
sqlite3_finalize(stmt);
|
||||
if (!ok_insert)
|
||||
{
|
||||
err = sqlite3_errmsg(db);
|
||||
execSql(db, "ROLLBACK", err);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Replace actions for this run
|
||||
if (sqlite3_prepare_v2(db, "DELETE FROM mission_run_actions WHERE run_id = ?1", -1, &stmt, nullptr) != SQLITE_OK)
|
||||
{
|
||||
err = sqlite3_errmsg(db);
|
||||
execSql(db, "ROLLBACK", err);
|
||||
return false;
|
||||
}
|
||||
sqlite3_bind_text(stmt, 1, run_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
if (sqlite3_prepare_v2(db,
|
||||
"INSERT INTO mission_run_actions(id, run_id, seq, action, state, message, ts) "
|
||||
"VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
{
|
||||
err = sqlite3_errmsg(db);
|
||||
execSql(db, "ROLLBACK", err);
|
||||
return false;
|
||||
}
|
||||
|
||||
int seq = 0;
|
||||
for (const auto& line : log)
|
||||
{
|
||||
if (!line.is_object())
|
||||
continue;
|
||||
const std::string id = IdUtil::newId();
|
||||
const std::string action = safeString(line, "action", safeString(line, "message", "—"));
|
||||
const std::string level = safeString(line, "level", "info");
|
||||
const std::string msg = safeString(line, "message", "");
|
||||
const std::string ts =
|
||||
line.contains("ts") && line["ts"].is_string() ? line["ts"].get<std::string>() : "";
|
||||
|
||||
sqlite3_reset(stmt);
|
||||
sqlite3_clear_bindings(stmt);
|
||||
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, run_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int(stmt, 3, seq++);
|
||||
sqlite3_bind_text(stmt, 4, action.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 5, level.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 6, msg.c_str(), -1, SQLITE_TRANSIENT);
|
||||
if (ts.empty())
|
||||
sqlite3_bind_null(stmt, 7);
|
||||
else
|
||||
sqlite3_bind_text(stmt, 7, ts.c_str(), -1, SQLITE_TRANSIENT);
|
||||
|
||||
if (sqlite3_step(stmt) != SQLITE_DONE)
|
||||
{
|
||||
err = sqlite3_errmsg(db);
|
||||
sqlite3_finalize(stmt);
|
||||
execSql(db, "ROLLBACK", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
// Retention: keep last N runs (MiR-like bounded history)
|
||||
std::string trim_err;
|
||||
trimRuns(db, settingsRetentionMissionRuns(db_), trim_err);
|
||||
|
||||
execSql(db, "COMMIT", err);
|
||||
return true;
|
||||
}
|
||||
|
||||
nlohmann::json MissionRunStore::list(int limit) const
|
||||
{
|
||||
nlohmann::json runs = nlohmann::json::array();
|
||||
sqlite3* db = db_.handle();
|
||||
if (!db)
|
||||
return {{ "runs", runs }};
|
||||
|
||||
limit = std::clamp(limit, 1, 2000);
|
||||
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db,
|
||||
"SELECT id, mission_id, mission_name, source, status, message, started_at, finished_at, created_at "
|
||||
"FROM mission_runs ORDER BY COALESCE(started_at, created_at) DESC LIMIT ?1",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
{
|
||||
return {{ "runs", runs }};
|
||||
}
|
||||
sqlite3_bind_int(stmt, 1, limit);
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW)
|
||||
{
|
||||
auto colText = [&](int idx) -> std::string {
|
||||
const char* t = reinterpret_cast<const char*>(sqlite3_column_text(stmt, idx));
|
||||
return t ? std::string(t) : std::string("");
|
||||
};
|
||||
nlohmann::json row = {{"id", colText(0)},
|
||||
{"mission_id", colText(1)},
|
||||
{"mission_name", colText(2)},
|
||||
{"source", colText(3)},
|
||||
{"status", colText(4)},
|
||||
{"message", colText(5)},
|
||||
{"started_at", colText(6).empty() ? nullptr : nlohmann::json(colText(6))},
|
||||
{"finished_at", colText(7).empty() ? nullptr : nlohmann::json(colText(7))},
|
||||
{"created_at", colText(8)}};
|
||||
runs.push_back(row);
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
return {{ "runs", runs }};
|
||||
}
|
||||
|
||||
std::optional<nlohmann::json> MissionRunStore::findRun(const std::string& run_id) const
|
||||
{
|
||||
sqlite3* db = db_.handle();
|
||||
if (!db || run_id.empty())
|
||||
return std::nullopt;
|
||||
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db,
|
||||
"SELECT id, mission_id, mission_name, source, status, message, started_at, finished_at, created_at "
|
||||
"FROM mission_runs WHERE id = ?1",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
sqlite3_bind_text(stmt, 1, run_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
if (sqlite3_step(stmt) != SQLITE_ROW)
|
||||
{
|
||||
sqlite3_finalize(stmt);
|
||||
return std::nullopt;
|
||||
}
|
||||
auto colText = [&](int idx) -> std::string {
|
||||
const char* t = reinterpret_cast<const char*>(sqlite3_column_text(stmt, idx));
|
||||
return t ? std::string(t) : std::string("");
|
||||
};
|
||||
nlohmann::json row = {{"id", colText(0)},
|
||||
{"mission_id", colText(1)},
|
||||
{"mission_name", colText(2)},
|
||||
{"source", colText(3)},
|
||||
{"status", colText(4)},
|
||||
{"message", colText(5)},
|
||||
{"started_at", colText(6).empty() ? nullptr : nlohmann::json(colText(6))},
|
||||
{"finished_at", colText(7).empty() ? nullptr : nlohmann::json(colText(7))},
|
||||
{"created_at", colText(8)}};
|
||||
sqlite3_finalize(stmt);
|
||||
return row;
|
||||
}
|
||||
|
||||
std::optional<nlohmann::json> MissionRunStore::actionsForRun(const std::string& run_id) const
|
||||
{
|
||||
sqlite3* db = db_.handle();
|
||||
if (!db)
|
||||
return std::nullopt;
|
||||
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db,
|
||||
"SELECT action, state, message, ts FROM mission_run_actions "
|
||||
"WHERE run_id = ?1 ORDER BY seq ASC",
|
||||
-1,
|
||||
&stmt,
|
||||
nullptr) != SQLITE_OK)
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
sqlite3_bind_text(stmt, 1, run_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
nlohmann::json items = nlohmann::json::array();
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW)
|
||||
{
|
||||
auto colText = [&](int idx) -> std::string {
|
||||
const char* t = reinterpret_cast<const char*>(sqlite3_column_text(stmt, idx));
|
||||
return t ? std::string(t) : std::string("");
|
||||
};
|
||||
items.push_back({{"action", colText(0)},
|
||||
{"level", colText(1)},
|
||||
{"message", colText(2)},
|
||||
{"ts", colText(3).empty() ? nullptr : nlohmann::json(colText(3))}});
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
return nlohmann::json{{"items", items}};
|
||||
}
|
||||
|
||||
bool MissionRunStore::deleteRun(const std::string& run_id, std::string& err)
|
||||
{
|
||||
sqlite3* db = db_.handle();
|
||||
if (!db)
|
||||
{
|
||||
err = "database not initialized";
|
||||
return false;
|
||||
}
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db, "DELETE FROM mission_runs WHERE id = ?1", -1, &stmt, nullptr) != SQLITE_OK)
|
||||
{
|
||||
err = sqlite3_errmsg(db);
|
||||
return false;
|
||||
}
|
||||
sqlite3_bind_text(stmt, 1, run_id.c_str(), -1, SQLITE_TRANSIENT);
|
||||
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
|
||||
sqlite3_finalize(stmt);
|
||||
if (!ok)
|
||||
err = sqlite3_errmsg(db);
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool MissionRunStore::clearAll(std::string& err)
|
||||
{
|
||||
sqlite3* db = db_.handle();
|
||||
if (!db)
|
||||
{
|
||||
err = "database not initialized";
|
||||
return false;
|
||||
}
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
if (sqlite3_exec(db, "DELETE FROM mission_runs", nullptr, nullptr, nullptr) != SQLITE_OK)
|
||||
{
|
||||
err = sqlite3_errmsg(db);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace lm
|
||||
|
||||
31
src/storage/mission_run_store.hpp
Normal file
31
src/storage/mission_run_store.hpp
Normal file
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
namespace lm {
|
||||
|
||||
class Database;
|
||||
|
||||
class MissionRunStore
|
||||
{
|
||||
public:
|
||||
explicit MissionRunStore(Database& db);
|
||||
|
||||
bool insertFromQueueEntry(const nlohmann::json& queue_entry, std::string& err);
|
||||
nlohmann::json list(int limit = 200) const;
|
||||
std::optional<nlohmann::json> findRun(const std::string& run_id) const;
|
||||
std::optional<nlohmann::json> actionsForRun(const std::string& run_id) const;
|
||||
bool deleteRun(const std::string& run_id, std::string& err);
|
||||
bool clearAll(std::string& err);
|
||||
|
||||
private:
|
||||
Database& db_;
|
||||
mutable std::mutex mu_;
|
||||
};
|
||||
|
||||
} // namespace lm
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Integration tests for lidar_manager_web REST API."""
|
||||
"""Integration tests for robot_app REST API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
12
www/app.js
12
www/app.js
@@ -10,6 +10,7 @@ const pageConfigEl = el("pageConfig");
|
||||
const pageMapsEl = el("pageMaps");
|
||||
const pageMissionsEl = el("pageMissions");
|
||||
const pageIntegrationsEl = el("pageIntegrations");
|
||||
const pageSettingsEl = el("pageSettings");
|
||||
const pageSoundsEl = el("pageSounds");
|
||||
const pageTransitionsEl = el("pageTransitions");
|
||||
const pageUsersEl = el("pageUsers");
|
||||
@@ -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);
|
||||
|
||||
@@ -143,7 +143,7 @@
|
||||
}
|
||||
|
||||
function canAccessPage(page) {
|
||||
if (page === "config") return isDistributor();
|
||||
if (page === "config" || page === "simulation") return isDistributor();
|
||||
|
||||
const map = {
|
||||
dashboard: "dashboard",
|
||||
|
||||
322
www/i18n.js
322
www/i18n.js
@@ -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.",
|
||||
|
||||
391
www/index.html
391
www/index.html
@@ -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">«</button>
|
||||
<button type="button" class="mapsMirPageBtn" id="systemLogPagePrev">‹</button>
|
||||
<span id="systemLogPageLabel" class="mapsMirPageLabel">Page 1 of 1</span>
|
||||
<button type="button" class="mapsMirPageBtn" id="systemLogPageNext">›</button>
|
||||
<button type="button" class="mapsMirPageBtn" id="systemLogPageLast">»</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mapsMirTableWrap">
|
||||
<table class="mapsMirTable mapsMirTable--systemLog" id="systemLogTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="monMirThState" data-i18n="monitoring.systemLog.colState">State</th>
|
||||
<th data-i18n="monitoring.systemLog.colModule">Module</th>
|
||||
<th data-i18n="monitoring.systemLog.colMessage">Message</th>
|
||||
<th data-i18n="monitoring.systemLog.colTime">Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="systemLogList"></tbody>
|
||||
</table>
|
||||
<div id="systemLogListEmpty" class="mapsMirEmpty" hidden data-i18n="monitoring.systemLog.empty">No system log entries.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="monitoringErrorLogsView" class="mapsMirPage" hidden>
|
||||
<header class="mapsMirHeader">
|
||||
<div class="mapsMirHeaderText">
|
||||
<h1 class="mapsMirTitle" data-i18n="monitoring.errorLogs.title">Error logs</h1>
|
||||
<p class="mapsMirSubtitle" data-i18n="monitoring.errorLogs.subtitle">Detected system errors.</p>
|
||||
</div>
|
||||
<div class="mapsMirHeaderActions">
|
||||
<button type="button" class="mapsMirBtn mapsMirBtn--primary" id="errorLogsGenerateBtn"><span data-i18n="monitoring.errorLogs.generate">Generate log</span></button>
|
||||
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="errorLogsDeleteAllBtn"><span data-i18n="monitoring.errorLogs.deleteAll">Delete all</span></button>
|
||||
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="errorLogsRefreshBtn"><span data-i18n="monitoring.refresh">Refresh</span></button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="mapsMirFilterBar">
|
||||
<label class="mapsMirFilterLabel" for="errorLogsFilterInput" data-i18n="monitoring.filterLabel">Filter:</label>
|
||||
<input type="search" id="errorLogsFilterInput" class="mapsMirFilterInput" data-i18n-placeholder="monitoring.errorLogs.filterPlaceholder" autocomplete="off" />
|
||||
<span id="errorLogsFilterCount" class="mapsMirFilterCount">0</span>
|
||||
</div>
|
||||
<div class="mapsMirTableWrap">
|
||||
<table class="mapsMirTable mapsMirTable--errorLogs" id="errorLogsTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="monitoring.errorLogs.colDescription">Description</th>
|
||||
<th data-i18n="monitoring.errorLogs.colModule">Module</th>
|
||||
<th data-i18n="monitoring.errorLogs.colTime">Time</th>
|
||||
<th class="mapsMirThFunctions" data-i18n="monitoring.errorLogs.colFunctions">Functions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="errorLogsList"></tbody>
|
||||
</table>
|
||||
<div id="errorLogsListEmpty" class="mapsMirEmpty" hidden data-i18n="monitoring.errorLogs.empty">No errors logged.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="monitoringHardwareView" class="mapsMirPage" hidden>
|
||||
<header class="mapsMirHeader">
|
||||
<div class="mapsMirHeaderText">
|
||||
<h1 class="mapsMirTitle" data-i18n="monitoring.hardware.title">Hardware health</h1>
|
||||
<p class="mapsMirSubtitle" data-i18n="monitoring.hardware.subtitle">Robot hardware component status.</p>
|
||||
</div>
|
||||
<div class="mapsMirHeaderActions">
|
||||
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="hardwareRefreshBtn"><span data-i18n="monitoring.refresh">Refresh</span></button>
|
||||
</div>
|
||||
</header>
|
||||
<div id="hardwareGroupsList" class="monHwList"></div>
|
||||
<div id="hardwareListEmpty" class="mapsMirEmpty" hidden data-i18n="monitoring.hardware.empty">No hardware data.</div>
|
||||
</div>
|
||||
|
||||
<div id="monitoringSafetyView" class="mapsMirPage" hidden>
|
||||
<header class="mapsMirHeader">
|
||||
<div class="mapsMirHeaderText">
|
||||
<h1 class="mapsMirTitle" data-i18n="monitoring.safety.title">Safety system</h1>
|
||||
<p class="mapsMirSubtitle" data-i18n="monitoring.safety.subtitle">Live emergency stop and laser scanner status.</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="monSafetyGrid" id="safetyGrid">
|
||||
<div class="monSafetyCard" id="safetyEstopCard">
|
||||
<h2 class="monSafetyCardTitle" data-i18n="monitoring.safety.estop">Emergency stop</h2>
|
||||
<div class="monSafetyStatus" id="safetyEstopStatus">—</div>
|
||||
</div>
|
||||
<div class="monSafetyCard" id="safetyFrontCard">
|
||||
<h2 class="monSafetyCardTitle" data-i18n="monitoring.safety.frontScanner">Front scanner</h2>
|
||||
<div class="monSafetyStatus" id="safetyFrontStatus">—</div>
|
||||
</div>
|
||||
<div class="monSafetyCard" id="safetyRearCard">
|
||||
<h2 class="monSafetyCardTitle" data-i18n="monitoring.safety.rearScanner">Rear scanner</h2>
|
||||
<div class="monSafetyStatus" id="safetyRearStatus">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="monitoringPlaceholderView" class="mapsMirPage" hidden>
|
||||
<header class="mapsMirHeader">
|
||||
<div class="mapsMirHeaderText">
|
||||
<h1 class="mapsMirTitle" id="monitoringPlaceholderTitle">—</h1>
|
||||
<p class="mapsMirSubtitle">
|
||||
<span id="monitoringPlaceholderSubtitle">—</span>
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="mapsMirTableWrap">
|
||||
<p class="mapsMirEmpty" id="monitoringPlaceholderBody" data-i18n="monitoring.comingSoon">Tính năng đang phát triển.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="monitoringMissionLogView" class="mapsMirPage" hidden>
|
||||
<header class="mapsMirHeader">
|
||||
<div class="mapsMirHeaderText">
|
||||
<h1 class="mapsMirTitle" data-i18n="monitoring.missionLog.title">Mission log</h1>
|
||||
<p class="mapsMirSubtitle">
|
||||
<span data-i18n="monitoring.missionLog.subtitle">Missions executed and currently running.</span>
|
||||
<button type="button" class="mapsMirHelpBtn" id="missionLogHelpBtn" data-i18n-title="monitoring.missionLog.helpTitle" aria-label="Help">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="7" fill="none" stroke="currentColor" stroke-width="1.5"/><text x="8" y="11.5" text-anchor="middle" font-size="10" font-weight="700" fill="currentColor">?</text></svg>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
<div class="mapsMirHeaderActions">
|
||||
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="missionLogRefreshBtn">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M11 2v3H8M3 12V9h3" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/><path d="M2.5 5.5A5 5 0 0 1 11 3.5l1.5 1.5M12.5 8.5A5 5 0 0 1 3.5 10.5L2 9" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
|
||||
<span data-i18n="monitoring.refresh">Refresh</span>
|
||||
</button>
|
||||
<button type="button" class="mapsMirBtn mapsMirBtn--outline mapsMirBtn--danger" id="missionLogClearHistoryBtn">
|
||||
<span data-i18n="monitoring.missionLog.clearHistory">Clear history</span>
|
||||
</button>
|
||||
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="missionLogClearFiltersBtn">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><circle cx="7" cy="7" r="5.5" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M4.5 4.5l5 5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
|
||||
<span data-i18n="monitoring.clearFilters">Clear filters</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mapsMirFilterBar">
|
||||
<label class="mapsMirFilterLabel" for="missionLogFilterInput" data-i18n="monitoring.filterLabel">Filter:</label>
|
||||
<input type="search" id="missionLogFilterInput" class="mapsMirFilterInput" data-i18n-placeholder="monitoring.missionLog.filterPlaceholder" placeholder="Filter by mission or state..." autocomplete="off" />
|
||||
<span id="missionLogFilterCount" class="mapsMirFilterCount">0 item(s) found</span>
|
||||
<div class="mapsMirPager">
|
||||
<button type="button" class="mapsMirPageBtn" id="missionLogPageFirst" aria-label="First page">«</button>
|
||||
<button type="button" class="mapsMirPageBtn" id="missionLogPagePrev" aria-label="Previous page">‹</button>
|
||||
<span id="missionLogPageLabel" class="mapsMirPageLabel">Page 1 of 1</span>
|
||||
<button type="button" class="mapsMirPageBtn" id="missionLogPageNext" aria-label="Next page">›</button>
|
||||
<button type="button" class="mapsMirPageBtn" id="missionLogPageLast" aria-label="Last page">»</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mapsMirTableWrap">
|
||||
<table class="mapsMirTable mapsMirTable--missionLog" id="missionLogTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="monMirThIcon" aria-hidden="true"></th>
|
||||
<th data-i18n="monitoring.missionLog.colMission">Mission</th>
|
||||
<th data-i18n="monitoring.missionLog.colState">State</th>
|
||||
<th data-i18n="monitoring.missionLog.colMessage">Message</th>
|
||||
<th data-i18n="monitoring.missionLog.colStart">Start time</th>
|
||||
<th data-i18n="monitoring.missionLog.colRanFor">Ran for</th>
|
||||
<th data-i18n="monitoring.missionLog.colStartedBy">Started by</th>
|
||||
<th class="mapsMirThFunctions" data-i18n="monitoring.missionLog.colFunctions">Functions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="missionLogList"></tbody>
|
||||
</table>
|
||||
<div id="missionLogListEmpty" class="mapsMirEmpty" hidden data-i18n="monitoring.missionLog.empty">No missions in the log yet.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="monitoringActionLogView" class="mapsMirPage" hidden>
|
||||
<header class="mapsMirHeader">
|
||||
<div class="mapsMirHeaderText">
|
||||
<button type="button" class="mapsMirBtn mapsMirBtn--outline monitoringActionLogBack" id="actionLogBackBtn">
|
||||
<span data-i18n="monitoring.actionLog.back">← Mission log</span>
|
||||
</button>
|
||||
<h1 class="mapsMirTitle" id="actionLogTitle" data-i18n="monitoring.actionLog.title">Mission action log</h1>
|
||||
<p class="mapsMirSubtitle">
|
||||
<span id="actionLogSubtitle">—</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="mapsMirHeaderActions">
|
||||
<a class="mapsMirBtn mapsMirBtn--outline" id="actionLogDownloadBtn" href="#" download>
|
||||
<span data-i18n="monitoring.actionLog.download">Download</span>
|
||||
</a>
|
||||
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="actionLogClearFiltersBtn">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><circle cx="7" cy="7" r="5.5" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M4.5 4.5l5 5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
|
||||
<span data-i18n="monitoring.clearFilters">Clear filters</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mapsMirFilterBar">
|
||||
<label class="mapsMirFilterLabel" for="actionLogFilterInput" data-i18n="monitoring.filterLabel">Filter:</label>
|
||||
<input type="search" id="actionLogFilterInput" class="mapsMirFilterInput" data-i18n-placeholder="monitoring.actionLog.filterPlaceholder" placeholder="Filter by action, state or message..." autocomplete="off" />
|
||||
<span id="actionLogFilterCount" class="mapsMirFilterCount">0 item(s) found</span>
|
||||
<div class="mapsMirPager">
|
||||
<button type="button" class="mapsMirPageBtn" id="actionLogPageFirst" aria-label="First page">«</button>
|
||||
<button type="button" class="mapsMirPageBtn" id="actionLogPagePrev" aria-label="Previous page">‹</button>
|
||||
<span id="actionLogPageLabel" class="mapsMirPageLabel">Page 1 of 1</span>
|
||||
<button type="button" class="mapsMirPageBtn" id="actionLogPageNext" aria-label="Next page">›</button>
|
||||
<button type="button" class="mapsMirPageBtn" id="actionLogPageLast" aria-label="Last page">»</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mapsMirTableWrap">
|
||||
<table class="mapsMirTable mapsMirTable--actionLog" id="actionLogTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="monMirThIcon" aria-hidden="true"></th>
|
||||
<th data-i18n="monitoring.actionLog.colAction">Action</th>
|
||||
<th data-i18n="monitoring.actionLog.colState">State</th>
|
||||
<th data-i18n="monitoring.actionLog.colMessage">Message</th>
|
||||
<th data-i18n="monitoring.actionLog.colStart">Start time</th>
|
||||
<th data-i18n="monitoring.actionLog.colRanFor">Ran for</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="actionLogList"></tbody>
|
||||
</table>
|
||||
<div id="actionLogListEmpty" class="mapsMirEmpty" hidden data-i18n="monitoring.actionLog.empty">No actions logged for this mission.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div 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
927
www/monitoring.js
Normal file
@@ -0,0 +1,927 @@
|
||||
(() => {
|
||||
const PAGE_SIZE = 10;
|
||||
const POLL_MS = 2500;
|
||||
|
||||
const ICONS = {
|
||||
mission: `<svg class="monMirIcon" width="22" height="22" viewBox="0 0 22 22" aria-hidden="true"><rect x="4" y="5" width="14" height="12" rx="2" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M7 9h8M7 12h5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>`,
|
||||
action: `<svg class="monMirIcon" width="22" height="22" viewBox="0 0 22 22" aria-hidden="true"><circle cx="11" cy="11" r="7" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M11 7v4l3 2" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>`,
|
||||
view: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M1 7s2.5-4 6-4 6 4 6 4-2.5 4-6 4-6-4-6-4z" fill="none" stroke="currentColor" stroke-width="1.2"/><circle cx="7" cy="7" r="1.8" fill="none" stroke="currentColor" stroke-width="1.2"/></svg>`,
|
||||
download: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M7 2v7M4 7l3 3 3-3" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><path d="M2 11h10" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>`,
|
||||
delete: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M4 4l6 6M10 4l-6 6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>`,
|
||||
};
|
||||
|
||||
const VIEW_IDS = [
|
||||
"monitoringAnalyticsView",
|
||||
"monitoringSystemLogView",
|
||||
"monitoringErrorLogsView",
|
||||
"monitoringHardwareView",
|
||||
"monitoringSafetyView",
|
||||
"monitoringPlaceholderView",
|
||||
"monitoringMissionLogView",
|
||||
"monitoringActionLogView",
|
||||
];
|
||||
|
||||
const el = (id) => document.getElementById(id);
|
||||
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
|
||||
|
||||
const store = {
|
||||
section: "analytics",
|
||||
view: "analytics",
|
||||
queue: [],
|
||||
runner: {},
|
||||
runs: [],
|
||||
runActions: new Map(),
|
||||
selectedEntryId: null,
|
||||
missionFilter: "",
|
||||
missionPage: 1,
|
||||
actionFilter: "",
|
||||
actionPage: 1,
|
||||
systemLogItems: [],
|
||||
systemLogFilter: "",
|
||||
systemLogPage: 1,
|
||||
errorLogItems: [],
|
||||
errorLogFilter: "",
|
||||
hardwareGroups: [],
|
||||
hardwareExpanded: new Set(),
|
||||
analytics: null,
|
||||
analyticsChartMode: "bar",
|
||||
safety: null,
|
||||
pollTimer: null,
|
||||
};
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
async function apiJson(url, opts = {}) {
|
||||
const res = await fetch(url, { credentials: "include", ...opts });
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
if (!res.ok) throw new Error((data && data.error) || text || res.statusText);
|
||||
return data;
|
||||
}
|
||||
|
||||
function hideAllViews() {
|
||||
VIEW_IDS.forEach((id) => {
|
||||
const node = el(id);
|
||||
if (!node) return;
|
||||
node.hidden = true;
|
||||
node.setAttribute("aria-hidden", "true");
|
||||
});
|
||||
}
|
||||
|
||||
function showView(node) {
|
||||
hideAllViews();
|
||||
if (!node) return;
|
||||
node.hidden = false;
|
||||
node.removeAttribute("aria-hidden");
|
||||
}
|
||||
|
||||
function pageCount(total) {
|
||||
return Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
}
|
||||
|
||||
function formatTime(iso) {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return String(iso);
|
||||
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
}
|
||||
|
||||
function formatDateYmd(d) {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function addDaysYmd(ymd, delta) {
|
||||
const d = new Date(`${ymd}T12:00:00`);
|
||||
d.setDate(d.getDate() + delta);
|
||||
return formatDateYmd(d);
|
||||
}
|
||||
|
||||
function startPoll(fn) {
|
||||
stopPoll();
|
||||
store.pollTimer = window.setInterval(() => {
|
||||
fn().catch(() => {});
|
||||
}, POLL_MS);
|
||||
}
|
||||
|
||||
function stopPoll() {
|
||||
if (store.pollTimer) {
|
||||
window.clearInterval(store.pollTimer);
|
||||
store.pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function stateDotClass(state) {
|
||||
if (state === "error") return "monMirStateDot--error";
|
||||
if (state === "warn") return "monMirStateDot--warn";
|
||||
if (state === "info") return "monMirStateDot--info";
|
||||
return "monMirStateDot--ok";
|
||||
}
|
||||
|
||||
function hwStatusClass(status) {
|
||||
if (status === "error") return "monHwStatus--error";
|
||||
if (status === "warn") return "monHwStatus--warn";
|
||||
return "monHwStatus--ok";
|
||||
}
|
||||
|
||||
// ——— Analytics ———
|
||||
|
||||
function initAnalyticsPresets() {
|
||||
const wrap = el("analyticsPresets");
|
||||
if (!wrap) return;
|
||||
const presets = [
|
||||
{ key: "week", label: "monitoring.analytics.presetWeek", days: -6 },
|
||||
{ key: "7d", label: "monitoring.analytics.preset7d", days: -6 },
|
||||
{ key: "30d", label: "monitoring.analytics.preset30d", days: -29 },
|
||||
{ key: "365d", label: "monitoring.analytics.preset365d", days: -364 },
|
||||
];
|
||||
wrap.innerHTML = presets
|
||||
.map(
|
||||
(p) =>
|
||||
`<button type="button" class="mapsMirBtn mapsMirBtn--outline monAnalyticsPresetBtn" data-preset-days="${p.days}" data-i18n="${p.label}">${t(p.label)}</button>`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function setAnalyticsDateRange(start, end) {
|
||||
if (el("analyticsStartDate")) el("analyticsStartDate").value = start;
|
||||
if (el("analyticsEndDate")) el("analyticsEndDate").value = end;
|
||||
}
|
||||
|
||||
async function refreshAnalytics() {
|
||||
const start = el("analyticsStartDate")?.value || "";
|
||||
const end = el("analyticsEndDate")?.value || "";
|
||||
const grouping = el("analyticsGrouping")?.value || "day";
|
||||
const qs = new URLSearchParams();
|
||||
if (start) qs.set("start", start);
|
||||
if (end) qs.set("end", end);
|
||||
if (grouping) qs.set("grouping", grouping);
|
||||
store.analytics = await apiJson(`/api/monitoring/analytics?${qs}`);
|
||||
renderAnalyticsChart();
|
||||
}
|
||||
|
||||
function renderAnalyticsChart() {
|
||||
const data = store.analytics;
|
||||
const svg = el("analyticsChart");
|
||||
const empty = el("analyticsChartEmpty");
|
||||
const totalEl = el("analyticsTotalLabel");
|
||||
if (!svg || !data) return;
|
||||
|
||||
const buckets = Array.isArray(data.buckets) ? data.buckets : [];
|
||||
const mode = el("analyticsChartMode")?.value || store.analyticsChartMode || "bar";
|
||||
store.analyticsChartMode = mode;
|
||||
|
||||
if (totalEl) {
|
||||
totalEl.textContent = t("monitoring.analytics.totalPeriod", {
|
||||
n: (data.total_meters || 0).toFixed(1),
|
||||
lifetime: (data.lifetime_meters || 0).toFixed(1),
|
||||
});
|
||||
}
|
||||
|
||||
if (empty) empty.hidden = buckets.length > 0;
|
||||
svg.hidden = buckets.length === 0;
|
||||
if (!buckets.length) {
|
||||
svg.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const values = buckets.map((b) => (mode === "accumulated" ? b.accumulated : b.meters) || 0);
|
||||
const maxVal = Math.max(...values, 1);
|
||||
const padL = 48;
|
||||
const padR = 16;
|
||||
const padT = 16;
|
||||
const padB = 40;
|
||||
const w = 800;
|
||||
const h = 280;
|
||||
const chartW = w - padL - padR;
|
||||
const chartH = h - padT - padB;
|
||||
const barGap = 4;
|
||||
const barW = Math.max(8, (chartW - barGap * (buckets.length - 1)) / buckets.length);
|
||||
|
||||
let svgBody = "";
|
||||
values.forEach((val, i) => {
|
||||
const barH = (val / maxVal) * chartH;
|
||||
const x = padL + i * (barW + barGap);
|
||||
const y = padT + chartH - barH;
|
||||
const label = String(buckets[i].label || "").slice(5);
|
||||
svgBody += `<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${barW.toFixed(1)}" height="${barH.toFixed(1)}" class="monAnalyticsBar" rx="2"><title>${escapeHtml(buckets[i].label)}: ${val.toFixed(1)} m</title></rect>`;
|
||||
svgBody += `<text x="${(x + barW / 2).toFixed(1)}" y="${h - 12}" text-anchor="middle" class="monAnalyticsLabel">${escapeHtml(label)}</text>`;
|
||||
});
|
||||
|
||||
svgBody += `<line x1="${padL}" y1="${padT + chartH}" x2="${w - padR}" y2="${padT + chartH}" class="monAnalyticsAxis"/>`;
|
||||
svgBody += `<text x="${padL - 8}" y="${padT + 8}" text-anchor="end" class="monAnalyticsLabel">${maxVal.toFixed(0)}m</text>`;
|
||||
svg.innerHTML = svgBody;
|
||||
}
|
||||
|
||||
function showAnalytics() {
|
||||
store.view = "analytics";
|
||||
store.section = "analytics";
|
||||
showView(el("monitoringAnalyticsView"));
|
||||
const today = formatDateYmd(new Date());
|
||||
if (!el("analyticsStartDate")?.value) setAnalyticsDateRange(addDaysYmd(today, -6), today);
|
||||
startPoll(refreshAnalytics);
|
||||
void refreshAnalytics();
|
||||
}
|
||||
|
||||
// ——— System log ———
|
||||
|
||||
function filteredSystemLog() {
|
||||
const q = store.systemLogFilter.trim().toLowerCase();
|
||||
let items = [...store.systemLogItems];
|
||||
if (q) {
|
||||
items = items.filter((e) => {
|
||||
return (
|
||||
String(e.module || "").toLowerCase().includes(q) ||
|
||||
String(e.message || "").toLowerCase().includes(q) ||
|
||||
String(e.state || "").toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function renderSystemLog() {
|
||||
const listEl = el("systemLogList");
|
||||
if (!listEl) return;
|
||||
const items = filteredSystemLog();
|
||||
const total = items.length;
|
||||
const pages = pageCount(total);
|
||||
if (store.systemLogPage > pages) store.systemLogPage = pages;
|
||||
const start = (store.systemLogPage - 1) * PAGE_SIZE;
|
||||
const pageItems = items.slice(start, start + PAGE_SIZE);
|
||||
|
||||
if (el("systemLogFilterCount")) el("systemLogFilterCount").textContent = t("monitoring.itemsFound", { n: total });
|
||||
if (el("systemLogPageLabel")) el("systemLogPageLabel").textContent = t("monitoring.pageOf", { page: store.systemLogPage, total: pages });
|
||||
|
||||
listEl.innerHTML = "";
|
||||
const tableEl = el("systemLogTable");
|
||||
const emptyEl = el("systemLogListEmpty");
|
||||
if (tableEl) tableEl.hidden = total === 0;
|
||||
if (emptyEl) {
|
||||
emptyEl.hidden = total > 0;
|
||||
emptyEl.textContent = store.systemLogFilter ? t("monitoring.systemLog.emptyFilter") : t("monitoring.systemLog.empty");
|
||||
}
|
||||
|
||||
pageItems.forEach((entry) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "monMirRow";
|
||||
tr.innerHTML = `
|
||||
<td><span class="monMirStateDot ${stateDotClass(entry.state)}" title="${escapeHtml(entry.state || "")}"></span></td>
|
||||
<td>${escapeHtml(entry.module || "—")}</td>
|
||||
<td class="monMirMessageCell">${escapeHtml(entry.message || "—")}</td>
|
||||
<td>${escapeHtml(formatTime(entry.ts))}</td>`;
|
||||
listEl.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshSystemLog() {
|
||||
const data = await apiJson("/api/monitoring/system_log");
|
||||
store.systemLogItems = Array.isArray(data.items) ? data.items : [];
|
||||
renderSystemLog();
|
||||
}
|
||||
|
||||
function showSystemLog() {
|
||||
store.view = "system-log";
|
||||
store.section = "monitoring-log";
|
||||
showView(el("monitoringSystemLogView"));
|
||||
startPoll(refreshSystemLog);
|
||||
void refreshSystemLog();
|
||||
}
|
||||
|
||||
// ——— Error logs ———
|
||||
|
||||
function filteredErrorLogs() {
|
||||
const q = store.errorLogFilter.trim().toLowerCase();
|
||||
let items = [...store.errorLogItems];
|
||||
if (q) {
|
||||
items = items.filter((e) => {
|
||||
return (
|
||||
String(e.description || "").toLowerCase().includes(q) ||
|
||||
String(e.module || "").toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function renderErrorLogs() {
|
||||
const listEl = el("errorLogsList");
|
||||
if (!listEl) return;
|
||||
const items = filteredErrorLogs();
|
||||
const total = items.length;
|
||||
|
||||
if (el("errorLogsFilterCount")) el("errorLogsFilterCount").textContent = t("monitoring.itemsFound", { n: total });
|
||||
|
||||
listEl.innerHTML = "";
|
||||
const tableEl = el("errorLogsTable");
|
||||
const emptyEl = el("errorLogsListEmpty");
|
||||
if (tableEl) tableEl.hidden = total === 0;
|
||||
if (emptyEl) {
|
||||
emptyEl.hidden = total > 0;
|
||||
emptyEl.textContent = store.errorLogFilter ? t("monitoring.errorLogs.emptyFilter") : t("monitoring.errorLogs.empty");
|
||||
}
|
||||
|
||||
items.forEach((entry) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "monMirRow";
|
||||
tr.innerHTML = `
|
||||
<td>${escapeHtml(entry.description || "—")}</td>
|
||||
<td>${escapeHtml(entry.module || "—")}</td>
|
||||
<td>${escapeHtml(formatTime(entry.ts))}</td>
|
||||
<td class="mapsMirTdFunctions">
|
||||
<a class="mapsMirIconBtn" href="/api/monitoring/error_logs/${encodeURIComponent(entry.id)}/download" download title="${escapeHtml(t("monitoring.errorLogs.download"))}">${ICONS.download}</a>
|
||||
<button type="button" class="mapsMirIconBtn mapsMirIconBtn--danger" data-delete-error="${escapeHtml(entry.id)}" title="${escapeHtml(t("monitoring.errorLogs.delete"))}">${ICONS.delete}</button>
|
||||
</td>`;
|
||||
listEl.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshErrorLogs() {
|
||||
const data = await apiJson("/api/monitoring/error_logs");
|
||||
store.errorLogItems = Array.isArray(data.items) ? data.items : [];
|
||||
renderErrorLogs();
|
||||
}
|
||||
|
||||
function showErrorLogs() {
|
||||
store.view = "error-logs";
|
||||
store.section = "error-logs";
|
||||
showView(el("monitoringErrorLogsView"));
|
||||
startPoll(refreshErrorLogs);
|
||||
void refreshErrorLogs();
|
||||
}
|
||||
|
||||
// ——— Hardware health ———
|
||||
|
||||
function renderHardware() {
|
||||
const listEl = el("hardwareGroupsList");
|
||||
const emptyEl = el("hardwareListEmpty");
|
||||
if (!listEl) return;
|
||||
const groups = store.hardwareGroups;
|
||||
listEl.innerHTML = "";
|
||||
if (emptyEl) emptyEl.hidden = groups.length > 0;
|
||||
if (!groups.length) return;
|
||||
|
||||
groups.forEach((group) => {
|
||||
const gid = group.id || group.name;
|
||||
const expanded = store.hardwareExpanded.has(gid);
|
||||
const section = document.createElement("section");
|
||||
section.className = "monHwGroup";
|
||||
section.innerHTML = `
|
||||
<button type="button" class="monHwGroupHeader" data-hw-toggle="${escapeHtml(gid)}" aria-expanded="${expanded}">
|
||||
<span class="monHwGroupArrow">${expanded ? "▾" : "▸"}</span>
|
||||
<span class="monHwStatusDot ${hwStatusClass(group.status)}"></span>
|
||||
<span class="monHwGroupName">${escapeHtml(group.name || gid)}</span>
|
||||
<span class="monHwGroupBadge ${hwStatusClass(group.status)}">${escapeHtml(group.status_label || group.status || "")}</span>
|
||||
</button>
|
||||
<div class="monHwGroupBody" ${expanded ? "" : "hidden"}></div>`;
|
||||
const body = section.querySelector(".monHwGroupBody");
|
||||
const components = Array.isArray(group.components) ? group.components : [];
|
||||
components.forEach((c) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "monHwComponent";
|
||||
row.innerHTML = `
|
||||
<span class="monHwStatusDot ${hwStatusClass(c.status)}"></span>
|
||||
<span class="monHwComponentName">${escapeHtml(c.name || c.id || "—")}</span>
|
||||
<span class="monHwComponentMsg">${escapeHtml(c.message || c.status_label || "")}</span>`;
|
||||
body?.appendChild(row);
|
||||
});
|
||||
listEl.appendChild(section);
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshHardware() {
|
||||
const data = await apiJson("/api/monitoring/hardware_health");
|
||||
store.hardwareGroups = Array.isArray(data.groups) ? data.groups : [];
|
||||
renderHardware();
|
||||
}
|
||||
|
||||
function showHardware() {
|
||||
store.view = "hardware-health";
|
||||
store.section = "hardware-health";
|
||||
showView(el("monitoringHardwareView"));
|
||||
startPoll(refreshHardware);
|
||||
void refreshHardware();
|
||||
}
|
||||
|
||||
// ——— Safety ———
|
||||
|
||||
function renderSafety() {
|
||||
const s = store.safety || {};
|
||||
const estop = s.emergency_stop || "released";
|
||||
const front = s.front_scanner || "free";
|
||||
const rear = s.rear_scanner || "free";
|
||||
|
||||
const setCard = (statusEl, cardEl, value, okValues, okKey, badKey) => {
|
||||
if (!statusEl || !cardEl) return;
|
||||
const ok = okValues.includes(value);
|
||||
statusEl.textContent = ok ? t(okKey) : t(badKey);
|
||||
cardEl.classList.toggle("monSafetyCard--ok", ok);
|
||||
cardEl.classList.toggle("monSafetyCard--bad", !ok);
|
||||
};
|
||||
|
||||
setCard(el("safetyEstopStatus"), el("safetyEstopCard"), estop, ["released"], "monitoring.safety.estopReleased", "monitoring.safety.estopActivated");
|
||||
setCard(el("safetyFrontStatus"), el("safetyFrontCard"), front, ["free"], "monitoring.safety.scannerFree", "monitoring.safety.scannerBlocked");
|
||||
setCard(el("safetyRearStatus"), el("safetyRearCard"), rear, ["free"], "monitoring.safety.scannerFree", "monitoring.safety.scannerBlocked");
|
||||
}
|
||||
|
||||
async function refreshSafety() {
|
||||
store.safety = await apiJson("/api/monitoring/safety");
|
||||
renderSafety();
|
||||
}
|
||||
|
||||
function showSafety() {
|
||||
store.view = "safety-system";
|
||||
store.section = "safety-system";
|
||||
showView(el("monitoringSafetyView"));
|
||||
startPoll(refreshSafety);
|
||||
void refreshSafety();
|
||||
}
|
||||
|
||||
// ——— Mission log (Phase 1) ———
|
||||
|
||||
const missionLogViewEl = () => el("monitoringMissionLogView");
|
||||
const actionLogViewEl = () => el("monitoringActionLogView");
|
||||
const missionLogListEl = () => el("missionLogList");
|
||||
const actionLogListEl = () => el("actionLogList");
|
||||
|
||||
function findEntry(id) {
|
||||
return (
|
||||
store.queue.find((e) => e && e.id === id) ||
|
||||
store.runs.find((e) => e && e.id === id) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function missionLogEntries() {
|
||||
const items = [...store.runs, ...store.queue];
|
||||
const seen = new Set();
|
||||
return items.filter((e) => {
|
||||
if (!e || typeof e !== "object") return false;
|
||||
if (seen.has(e.id)) return false;
|
||||
seen.add(e.id);
|
||||
const status = String(e.status || "");
|
||||
return status === "executing" || status === "completed" || status === "failed" || status === "cancelled";
|
||||
});
|
||||
}
|
||||
|
||||
function formatDuration(ms) {
|
||||
if (!Number.isFinite(ms) || ms < 0) return "—";
|
||||
const sec = Math.floor(ms / 1000);
|
||||
if (sec < 60) return `${sec}s`;
|
||||
const min = Math.floor(sec / 60);
|
||||
const rem = sec % 60;
|
||||
if (min < 60) return `${min}m ${rem}s`;
|
||||
const hr = Math.floor(min / 60);
|
||||
return `${hr}h ${min % 60}m`;
|
||||
}
|
||||
|
||||
function entryDuration(entry) {
|
||||
const start = entry.started_at ? new Date(entry.started_at).getTime() : NaN;
|
||||
if (!Number.isFinite(start)) return "—";
|
||||
const end = entry.finished_at ? new Date(entry.finished_at).getTime() : Date.now();
|
||||
return formatDuration(end - start);
|
||||
}
|
||||
|
||||
function entryMessage(entry) {
|
||||
if (entry.status === "executing") {
|
||||
const cur = store.runner?.current_action;
|
||||
if (cur) return String(cur);
|
||||
return store.runner?.message || t("monitoring.missionLog.running");
|
||||
}
|
||||
const log = Array.isArray(entry.log) ? entry.log : [];
|
||||
const last = log.length ? log[log.length - 1] : null;
|
||||
if (last?.message) return String(last.message);
|
||||
if (entry.status === "failed") return t("monitoring.missionLog.stateFailed");
|
||||
if (entry.status === "cancelled") return t("monitoring.missionLog.stateCancelled");
|
||||
if (entry.status === "completed") return t("monitoring.missionLog.stateCompleted");
|
||||
return "—";
|
||||
}
|
||||
|
||||
function formatStartedBy(entry) {
|
||||
const src = String(entry.source || "ui");
|
||||
const key = `monitoring.missionLog.source.${src}`;
|
||||
const label = t(key);
|
||||
return label === key ? src : label;
|
||||
}
|
||||
|
||||
function stateLabel(status) {
|
||||
const key = `monitoring.missionLog.state.${status}`;
|
||||
const label = t(key);
|
||||
return label === key ? status : label;
|
||||
}
|
||||
|
||||
function stateClass(status) {
|
||||
if (status === "executing") return "monMirState--running";
|
||||
if (status === "completed") return "monMirState--ok";
|
||||
if (status === "failed") return "monMirState--error";
|
||||
if (status === "cancelled") return "monMirState--warn";
|
||||
return "";
|
||||
}
|
||||
|
||||
function levelLabel(level) {
|
||||
const key = `monitoring.actionLog.level.${level || "info"}`;
|
||||
const label = t(key);
|
||||
return label === key ? level || "info" : label;
|
||||
}
|
||||
|
||||
function levelClass(level) {
|
||||
if (level === "error") return "monMirState--error";
|
||||
if (level === "warn") return "monMirState--warn";
|
||||
if (level === "user") return "monMirState--user";
|
||||
return "monMirState--ok";
|
||||
}
|
||||
|
||||
function filteredMissionEntries() {
|
||||
const q = store.missionFilter.trim().toLowerCase();
|
||||
let items = missionLogEntries();
|
||||
if (q) {
|
||||
items = items.filter((e) => {
|
||||
const name = String(e.mission_name || "").toLowerCase();
|
||||
const state = stateLabel(e.status).toLowerCase();
|
||||
return name.includes(q) || state.includes(q) || String(e.status).includes(q);
|
||||
});
|
||||
}
|
||||
return items.sort((a, b) => {
|
||||
const ta = new Date(a.started_at || a.created_at || 0).getTime();
|
||||
const tb = new Date(b.started_at || b.created_at || 0).getTime();
|
||||
return tb - ta;
|
||||
});
|
||||
}
|
||||
|
||||
function renderMissionLog() {
|
||||
const listEl = missionLogListEl();
|
||||
if (!listEl) return;
|
||||
const items = filteredMissionEntries();
|
||||
const total = items.length;
|
||||
const pages = pageCount(total);
|
||||
if (store.missionPage > pages) store.missionPage = pages;
|
||||
const start = (store.missionPage - 1) * PAGE_SIZE;
|
||||
const pageItems = items.slice(start, start + PAGE_SIZE);
|
||||
|
||||
if (el("missionLogFilterCount")) el("missionLogFilterCount").textContent = t("monitoring.itemsFound", { n: total });
|
||||
if (el("missionLogPageLabel")) el("missionLogPageLabel").textContent = t("monitoring.pageOf", { page: store.missionPage, total: pages });
|
||||
|
||||
listEl.innerHTML = "";
|
||||
const tableEl = el("missionLogTable");
|
||||
const emptyEl = el("missionLogListEmpty");
|
||||
if (tableEl) tableEl.hidden = total === 0;
|
||||
if (emptyEl) {
|
||||
emptyEl.hidden = total > 0;
|
||||
emptyEl.textContent = store.missionFilter ? t("monitoring.missionLog.emptyFilter") : t("monitoring.missionLog.empty");
|
||||
}
|
||||
|
||||
pageItems.forEach((entry) => {
|
||||
const status = entry.status || "—";
|
||||
const isPersisted = entry.__persisted === true;
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "monMirRow";
|
||||
tr.innerHTML = `
|
||||
<td class="monMirTdIcon">${ICONS.mission}</td>
|
||||
<td>${escapeHtml(entry.mission_name || entry.mission_id || "—")}</td>
|
||||
<td><span class="monMirState ${stateClass(status)}">${escapeHtml(stateLabel(status))}</span></td>
|
||||
<td class="monMirMessageCell">${escapeHtml(entryMessage(entry))}</td>
|
||||
<td>${escapeHtml(formatTime(entry.started_at || entry.created_at))}</td>
|
||||
<td>${escapeHtml(entryDuration(entry))}</td>
|
||||
<td>${escapeHtml(formatStartedBy(entry))}</td>
|
||||
<td class="mapsMirTdFunctions">
|
||||
<button type="button" class="mapsMirIconBtn" data-view-log="${escapeHtml(entry.id)}" title="${escapeHtml(t("monitoring.missionLog.viewActions"))}">${ICONS.view}</button>
|
||||
${
|
||||
isPersisted && status !== "executing"
|
||||
? `<button type="button" class="mapsMirIconBtn mapsMirIconBtn--danger" data-delete-run="${escapeHtml(entry.id)}" title="${escapeHtml(t("monitoring.missionLog.deleteRun"))}">${ICONS.delete}</button>`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
isPersisted && status !== "executing"
|
||||
? `<a class="mapsMirIconBtn" href="/api/monitoring/mission_runs/${encodeURIComponent(entry.id)}/download" download title="${escapeHtml(t("monitoring.missionLog.downloadRun"))}">${ICONS.download}</a>`
|
||||
: ""
|
||||
}
|
||||
</td>`;
|
||||
listEl.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function buildActionRows(entry) {
|
||||
const rows = [];
|
||||
const cached = store.runActions.get(entry?.id);
|
||||
const log = Array.isArray(entry?.log) ? entry.log : Array.isArray(cached) ? cached : [];
|
||||
log.forEach((line, idx) => {
|
||||
const next = log[idx + 1];
|
||||
const ts = line.ts ? new Date(line.ts).getTime() : NaN;
|
||||
const nextTs = next?.ts ? new Date(next.ts).getTime() : NaN;
|
||||
const ranFor = Number.isFinite(ts) && Number.isFinite(nextTs) ? formatDuration(nextTs - ts) : "—";
|
||||
rows.push({
|
||||
action: line.action || line.message || "—",
|
||||
state: line.level || "info",
|
||||
message: line.message || "—",
|
||||
start: line.ts,
|
||||
ranFor,
|
||||
current: false,
|
||||
});
|
||||
});
|
||||
if (entry?.status === "executing" && store.runner?.current_queue_id === entry.id) {
|
||||
const cur = store.runner.current_action;
|
||||
if (cur) {
|
||||
rows.unshift({
|
||||
action: cur,
|
||||
state: "executing",
|
||||
message: store.runner.message || cur,
|
||||
start: store.runner.updated_at || null,
|
||||
ranFor: "—",
|
||||
current: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function filteredActionRows(entry) {
|
||||
const q = store.actionFilter.trim().toLowerCase();
|
||||
let rows = buildActionRows(entry);
|
||||
if (q) {
|
||||
rows = rows.filter((r) => {
|
||||
return (
|
||||
String(r.action).toLowerCase().includes(q) ||
|
||||
String(r.message).toLowerCase().includes(q) ||
|
||||
levelLabel(r.state).toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function renderActionLog() {
|
||||
const listEl = actionLogListEl();
|
||||
if (!listEl) return;
|
||||
const entry = findEntry(store.selectedEntryId);
|
||||
if (!entry) {
|
||||
listEl.innerHTML = "";
|
||||
if (el("actionLogListEmpty")) {
|
||||
el("actionLogListEmpty").hidden = false;
|
||||
el("actionLogListEmpty").textContent = t("monitoring.actionLog.missingEntry");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = filteredActionRows(entry);
|
||||
const total = rows.length;
|
||||
const pages = pageCount(total);
|
||||
if (store.actionPage > pages) store.actionPage = pages;
|
||||
const start = (store.actionPage - 1) * PAGE_SIZE;
|
||||
const pageItems = rows.slice(start, start + PAGE_SIZE);
|
||||
|
||||
if (el("actionLogFilterCount")) el("actionLogFilterCount").textContent = t("monitoring.itemsFound", { n: total });
|
||||
if (el("actionLogPageLabel")) el("actionLogPageLabel").textContent = t("monitoring.pageOf", { page: store.actionPage, total: pages });
|
||||
|
||||
listEl.innerHTML = "";
|
||||
const tableEl = el("actionLogTable");
|
||||
const emptyEl = el("actionLogListEmpty");
|
||||
if (tableEl) tableEl.hidden = total === 0;
|
||||
if (emptyEl) {
|
||||
emptyEl.hidden = total > 0;
|
||||
emptyEl.textContent = store.actionFilter ? t("monitoring.actionLog.emptyFilter") : t("monitoring.actionLog.empty");
|
||||
}
|
||||
|
||||
pageItems.forEach((row) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = `monMirRow${row.current ? " monMirRow--current" : ""}`;
|
||||
tr.innerHTML = `
|
||||
<td class="monMirTdIcon">${ICONS.action}</td>
|
||||
<td>${escapeHtml(row.action)}</td>
|
||||
<td><span class="monMirState ${row.state === "executing" ? "monMirState--running" : levelClass(row.state)}">${escapeHtml(row.state === "executing" ? t("monitoring.actionLog.state.executing") : levelLabel(row.state))}</span></td>
|
||||
<td class="monMirMessageCell">${escapeHtml(row.message)}</td>
|
||||
<td>${escapeHtml(formatTime(row.start))}</td>
|
||||
<td>${escapeHtml(row.ranFor)}</td>`;
|
||||
listEl.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshMissionLog() {
|
||||
const [queueData, runsData] = await Promise.all([
|
||||
apiJson("/api/mission_queue"),
|
||||
apiJson("/api/monitoring/mission_runs?limit=500"),
|
||||
]);
|
||||
store.queue = Array.isArray(queueData.queue) ? queueData.queue : [];
|
||||
store.runner = queueData.runner && typeof queueData.runner === "object" ? queueData.runner : {};
|
||||
store.runs = (Array.isArray(runsData.runs) ? runsData.runs : []).map((r) => ({ ...r, __persisted: true }));
|
||||
if (store.view === "mission-log") renderMissionLog();
|
||||
else if (store.view === "action-log") renderActionLog();
|
||||
}
|
||||
|
||||
function showMissionLogList() {
|
||||
store.view = "mission-log";
|
||||
store.section = "mission-log";
|
||||
store.selectedEntryId = null;
|
||||
showView(missionLogViewEl());
|
||||
startPoll(refreshMissionLog);
|
||||
void refreshMissionLog();
|
||||
}
|
||||
|
||||
function showActionLog(entryId) {
|
||||
store.view = "action-log";
|
||||
store.selectedEntryId = entryId;
|
||||
store.actionFilter = "";
|
||||
store.actionPage = 1;
|
||||
if (el("actionLogFilterInput")) el("actionLogFilterInput").value = "";
|
||||
const entry = findEntry(entryId);
|
||||
if (el("actionLogSubtitle")) {
|
||||
el("actionLogSubtitle").textContent = entry
|
||||
? t("monitoring.actionLog.subtitleMission", { name: entry.mission_name || entry.mission_id || "—" })
|
||||
: "";
|
||||
}
|
||||
const dl = el("actionLogDownloadBtn");
|
||||
if (dl) {
|
||||
const persisted = entry && entry.__persisted === true;
|
||||
dl.hidden = !persisted;
|
||||
if (persisted) dl.href = `/api/monitoring/mission_runs/${encodeURIComponent(entryId)}/download`;
|
||||
else dl.href = "#";
|
||||
}
|
||||
showView(actionLogViewEl());
|
||||
startPoll(refreshMissionLog);
|
||||
if (entry && (!Array.isArray(entry.log) || entry.log.length === 0) && !store.runActions.has(entryId)) {
|
||||
apiJson(`/api/monitoring/mission_runs/${encodeURIComponent(entryId)}/actions`)
|
||||
.then((data) => {
|
||||
const items = Array.isArray(data.items) ? data.items : [];
|
||||
store.runActions.set(entryId, items);
|
||||
renderActionLog();
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
renderActionLog();
|
||||
}
|
||||
|
||||
function showSection(section) {
|
||||
store.section = section;
|
||||
if (section === "analytics") showAnalytics();
|
||||
else if (section === "monitoring-log") showSystemLog();
|
||||
else if (section === "error-logs") showErrorLogs();
|
||||
else if (section === "hardware-health") showHardware();
|
||||
else if (section === "safety-system") showSafety();
|
||||
else if (section === "mission-log") showMissionLogList();
|
||||
else showAnalytics();
|
||||
}
|
||||
|
||||
function bindPager(prefix, getTotal, getPage, setPage, render) {
|
||||
const bind = (id, fn) => el(id)?.addEventListener("click", fn);
|
||||
bind(`${prefix}PageFirst`, () => { setPage(1); render(); });
|
||||
bind(`${prefix}PagePrev`, () => { setPage(Math.max(1, getPage() - 1)); render(); });
|
||||
bind(`${prefix}PageNext`, () => { setPage(getPage() + 1); render(); });
|
||||
bind(`${prefix}PageLast`, () => { setPage(pageCount(getTotal())); render(); });
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
initAnalyticsPresets();
|
||||
|
||||
el("analyticsRefreshBtn")?.addEventListener("click", () => refreshAnalytics().catch((e) => alert(e.message)));
|
||||
el("analyticsHelpBtn")?.addEventListener("click", () => alert(t("monitoring.analytics.helpBody")));
|
||||
["analyticsStartDate", "analyticsEndDate", "analyticsGrouping", "analyticsChartMode"].forEach((id) => {
|
||||
el(id)?.addEventListener("change", () => refreshAnalytics().catch(() => {}));
|
||||
});
|
||||
el("analyticsPresets")?.addEventListener("click", (evt) => {
|
||||
const btn = evt.target.closest("[data-preset-days]");
|
||||
if (!btn) return;
|
||||
const days = Number(btn.dataset.presetDays);
|
||||
const today = formatDateYmd(new Date());
|
||||
setAnalyticsDateRange(addDaysYmd(today, days), today);
|
||||
refreshAnalytics().catch(() => {});
|
||||
});
|
||||
|
||||
el("systemLogFilterInput")?.addEventListener("input", () => {
|
||||
store.systemLogFilter = el("systemLogFilterInput").value;
|
||||
store.systemLogPage = 1;
|
||||
renderSystemLog();
|
||||
});
|
||||
el("systemLogClearFiltersBtn")?.addEventListener("click", () => {
|
||||
store.systemLogFilter = "";
|
||||
store.systemLogPage = 1;
|
||||
if (el("systemLogFilterInput")) el("systemLogFilterInput").value = "";
|
||||
renderSystemLog();
|
||||
});
|
||||
el("systemLogRefreshBtn")?.addEventListener("click", () => refreshSystemLog().catch((e) => alert(e.message)));
|
||||
bindPager("systemLog", () => filteredSystemLog().length, () => store.systemLogPage, (p) => { store.systemLogPage = p; }, renderSystemLog);
|
||||
|
||||
el("errorLogsFilterInput")?.addEventListener("input", () => {
|
||||
store.errorLogFilter = el("errorLogsFilterInput").value;
|
||||
renderErrorLogs();
|
||||
});
|
||||
el("errorLogsRefreshBtn")?.addEventListener("click", () => refreshErrorLogs().catch((e) => alert(e.message)));
|
||||
el("errorLogsGenerateBtn")?.addEventListener("click", () => {
|
||||
apiJson("/api/monitoring/error_logs/generate", { method: "POST" })
|
||||
.then(() => refreshErrorLogs())
|
||||
.catch((e) => alert(e.message));
|
||||
});
|
||||
el("errorLogsDeleteAllBtn")?.addEventListener("click", () => {
|
||||
if (!window.confirm(t("monitoring.errorLogs.deleteAllConfirm"))) return;
|
||||
apiJson("/api/monitoring/error_logs", { method: "DELETE" })
|
||||
.then(() => refreshErrorLogs())
|
||||
.catch((e) => alert(e.message));
|
||||
});
|
||||
el("errorLogsList")?.addEventListener("click", (evt) => {
|
||||
const btn = evt.target.closest("[data-delete-error]");
|
||||
if (!btn?.dataset.deleteError) return;
|
||||
apiJson(`/api/monitoring/error_logs/${encodeURIComponent(btn.dataset.deleteError)}`, { method: "DELETE" })
|
||||
.then(() => refreshErrorLogs())
|
||||
.catch((e) => alert(e.message));
|
||||
});
|
||||
|
||||
el("hardwareRefreshBtn")?.addEventListener("click", () => refreshHardware().catch((e) => alert(e.message)));
|
||||
el("hardwareGroupsList")?.addEventListener("click", (evt) => {
|
||||
const btn = evt.target.closest("[data-hw-toggle]");
|
||||
if (!btn?.dataset.hwToggle) return;
|
||||
const gid = btn.dataset.hwToggle;
|
||||
if (store.hardwareExpanded.has(gid)) store.hardwareExpanded.delete(gid);
|
||||
else store.hardwareExpanded.add(gid);
|
||||
renderHardware();
|
||||
});
|
||||
|
||||
el("missionLogFilterInput")?.addEventListener("input", () => {
|
||||
store.missionFilter = el("missionLogFilterInput").value;
|
||||
store.missionPage = 1;
|
||||
renderMissionLog();
|
||||
});
|
||||
el("missionLogClearFiltersBtn")?.addEventListener("click", () => {
|
||||
store.missionFilter = "";
|
||||
store.missionPage = 1;
|
||||
if (el("missionLogFilterInput")) el("missionLogFilterInput").value = "";
|
||||
renderMissionLog();
|
||||
});
|
||||
el("missionLogRefreshBtn")?.addEventListener("click", () => refreshMissionLog().catch((e) => alert(e.message)));
|
||||
el("missionLogClearHistoryBtn")?.addEventListener("click", () => {
|
||||
if (!window.confirm(t("monitoring.missionLog.clearHistoryConfirm"))) return;
|
||||
apiJson("/api/monitoring/mission_runs", { method: "DELETE" })
|
||||
.then(() => refreshMissionLog())
|
||||
.catch((e) => alert(e.message));
|
||||
});
|
||||
el("missionLogHelpBtn")?.addEventListener("click", () => alert(t("monitoring.missionLog.helpBody")));
|
||||
missionLogListEl()?.addEventListener("click", (evt) => {
|
||||
const btn = evt.target.closest("[data-view-log]");
|
||||
if (!btn?.dataset.viewLog) return;
|
||||
showActionLog(btn.dataset.viewLog);
|
||||
});
|
||||
missionLogListEl()?.addEventListener("click", (evt) => {
|
||||
const btn = evt.target.closest("[data-delete-run]");
|
||||
if (!btn?.dataset.deleteRun) return;
|
||||
if (!window.confirm(t("monitoring.missionLog.deleteRunConfirm"))) return;
|
||||
apiJson(`/api/monitoring/mission_runs/${encodeURIComponent(btn.dataset.deleteRun)}`, { method: "DELETE" })
|
||||
.then(() => refreshMissionLog())
|
||||
.catch((e) => alert(e.message));
|
||||
});
|
||||
el("actionLogBackBtn")?.addEventListener("click", () => showMissionLogList());
|
||||
el("actionLogFilterInput")?.addEventListener("input", () => {
|
||||
store.actionFilter = el("actionLogFilterInput").value;
|
||||
store.actionPage = 1;
|
||||
renderActionLog();
|
||||
});
|
||||
el("actionLogClearFiltersBtn")?.addEventListener("click", () => {
|
||||
store.actionFilter = "";
|
||||
store.actionPage = 1;
|
||||
if (el("actionLogFilterInput")) el("actionLogFilterInput").value = "";
|
||||
renderActionLog();
|
||||
});
|
||||
bindPager("missionLog", () => filteredMissionEntries().length, () => store.missionPage, (p) => { store.missionPage = p; }, renderMissionLog);
|
||||
bindPager("actionLog", () => {
|
||||
const entry = findEntry(store.selectedEntryId);
|
||||
return entry ? filteredActionRows(entry).length : 0;
|
||||
}, () => store.actionPage, (p) => { store.actionPage = p; }, renderActionLog);
|
||||
|
||||
window.addEventListener("lm:locale-change", () => {
|
||||
if (store.view === "analytics") renderAnalyticsChart();
|
||||
else if (store.view === "system-log") renderSystemLog();
|
||||
else if (store.view === "error-logs") renderErrorLogs();
|
||||
else if (store.view === "hardware-health") renderHardware();
|
||||
else if (store.view === "safety-system") renderSafety();
|
||||
else if (store.view === "mission-log") renderMissionLog();
|
||||
else if (store.view === "action-log") renderActionLog();
|
||||
});
|
||||
}
|
||||
|
||||
function onPageShow() {
|
||||
const section = window.NavApp?.getActiveSection?.() || store.section || "analytics";
|
||||
showSection(section);
|
||||
}
|
||||
|
||||
function onPageHide() {
|
||||
stopPoll();
|
||||
}
|
||||
|
||||
window.MonitoringApp = { init: bindEvents, onPageShow, onPageHide, showSection, refresh: refreshMissionLog };
|
||||
|
||||
function boot() {
|
||||
bindEvents();
|
||||
}
|
||||
|
||||
if (window.AuthApp?.isReady()) boot();
|
||||
else window.addEventListener("lm:auth-ready", boot, { once: true });
|
||||
})();
|
||||
36
www/nav.js
36
www/nav.js
@@ -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
148
www/settings.js
Normal file
@@ -0,0 +1,148 @@
|
||||
(() => {
|
||||
const el = (id) => document.getElementById(id);
|
||||
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
|
||||
|
||||
const statusEl = el("settingsStatusText");
|
||||
const inputMissionRuns = el("settingsRetentionMissionRuns");
|
||||
const inputSystemLog = el("settingsRetentionSystemLog");
|
||||
const inputErrorLogs = el("settingsRetentionErrorLogs");
|
||||
const selectLogLevel = el("settingsLogLevel");
|
||||
const gridEl = el("settingsTileGrid");
|
||||
const gridViewEl = el("settingsGridView");
|
||||
const detailViewEl = el("settingsDetailView");
|
||||
|
||||
function setStatus(text, kind = "info") {
|
||||
if (!statusEl) return;
|
||||
statusEl.textContent = text || "";
|
||||
statusEl.classList.toggle("mapsMirNote--ok", kind === "ok");
|
||||
statusEl.classList.toggle("mapsMirNote--error", kind === "error");
|
||||
}
|
||||
|
||||
const TILE_SVGS = {
|
||||
mapping: `<svg width="42" height="42" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6l6-2 4 2 6-2v14l-6 2-4-2-6 2V6z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M10 4v14M14 6v14" fill="none" stroke="currentColor" stroke-width="1.6"/></svg>`,
|
||||
warning: `<svg width="42" height="42" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l10 18H2L12 3z" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M12 9v5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><circle cx="12" cy="17" r="1" fill="currentColor"/></svg>`,
|
||||
gear: `<svg width="42" height="42" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 15.2a3.2 3.2 0 1 0 0-6.4 3.2 3.2 0 0 0 0 6.4z" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M19.4 15a8.6 8.6 0 0 0 .1-2l2-1.2-2-3.4-2.3.7a8.5 8.5 0 0 0-1.7-1L15 5h-4l-.5 2.1a8.5 8.5 0 0 0-1.7 1l-2.3-.7-2 3.4L6.6 13a8.6 8.6 0 0 0 .1 2l-2 1.2 2 3.4 2.3-.7c.5.4 1.1.7 1.7 1L11 22h4l.5-2.1c.6-.3 1.2-.6 1.7-1l2.3.7 2-3.4-2-1.2z" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>`,
|
||||
battery: `<svg width="42" height="42" viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="7" width="16" height="10" rx="2" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M21 10v4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><path d="M6 10h8v4H6z" fill="currentColor"/></svg>`,
|
||||
calendar: `<svg width="42" height="42" viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="5" width="16" height="15" rx="2" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M8 3v4M16 3v4M4 9h16" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>`,
|
||||
wifi: `<svg width="42" height="42" viewBox="0 0 24 24" aria-hidden="true"><path d="M2 8c5-4 15-4 20 0" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><path d="M5 12c3.5-2.7 10.5-2.7 14 0" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><path d="M8.5 15.5c2-1.5 5-1.5 7 0" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><circle cx="12" cy="19" r="1.3" fill="currentColor"/></svg>`,
|
||||
};
|
||||
|
||||
const TILES = [
|
||||
{ id: "mapping", title: "settings.tile.mapping", sub: "settings.tile.mappingSub", icon: "mapping", action: "coming_soon" },
|
||||
{ id: "error_handling", title: "settings.tile.errorHandling", sub: "settings.tile.errorHandlingSub", icon: "warning", action: "coming_soon" },
|
||||
{ id: "battery", title: "settings.tile.battery", sub: "settings.tile.batterySub", icon: "battery", action: "coming_soon" },
|
||||
{ id: "wifi", title: "settings.tile.wifi", sub: "settings.tile.wifiSub", icon: "wifi", action: "coming_soon" },
|
||||
{ id: "date_time", title: "settings.tile.dateTime", sub: "settings.tile.dateTimeSub", icon: "calendar", action: "coming_soon" },
|
||||
{ id: "advanced", title: "settings.tile.advanced", sub: "settings.tile.advancedSub", icon: "gear", action: "open_logging_retention" },
|
||||
];
|
||||
|
||||
function showGrid() {
|
||||
if (gridViewEl) gridViewEl.hidden = false;
|
||||
if (detailViewEl) detailViewEl.hidden = true;
|
||||
setStatus("");
|
||||
}
|
||||
|
||||
function showDetail() {
|
||||
if (gridViewEl) gridViewEl.hidden = true;
|
||||
if (detailViewEl) detailViewEl.hidden = false;
|
||||
}
|
||||
|
||||
function renderGrid() {
|
||||
if (!gridEl) return;
|
||||
gridEl.innerHTML = TILES.map((tile) => {
|
||||
const disabled = tile.action === "coming_soon";
|
||||
return `
|
||||
<button type="button" class="settingsMirTile${disabled ? " is-disabled" : ""}" data-settings-tile="${tile.id}" ${disabled ? "disabled" : ""}>
|
||||
<div class="settingsMirTileIcon">${TILE_SVGS[tile.icon] || ""}</div>
|
||||
<div class="settingsMirTileTitle">${t(tile.title)}</div>
|
||||
<div class="settingsMirTileSub">${t(tile.sub)}</div>
|
||||
</button>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
async function apiJson(url, opts = {}) {
|
||||
const res = await fetch(url, { credentials: "include", ...opts });
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
if (!res.ok) throw new Error((data && data.error) || text || res.statusText);
|
||||
return data;
|
||||
}
|
||||
|
||||
function toInt(v, fallback) {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return fallback;
|
||||
return Math.round(n);
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
setStatus(t("settings.loading"));
|
||||
const data = await apiJson("/api/settings");
|
||||
const s = data && typeof data === "object" ? data : {};
|
||||
|
||||
if (inputMissionRuns) inputMissionRuns.value = String(toInt(s.retention_mission_runs, 2000));
|
||||
if (inputSystemLog) inputSystemLog.value = String(toInt(s.retention_system_log, 2000));
|
||||
if (inputErrorLogs) inputErrorLogs.value = String(toInt(s.retention_error_logs, 200));
|
||||
if (selectLogLevel) selectLogLevel.value = String(s.log_level || "info");
|
||||
|
||||
setStatus(t("settings.loaded"), "ok");
|
||||
}
|
||||
|
||||
async function apply() {
|
||||
const payload = {
|
||||
retention_mission_runs: toInt(inputMissionRuns?.value, 2000),
|
||||
retention_system_log: toInt(inputSystemLog?.value, 2000),
|
||||
retention_error_logs: toInt(inputErrorLogs?.value, 200),
|
||||
log_level: String(selectLogLevel?.value || "info"),
|
||||
};
|
||||
|
||||
setStatus(t("settings.saving"));
|
||||
const res = await apiJson("/api/settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const ok = res && typeof res === "object";
|
||||
setStatus(ok ? t("settings.saved") : t("settings.saveFailed"), ok ? "ok" : "error");
|
||||
await refresh();
|
||||
}
|
||||
|
||||
function bind() {
|
||||
renderGrid();
|
||||
gridEl?.addEventListener("click", (evt) => {
|
||||
const btn = evt.target.closest("[data-settings-tile]");
|
||||
if (!btn?.dataset.settingsTile) return;
|
||||
const tile = TILES.find((x) => x.id === btn.dataset.settingsTile);
|
||||
if (!tile) return;
|
||||
if (tile.action === "open_logging_retention") {
|
||||
showDetail();
|
||||
refresh().catch((e) => setStatus(e.message, "error"));
|
||||
}
|
||||
});
|
||||
el("settingsRefreshBtn")?.addEventListener("click", () => refresh().catch((e) => setStatus(e.message, "error")));
|
||||
el("settingsApplyBtn")?.addEventListener("click", () => apply().catch((e) => setStatus(e.message, "error")));
|
||||
el("settingsBackBtn")?.addEventListener("click", () => showGrid());
|
||||
}
|
||||
|
||||
function onPageShow() {
|
||||
showGrid();
|
||||
refresh().catch((e) => setStatus(e.message, "error"));
|
||||
}
|
||||
|
||||
function onPageHide() {}
|
||||
|
||||
window.SettingsApp = { onPageShow, onPageHide, refresh };
|
||||
|
||||
function boot() {
|
||||
bind();
|
||||
}
|
||||
|
||||
if (window.AuthApp?.isReady()) boot();
|
||||
else window.addEventListener("lm:auth-ready", boot, { once: true });
|
||||
})();
|
||||
|
||||
191
www/style.css
191
www/style.css
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user