# Conflicts:
#	meshmap_frontend/src/App.vue
#	web.go
This commit is contained in:
2026-06-12 18:15:30 +08:00
31 changed files with 3785 additions and 758 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 kevin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+208 -308
View File
@@ -1,42 +1,144 @@
# Meshtastic MQTT Server # Meshtastic MQTT Server
本程序启动一个本地 MQTT broker,并在转发客户端发布的消息前校验 Meshtastic MQTT payload Meshtastic MQTT Server 是一个面向 Meshtastic MQTT 数据的本地服务:内置 MQTT broker,接收并校验 Meshtastic MQTT payload,同时提供 Web 管理与地图前端,用于查看节点、消息、位置、遥测、转发状态和管理配置
每条传入的 `PUBLISH` 都会先进入: [meshmap.lmve.net](https://meshmap.lmve.net/)
```go ![mainpage](doc/main_page.png)
valid, _, record := mqtpp.MQTTPP(topic, payload, key, mqtpp.Options{})
```
- `valid == true`:保留原始 topic、payload、QoS、retain 等字段,正常转发给订阅匹配 topic 的客户端 ## 后端功能
- `valid == false`:丢弃该消息,不转发给订阅客户端
当前不桥接到 `mqtt.meshtastic.org` 等上游 broker。 后端提供 MQTT broker、Meshtastic 数据校验、数据入库、Web API 和管理后台能力,主要功能包括:
## 运行
- 用户管理:支持创建管理员用户、修改管理员密码。
- 屏蔽规则管理:支持设置节点屏蔽、IP 屏蔽和屏蔽词;屏蔽词可设置匹配方式、是否区分大小写、启用状态和原因。
- 消息拦截:命中被屏蔽节点、被屏蔽 IP 或屏蔽词的消息会被拒绝,并写入丢弃记录。
- MQTT 转发管理:支持配置多个 MQTT 转发器,设置源端、目标端、TLS、认证信息、转发 topic、方向、QoS、retain,并可查看转发运行状态或重启转发器。
- 运行时设置:支持动态设置无法解密的加密 MQTT 包是否允许继续转发。
- 地图源管理:支持配置地图瓦片源、默认地图源、启用状态、最大缩放级别、attribution 和是否通过后端代理地图瓦片。
- 地图瓦片代理与缓存:可通过后端代理地图瓦片请求,并使用本地目录缓存。
- 帮助内容管理:支持在管理后台编辑 Markdown 帮助内容,并提供预览与展示。
- 数据库支持:支持 SQLite 和 MySQL。
- Meshtastic payload 校验:在消息转发前校验 Meshtastic MQTT 数据包,无效数据会被拒绝并记录。
- 数据解析与存储:解析并保存节点信息、地图上报、文本消息、位置、遥测、路由、traceroute 等数据。
## 运行环境
### 后端
- Go`1.25.0` 或更高版本
- 默认监听:
- MQTT`0.0.0.0:1883`
- Web`0.0.0.0:8080`
### 前端
- Node.js:满足 Vite 8 要求
- `^20.19.0`,或
- `>=22.12.0`
- npm:随 Node.js 安装即可
建议生产环境使用当前 LTS 版本的 Node.js,并确保版本满足上述要求。
## 快速部署
### Linux 一键部署
在 Linux 下进入项目目录后,直接执行:
```bash ```bash
go run . sudo bash install.sh
``` ```
默认监听: 安装脚本会自动拉取最新代码、安装前端依赖、构建前端、编译后端、安装到 `/opt/mesh_mqtt_go`,并创建和启动 `mesh_mqtt_go` systemd 服务。
- host`0.0.0.0` ### 手动构建前端
- port`1883`
- PSK`AQ==`
- TLS:关闭
- Web`0.0.0.0:8080`,静态目录 `./dist`
- 数据库:SQLite
- SQLite 文件:Unix/Linux 为 `/srv/mesh_mqtt_go/mesh_mqtt_go.db`Windows 测试为 `./win/etc/mesh_mqtt_go/mesh_mqtt_go.db`
首次启动会自动生成配置文件;之后每次启动都会检查配置项,缺失项会自动补全并写回。 ```bash
cd meshmap_frontend
npm install
npm run build
cd ..
```
配置文件路径: 构建完成后,前端静态文件会生成到项目根目录的 `dist`,也就是从 `meshmap_frontend` 目录看是 `../dist`
- Unix/Linux`/etc/mesh_mqtt_go/config.yaml` ### 手动构建后端
- Windows 测试:`./win/etc/mesh_mqtt_go/config.yaml`
默认配置内容: ```bash
go build -o meshtastic_mqtt_server .
```
### 手动启动
```bash
./meshtastic_mqtt_server -web-static-dir ./dist
```
首次启动时,程序会自动生成默认配置文件。
默认配置路径:
- Linux`/etc/mesh_mqtt_go/config.yaml`
- Windows`./win/etc/mesh_mqtt_go/config.yaml`
默认数据路径:
- Linux SQLite`/srv/mesh_mqtt_go/mesh_mqtt_go.db`
- Windows SQLite`./win/etc/mesh_mqtt_go/mesh_mqtt_go.db`
默认地图瓦片缓存目录:
- Linux`/srv/mesh_mqtt_go`
- Windows`./win/srv/mesh_mqtt_go`
## 常用启动参数
```bash
./meshtastic_mqtt_server \
-host 0.0.0.0 \
-port 1883 \
-web-host 0.0.0.0 \
-web-port 8080 \
-web-static-dir ./dist
```
常用参数说明:
| 参数 | 说明 | 默认值 |
| --- | --- | --- |
| `-host` | MQTT broker 监听地址 | `0.0.0.0` |
| `-port` | MQTT broker 监听端口 | `1883` |
| `-psk` | Meshtastic channel PSKBase64 格式 | `AQ==` |
| `-tls` | 启用 MQTT TLS | `false` |
| `-tls-cert` | MQTT TLS 证书文件 | 空 |
| `-tls-key` | MQTT TLS 私钥文件 | 空 |
| `-db-driver` | 数据库类型:`sqlite``mysql` | `sqlite` |
| `-sqlite-path` | SQLite 数据库文件路径 | 见默认数据路径 |
| `-mysql-dsn` | MySQL DSN | 空 |
| `-web` | 启用 Web 服务 | `true` |
| `-web-host` | Web 服务监听地址 | `0.0.0.0` |
| `-web-port` | Web 服务监听端口 | `8080` |
| `-web-socket-path` | Web Unix Socket 路径,Windows 不支持 | Linux 默认 `/opt/mesh_mqtt_go/web.sock` |
| `-web-static-dir` | 前端静态文件目录 | `./dist` |
| `-web-map-tile-cache-dir` | 地图瓦片缓存目录 | 见默认地图瓦片缓存目录 |
| `-admin-username` | Web 管理员用户名 | `admin` |
管理员密码与会话密钥建议通过环境变量传入:
```bash
export MESH_ADMIN_PASSWORD='change-me'
export MESH_ADMIN_SESSION_SECRET='replace-with-a-long-random-string'
./meshtastic_mqtt_server
```
## 配置文件示例
程序会自动生成并补全配置文件,也可以手动维护 `config.yaml`
```yaml ```yaml
mqtt: mqtt:
@@ -46,19 +148,24 @@ mqtt:
enabled: false enabled: false
cert_file: "" cert_file: ""
key_file: "" key_file: ""
meshtastic: meshtastic:
psk: AQ== psk: AQ==
database: database:
driver: sqlite driver: sqlite
sqlite: sqlite:
path: /srv/mesh_mqtt_go/mesh_mqtt_go.db path: /srv/mesh_mqtt_go/mesh_mqtt_go.db
mysql: mysql:
dsn: "" dsn: ""
web: web:
enabled: true enabled: true
host: 0.0.0.0 host: 0.0.0.0
port: 8080 port: 8080
socket_path: ""
static_dir: ./dist static_dir: ./dist
map_tile_cache_dir: /srv/mesh_mqtt_go
admin: admin:
username: admin username: admin
password: admin password: admin
@@ -66,319 +173,112 @@ web:
session_secure: false session_secure: false
``` ```
配置优先级: > 生产环境请修改默认管理员密码,并设置足够长、随机的 `session_secret`。如果通过 HTTPS 访问 Web 管理后台,建议将 `session_secure` 设置为 `true`。
```text ## 使用 SQLite 部署
内置默认值 < 配置文件 < 环境变量 < 命令行参数
```
也可以用命令行临时覆盖监听地址、PSK 和 TLS 设置 SQLite 是默认数据库,适合单机部署
```bash ```bash
go run . --host 127.0.0.1 --port 1883 --psk AQ== mkdir -p /srv/mesh_mqtt_go
./meshtastic_mqtt_server \
-db-driver sqlite \
-sqlite-path /srv/mesh_mqtt_go/mesh_mqtt_go.db \
-web-map-tile-cache-dir /srv/mesh_mqtt_go
``` ```
## 参数 ## 使用 MySQL 部署
```text 如果需要使用 MySQL,启动时指定数据库驱动和 DSN:
--host MQTT broker listen host
--port MQTT broker listen port
--psk Base64 channel PSK used to try decrypting encrypted packets
--tls Enable MQTT TLS listener
--tls-cert MQTT TLS certificate file
--tls-key MQTT TLS private key file
--db-driver Database driver: sqlite or mysql
--sqlite-path SQLite database file path
--mysql-dsn MySQL database DSN
--web Enable Gin web server
--web-host Web server listen host
--web-port Web server listen port
--web-static-dir Web frontend static files directory
```
## Web 前端
开发模式:
```bash ```bash
go run . --web-host 127.0.0.1 --web-port 8080 ./meshtastic_mqtt_server \
cd meshmap_frontend -db-driver mysql \
npm run dev -mysql-dsn 'user:password@tcp(127.0.0.1:3306)/meshtastic?charset=utf8mb4&parseTime=True&loc=Local'
``` ```
生产构建: ## 启用 MQTT TLS
准备证书和私钥后启动:
```bash ```bash
cd meshmap_frontend ./meshtastic_mqtt_server \
npm run build -tls \
cd .. -tls-cert /path/to/server.crt \
go run . -tls-key /path/to/server.key
``` ```
构建后的文件位于项目根目录 `dist/`Gin 会提供静态文件服务;`/api` 路径保留给后端接口。 ## 访问服务
管理页面位于 `/admin`,默认管理员账号为 `admin` / `admin`。生产环境请修改 `web.admin.password` 或设置 `MESH_ADMIN_PASSWORD`,并配置固定的 `web.admin.session_secret``MESH_ADMIN_SESSION_SECRET`;如果 `session_secret` 为空,程序会在启动时生成临时签名密钥,重启后需要重新登录。后台页面包括 `/admin` 服务状态、`/admin/users` 用户管理、`/admin/log/login` 登录日志、`/admin/discard_details` 丢弃数据。`/admin` 中的“丢弃消息”统计来自 `discard_details` 表记录数,点击可进入丢弃数据分页页。后台支持新增管理员用户和修改用户密码;密码使用 bcrypt hash 保存,API 不会返回密码 hash。修改密码不会立即使已签发 Session 失效,当前 Session 到期或退出登录后才需要使用新密码。登录成功和失败都会记录到登录日志,包含用户名、结果、原因、来源地址、User-Agent 和时间。管理员可在主页右键删除聊天消息、地图节点或节点列表记录;删除节点会删除 `nodeinfo``map_report` 当前状态,不会删除历史消息、位置、遥测等 append 记录,后续收到新的节点上报时可能重新出现。 启动后可访问:
常用 API - Web 前端:`http://服务器地址:8080/`
- 健康检查:`http://服务器地址:8080/api/health`
- MQTT broker`服务器地址:1883`
```text Web 管理后台默认账号:
GET /api/health
POST /api/admin/login - 用户名:`admin`
POST /api/admin/logout - 密码:`admin`
GET /api/admin/me
GET /api/admin/mqtt/status 生产环境请务必修改默认密码。
GET /api/admin/log/login
GET /api/admin/users ## systemd 部署示例
POST /api/admin/users
PUT /api/admin/users/:id/password 以下示例假设:
DELETE /api/admin/text-messages/:id
DELETE /api/admin/nodes/:id - 后端可执行文件位于 `/opt/mesh_mqtt_go/meshtastic_mqtt_server`
GET /api/nodeinfo - 前端静态文件位于 `/opt/mesh_mqtt_go/dist`
GET /api/nodeinfo/:id - 数据与缓存目录位于 `/srv/mesh_mqtt_go`
GET /api/map-reports - 配置文件位于 `/etc/mesh_mqtt_go/config.yaml`
GET /api/map-reports/:id
GET /api/nodes # /api/nodeinfo 的兼容别名 创建服务文件 `/etc/systemd/system/mesh_mqtt_go.service`
GET /api/nodes/:id # /api/nodeinfo/:id 的兼容别名
GET /api/text-messages ```ini
GET /api/discard-details [Unit]
GET /api/positions Description=Meshtastic MQTT Server
GET /api/telemetry After=network.target
GET /api/routing
GET /api/traceroute [Service]
Type=simple
WorkingDirectory=/opt/mesh_mqtt_go
ExecStart=/opt/mesh_mqtt_go/meshtastic_mqtt_server -web-static-dir /opt/mesh_mqtt_go/dist
Environment=MESH_ADMIN_PASSWORD=change-me
Environment=MESH_ADMIN_SESSION_SECRET=replace-with-a-long-random-string
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=multi-user.target
``` ```
## TLS 配置示例 启用并启动服务:
```yaml
mqtt:
host: 0.0.0.0
port: 8883
tls:
enabled: true
cert_file: ./certs/server.crt
key_file: ./certs/server.key
meshtastic:
psk: AQ==
```
启用 TLS 后,`cert_file``key_file` 必须指向可读取的证书和私钥文件。
## 数据库持久化
程序默认启用 SQLite,数据库表迁移和操作由 GORM 执行,并持久化以下数据:
- `login_log`:追加保存后台登录成功和失败日志
- `discard_details`:追加保存 `MQTTPP` 判定无效而被 broker 丢弃的数据,raw payload 使用 base64 保存
- `nodeinfo`:保存 `type == "nodeinfo"` 的节点身份和设备信息
- `map_report`:保存 `type == "map_report"` 的地图报告信息,前端地图从该表读取
- `text_message`:追加保存 `type == "text_message"` 的聊天消息
- `position`:追加保存 `type == "position"` 的位置包
- `telemetry`:追加保存 `type == "telemetry"` 的遥测包
- `routing`:追加保存 `type == "routing"` 的路由控制包
- `traceroute`:追加保存 `type == "traceroute"` 的路径追踪包
`nodeinfo` / `map_report` 规则:
- 两张表都以 `node_id`(即解析结果中的 `from`,例如 `!a8dfd867`)作为主键
- `nodeinfo` 只保存节点身份和设备字段,例如 `user_id`、名称、硬件型号、角色、授权状态和公钥
- `map_report` 只保存地图报告字段,例如名称、硬件型号、角色、固件版本、区域、调制预设、经纬度、海拔、位置精度和在线节点数
- 重复收到同一节点时不会插入重复行,只更新 `updated_at``content_json` 和本次记录中有值的字段
- `first_seen_at` 保留第一次写入时间
- `content_json` 分别保存最新一次 `nodeinfo``map_report` 的完整解析结果 JSON
- 旧版本创建的 `nodeinfo_map` 融合表不会被自动删除,新版本不再写入该表;新表会从新收到的数据开始填充
`text_message` 规则:
- 使用自增 `id` 作为主键
- 每条聊天消息都会新增一行,不做去重
- 保存 `from_id``from_num``text``payload_hex`、topic、packet 元数据和完整 `content_json`
- 保存 MQTT 客户端信息:`mqtt_client_id``mqtt_username``mqtt_listener``mqtt_remote_addr``mqtt_remote_host``mqtt_remote_port`
`position` / `telemetry` / `routing` / `traceroute` 规则:
- 都使用自增 `id` 作为主键
- 每条有效记录都会新增一行,不做去重
- 保存通用 packet 元数据、MQTT 客户端信息和完整 `content_json`
- `position` 额外保存经纬度、海拔、时间、定位来源、精度、速度、卫星数等字段
- `telemetry` 额外保存 `telemetry_type`,并把动态 `metrics` 对象保存为 `metrics_json`
- `routing``traceroute` 当前保存通用元数据和完整 JSON;后续如果解析更多 payload 字段,可继续扩展列
查询最近聊天消息示例:
```sql
SELECT id, created_at, from_id, text, mqtt_remote_host
FROM text_message
ORDER BY id DESC
LIMIT 20;
```
查询位置包示例:
```sql
SELECT id, created_at, from_id, latitude, longitude, altitude
FROM position
ORDER BY id DESC
LIMIT 20;
```
查询遥测包示例:
```sql
SELECT id, created_at, from_id, telemetry_type, metrics_json
FROM telemetry
ORDER BY id DESC
LIMIT 20;
```
SQLite 默认路径:
- Unix/Linux`/srv/mesh_mqtt_go/mesh_mqtt_go.db`
- Windows 测试:`./win/etc/mesh_mqtt_go/mesh_mqtt_go.db`
MySQL 配置示例:
```yaml
database:
driver: mysql
sqlite:
path: /srv/mesh_mqtt_go/mesh_mqtt_go.db
mysql:
dsn: mesh_user:mesh_pass@tcp(127.0.0.1:3306)/mesh_mqtt_go?parseTime=true&charset=utf8mb4,utf8
```
使用 MySQL 时,需要提前创建好 database/schema。
## 转发规则
程序监听所有传入 publish。payload 能被 `mqtpp.MQTTPP` 解析时,认为 `valid == true`broker 会继续把原始 MQTT 消息转发给订阅者;解析失败时,认为 `valid == false`broker 会拒绝并丢弃该 publish。
`empty_packet` 仍然属于 `valid == true`,会被转发;只是控制台默认不显示它。
无法解密的加密包会输出为 `encrypted_packet`,属于 `valid == false`,因此会被拒绝并丢弃。
丢弃的 publish 会写入 `discard_details`,记录 topic、错误原因、payload 长度、base64 raw payload、MQTT 客户端信息和完整 `content_json`
## 本地验证
一个终端启动 broker
```bash ```bash
go run . --host 127.0.0.1 --port 1883 --psk AQ== sudo systemctl daemon-reload
sudo systemctl enable --now mesh_mqtt_go
sudo systemctl status mesh_mqtt_go
``` ```
另一个终端订阅 查看日志
```bash ```bash
mosquitto_sub -h 127.0.0.1 -p 1883 -t '#' sudo journalctl -u mesh_mqtt_go -f
``` ```
发布非法 payload ## 生产环境建议
```bash - 修改默认管理员密码。
mosquitto_pub -h 127.0.0.1 -p 1883 -t 'msh/US/test' -m 'not protobuf' - 设置随机且足够长的 `MESH_ADMIN_SESSION_SECRET`
``` - 使用反向代理提供 HTTPS。
- 如果 Web 管理后台通过 HTTPS 访问,启用安全 Cookie。
- 根据实际情况开放防火墙端口:
- MQTT`1883`
- MQTT TLS:自定义端口或仍使用 `1883`
- Web`8080` 或反向代理端口
- 定期备份数据库文件或 MySQL 数据库。
- 为地图瓦片缓存目录预留足够磁盘空间。
订阅端应该收不到该消息。 ## 开源协议
要验证 valid 消息转发,请使用真实 Meshtastic MQTT payload 发布到本 broker;订阅匹配 topic 的客户端应收到原始消息,broker 控制台会打印解析后的 `record` 本项目采用 MIT License 开源。详见项目许可证文件。
## 控制台颜色说明
程序会按数据包类型使用不同背景色,方便快速区分消息类型。
| 背景色 | type | portnum | 含义 |
|---|---|---|---|
| 绿色 | `nodeinfo` | `NODEINFO_APP` | 节点信息包,包含节点 ID、长名称、短名称、硬件型号、角色、公钥等 |
| 蓝色 | `map_report` | `MAP_REPORT_APP` | 地图报告包,包含节点名称、硬件、固件版本、区域、调制预设、位置等地图信息 |
| 紫色 | `text_message` | `TEXT_MESSAGE_APP` | 聊天文本消息 |
| 青色 | `position` | `POSITION_APP` | 位置包,会展开解析经纬度、海拔、时间、定位来源、精度、速度、卫星数等字段 |
| 黄色 | `telemetry` | `TELEMETRY_APP` | 遥测包,会展开解析设备、电源、环境、空气质量、本地统计、健康、主机和流量管理指标 |
| 灰色 | `routing` | `ROUTING_APP` | 路由控制包,常见于 ACK、NAK、路由错误等控制信息 |
| 灰色 | `traceroute` | `TRACEROUTE_APP` | 路径追踪包,用于 mesh 网络路径探测 |
| 红色 | error record | - | protobuf 解析失败、payload 解码失败或其他处理错误 |
| 无颜色 | `encrypted_packet` | - | 加密包但当前 PSK/频道 hash 无法解密;这不一定是错误 |
| 无颜色 | `decoded_packet` | 其他 portnum | 已解码/已解密,但程序尚未细分的其他应用包 |
## 已展开解析的数据包
### `position` / `POSITION_APP`
位置包会从 Meshtastic `Position` payload 中展开常用字段,包括:
- `latitude` / `longitude`:经纬度,已从 `latitude_i` / `longitude_i` 转换为浮点角度
- `altitude`:海拔,单位米
- `time` / `timestamp`:位置相关时间戳
- `location_source`:定位来源,例如 `LOC_MANUAL``LOC_INTERNAL``LOC_EXTERNAL`
- `altitude_source`:海拔来源,例如 `ALT_MANUAL``ALT_INTERNAL``ALT_BAROMETRIC`
- `altitude_hae` / `altitude_geoidal_separation`HAE 海拔和大地水准面分离值
- `pdop` / `hdop` / `vdop`:定位精度因子,已从 1/100 单位转换为浮点值
- `gps_accuracy`GPS 精度,单位 mm
- `ground_speed`:地面速度,单位 m/s
- `ground_track`:地面航迹角,已从 1/100 度转换为度
- `fix_quality` / `fix_type` / `sats_in_view`:GPS fix 质量、类型和可见卫星数
- `sensor_id` / `next_update` / `seq_number` / `precision_bits`:传感器、更新间隔、序列号和位置精度位数
### `telemetry` / `TELEMETRY_APP`
遥测包会输出:
- `time`:遥测时间戳
- `telemetry_type`:具体 telemetry variant
- `metrics`:展开后的指标对象
当前支持的 `telemetry_type`
| telemetry_type | 含义 | 常见 metrics |
|---|---|---|
| `device_metrics` | 设备状态 | `battery_level``voltage``channel_utilization``air_util_tx``uptime_seconds` |
| `environment_metrics` | 环境传感器 | `temperature``relative_humidity``barometric_pressure``gas_resistance``lux``wind_speed``rainfall_1h` 等 |
| `air_quality_metrics` | 空气质量 | `pm25_standard``pm100_standard``co2``pm_temperature``pm_humidity``pm_voc_idx` 等 |
| `power_metrics` | 多通道电源数据 | `ch1_voltage``ch1_current``ch8_voltage``ch8_current` |
| `local_stats` | 本地 mesh 统计 | `num_packets_tx``num_packets_rx``num_online_nodes``heap_free_bytes``noise_floor` 等 |
| `health_metrics` | 健康数据 | `heart_bpm``spO2``temperature` |
| `host_metrics` | Linux/Portduino 主机指标 | `uptime_seconds``freemem_bytes``diskfree1_bytes``load1``load5``load15``user_string` |
| `traffic_management_stats` | 流量管理统计 | `packets_inspected``position_dedup_drops``rate_limit_drops``unknown_packet_drops` 等 |
## 过滤规则
程序默认不显示 `empty_packet`
`empty_packet``MeshPacket` 中没有 `decoded``encrypted` payload 的包,只包含类似 `from``to``id``via_mqtt` 等包头信息。根据固件源码分析,这类包通常不是普通业务数据,更多是 MQTT 回显/隐式 ACK 相关的元信息,对查看节点信息、地图报告和聊天内容价值较低。
## 输出示例
节点信息包:
```json
{"type":"nodeinfo","portnum":"NODEINFO_APP","from":"!a8dfd867","long_name":"Kabi Matrix 🖥️","short_name":"KaMX","hw_model":"PRIVATE_HW","role":"CLIENT_MUTE"}
```
地图报告包:
```json
{"type":"map_report","portnum":"MAP_REPORT_APP","from":"!675c9803","long_name":"PaulHome","latitude":42.51043,"longitude":-83.08624999999999,"hw_model":"PORTDUINO"}
```
聊天消息包:
```json
{"type":"text_message","portnum":"TEXT_MESSAGE_APP","from":"!12345678","text":"hello mesh"}
```
位置包:
```json
{"type":"position","portnum":"POSITION_APP","from":"!12345678","latitude":42.51043,"longitude":-83.08625,"altitude":192,"location_source":"LOC_INTERNAL","sats_in_view":8}
```
遥测包:
```json
{"type":"telemetry","portnum":"TELEMETRY_APP","from":"!12345678","telemetry_type":"device_metrics","metrics":{"battery_level":85,"voltage":4.1,"channel_utilization":2.3,"air_util_tx":0.5,"uptime_seconds":12345}}
```
解密失败的加密包:
```json
{"type":"encrypted_packet","decrypt_success":false,"decrypt_status":"channel hash mismatch","encrypted_len":43}
```
+169
View File
@@ -0,0 +1,169 @@
package main
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type mapTileSourceRequest struct {
Name string `json:"name"`
URLTemplate string `json:"url_template"`
Attribution string `json:"attribution"`
MaxZoom int `json:"max_zoom"`
Enabled bool `json:"enabled"`
IsDefault bool `json:"is_default"`
ProxyEnabled bool `json:"proxy_enabled"`
}
func registerMapSourceRoutes(r gin.IRouter, store *store) {
r.GET("/map-source/default", func(c *gin.Context) {
row, err := store.GetDefaultMapTileSource()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"item": publicMapTileSourceDTO(*row)})
})
r.GET("/map-source/enabled", func(c *gin.Context) {
rows, err := store.ListEnabledMapTileSources()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
items := make([]gin.H, 0, len(rows))
for _, row := range rows {
items = append(items, publicMapTileSourceDTO(row))
}
c.JSON(http.StatusOK, gin.H{"items": items})
})
}
func registerAdminMapSourceRoutes(r gin.IRouter, store *store) {
r.GET("/map-source", func(c *gin.Context) {
opts, ok := parseListOptions(c)
if !ok {
return
}
rows, err := store.ListMapTileSources(opts)
if err != nil {
writeListResponse(c, rows, opts, err, mapTileSourceDTO)
return
}
total, err := store.CountMapTileSources(opts)
writeListResponseWithTotal(c, rows, opts, total, err, mapTileSourceDTO)
})
r.POST("/map-source", func(c *gin.Context) {
var req mapTileSourceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid map source request"})
return
}
row, err := store.CreateMapTileSource(mapTileSourceInputFromRequest(req))
writeMapTileSourceMutationResponse(c, http.StatusCreated, row, err)
})
r.PUT("/map-source/:id", func(c *gin.Context) {
id, ok := parseMapTileSourceID(c)
if !ok {
return
}
var req mapTileSourceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid map source request"})
return
}
row, err := store.UpdateMapTileSource(id, mapTileSourceInputFromRequest(req))
writeMapTileSourceMutationResponse(c, http.StatusOK, row, err)
})
r.DELETE("/map-source/:id", func(c *gin.Context) {
id, ok := parseMapTileSourceID(c)
if !ok {
return
}
writeMapTileSourceDeleteResponse(c, store.DeleteMapTileSource(id))
})
r.POST("/map-source/:id/default", func(c *gin.Context) {
id, ok := parseMapTileSourceID(c)
if !ok {
return
}
row, err := store.SetDefaultMapTileSource(id)
writeMapTileSourceMutationResponse(c, http.StatusOK, row, err)
})
}
func mapTileSourceInputFromRequest(req mapTileSourceRequest) mapTileSourceInput {
return mapTileSourceInput{
Name: req.Name,
URLTemplate: req.URLTemplate,
Attribution: req.Attribution,
MaxZoom: req.MaxZoom,
Enabled: req.Enabled,
IsDefault: req.IsDefault,
ProxyEnabled: req.ProxyEnabled,
}
}
func parseMapTileSourceID(c *gin.Context) (uint64, bool) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid map source id"})
return 0, false
}
return id, true
}
func writeMapTileSourceMutationResponse(c *gin.Context, status int, row *mapTileSourceRecord, err error) {
if errors.Is(err, errMapTileSourceAlreadyExists) {
c.JSON(http.StatusConflict, gin.H{"error": "map source already exists"})
return
}
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "map source not found"})
return
}
if errors.Is(err, errMapTileSourceCannotDeleteDefault) || errors.Is(err, errMapTileSourceCannotDisableDefault) || errors.Is(err, errMapTileSourceDefaultMustBeEnabled) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(status, gin.H{"item": mapTileSourceDTO(*row)})
}
func writeMapTileSourceDeleteResponse(c *gin.Context, err error) {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "map source not found"})
return
}
if errors.Is(err, errMapTileSourceCannotDeleteDefault) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
func mapTileSourceDTO(row mapTileSourceRecord) gin.H {
return gin.H{"id": row.ID, "name": row.Name, "url_template": row.URLTemplate, "attribution": row.Attribution, "max_zoom": row.MaxZoom, "enabled": row.Enabled, "is_default": row.IsDefault, "proxy_enabled": row.ProxyEnabled, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
}
func publicMapTileSourceDTO(row mapTileSourceRecord) gin.H {
urlTemplate := row.URLTemplate
if row.ProxyEnabled {
hash := row.URLTemplateHash
if hash == "" {
hash = mapTileSourceHash(row.URLTemplate)
}
urlTemplate = "/api/map/" + hash + "?x={x}&y={y}&z={z}"
}
return gin.H{"id": row.ID, "name": row.Name, "url_template": urlTemplate, "attribution": row.Attribution, "max_zoom": row.MaxZoom}
}
+39 -17
View File
@@ -51,12 +51,13 @@ type mysqlConfig struct {
} }
type webConfig struct { type webConfig struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
Host string `yaml:"host"` Host string `yaml:"host"`
Port int `yaml:"port"` Port int `yaml:"port"`
SocketPath string `yaml:"socket_path"` SocketPath string `yaml:"socket_path"`
StaticDir string `yaml:"static_dir"` StaticDir string `yaml:"static_dir"`
Admin webAdminConfig `yaml:"admin"` MapTileCacheDir string `yaml:"map_tile_cache_dir"`
Admin webAdminConfig `yaml:"admin"`
} }
type webAdminConfig struct { type webAdminConfig struct {
@@ -104,12 +105,13 @@ type rawMySQLConfig struct {
} }
type rawWebConfig struct { type rawWebConfig struct {
Enabled *bool `yaml:"enabled"` Enabled *bool `yaml:"enabled"`
Host *string `yaml:"host"` Host *string `yaml:"host"`
Port *int `yaml:"port"` Port *int `yaml:"port"`
SocketPath *string `yaml:"socket_path"` SocketPath *string `yaml:"socket_path"`
StaticDir *string `yaml:"static_dir"` StaticDir *string `yaml:"static_dir"`
Admin *rawWebAdminConfig `yaml:"admin"` MapTileCacheDir *string `yaml:"map_tile_cache_dir"`
Admin *rawWebAdminConfig `yaml:"admin"`
} }
type rawWebAdminConfig struct { type rawWebAdminConfig struct {
@@ -140,11 +142,12 @@ func defaultConfig() *config {
MySQL: mysqlConfig{DSN: ""}, MySQL: mysqlConfig{DSN: ""},
}, },
Web: webConfig{ Web: webConfig{
Enabled: true, Enabled: true,
Host: "0.0.0.0", Host: "0.0.0.0",
Port: 8080, Port: 8080,
SocketPath: defaultWebSocketPath(), SocketPath: defaultWebSocketPath(),
StaticDir: "./dist", StaticDir: "./dist",
MapTileCacheDir: defaultMapTileCacheDir(),
Admin: webAdminConfig{ Admin: webAdminConfig{
Username: "admin", Username: "admin",
Password: "admin", Password: "admin",
@@ -176,6 +179,17 @@ func defaultWebSocketPath() string {
return defaultWebSocketPathForGOOS(runtime.GOOS) return defaultWebSocketPathForGOOS(runtime.GOOS)
} }
func defaultMapTileCacheDir() string {
return defaultMapTileCacheDirForGOOS(runtime.GOOS)
}
func defaultMapTileCacheDirForGOOS(goos string) string {
if goos == "windows" {
return filepath.Join(".", "win", "srv", "mesh_mqtt_go")
}
return filepath.Join(string(filepath.Separator), "srv", "mesh_mqtt_go")
}
func defaultWebSocketPathForGOOS(goos string) string { func defaultWebSocketPathForGOOS(goos string) string {
if goos == "windows" { if goos == "windows" {
return "" return ""
@@ -342,6 +356,11 @@ func normalizeConfig(raw rawConfig) (*config, bool) {
} else { } else {
cfg.Web.StaticDir = *raw.Web.StaticDir cfg.Web.StaticDir = *raw.Web.StaticDir
} }
if raw.Web.MapTileCacheDir == nil {
changed = true
} else {
cfg.Web.MapTileCacheDir = *raw.Web.MapTileCacheDir
}
if raw.Web.Admin == nil { if raw.Web.Admin == nil {
changed = true changed = true
} else { } else {
@@ -394,6 +413,9 @@ func validateConfig(cfg *config) error {
if cfg.Web.StaticDir == "" { if cfg.Web.StaticDir == "" {
return fmt.Errorf("web.static_dir is required when web is enabled") return fmt.Errorf("web.static_dir is required when web is enabled")
} }
if cfg.Web.MapTileCacheDir == "" {
return fmt.Errorf("web.map_tile_cache_dir is required when web is enabled")
}
if cfg.Web.Admin.Username == "" { if cfg.Web.Admin.Username == "" {
return fmt.Errorf("web.admin.username is required when web is enabled") return fmt.Errorf("web.admin.username is required when web is enabled")
} }
+25 -1
View File
@@ -44,6 +44,9 @@ func TestLoadConfigCreatesDefaultFile(t *testing.T) {
if cfg.Web.StaticDir != "./dist" { if cfg.Web.StaticDir != "./dist" {
t.Fatalf("web static dir = %q, want ./dist", cfg.Web.StaticDir) t.Fatalf("web static dir = %q, want ./dist", cfg.Web.StaticDir)
} }
if cfg.Web.MapTileCacheDir != defaultMapTileCacheDir() {
t.Fatalf("web map tile cache dir = %q, want %q", cfg.Web.MapTileCacheDir, defaultMapTileCacheDir())
}
if _, err := os.Stat(path); err != nil { if _, err := os.Stat(path); err != nil {
t.Fatalf("default config was not written: %v", err) t.Fatalf("default config was not written: %v", err)
} }
@@ -80,7 +83,7 @@ func TestLoadConfigFillsMissingFields(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
text := string(data) text := string(data)
for _, want := range []string{"host:", "tls:", "enabled:", "cert_file:", "key_file:", "meshtastic:", "psk:", "database:", "driver:", "sqlite:", "mysql:", "dsn:", "web:", "port:", "socket_path:", "static_dir:"} { for _, want := range []string{"host:", "tls:", "enabled:", "cert_file:", "key_file:", "meshtastic:", "psk:", "database:", "driver:", "sqlite:", "mysql:", "dsn:", "web:", "port:", "socket_path:", "static_dir:", "map_tile_cache_dir:"} {
if !strings.Contains(text, want) { if !strings.Contains(text, want) {
t.Fatalf("completed config missing %q in:\n%s", want, text) t.Fatalf("completed config missing %q in:\n%s", want, text)
} }
@@ -154,6 +157,20 @@ func TestLoadConfigMalformedYAMLDoesNotOverwrite(t *testing.T) {
} }
} }
func TestDefaultMapTileCacheDirForGOOS(t *testing.T) {
windowsPath := defaultMapTileCacheDirForGOOS("windows")
wantWindows := filepath.Join(".", "win", "srv", "mesh_mqtt_go")
if windowsPath != wantWindows {
t.Fatalf("windows map tile cache dir = %q, want %q", windowsPath, wantWindows)
}
linuxPath := defaultMapTileCacheDirForGOOS("linux")
wantLinux := filepath.Join(string(filepath.Separator), "srv", "mesh_mqtt_go")
if linuxPath != wantLinux {
t.Fatalf("linux map tile cache dir = %q, want %q", linuxPath, wantLinux)
}
}
func TestDefaultWebSocketPathForGOOS(t *testing.T) { func TestDefaultWebSocketPathForGOOS(t *testing.T) {
if windowsPath := defaultWebSocketPathForGOOS("windows"); windowsPath != "" { if windowsPath := defaultWebSocketPathForGOOS("windows"); windowsPath != "" {
t.Fatalf("windows web socket path = %q, want empty", windowsPath) t.Fatalf("windows web socket path = %q, want empty", windowsPath)
@@ -228,6 +245,7 @@ func TestValidateConfigWeb(t *testing.T) {
} }
cfg = defaultConfig() cfg = defaultConfig()
cfg.Web.SocketPath = filepath.Join(string(filepath.Separator), "tmp", "mesh_mqtt_go.sock")
cfg.Web.Port = 0 cfg.Web.Port = 0
if err := validateConfig(cfg); err != nil { if err := validateConfig(cfg); err != nil {
t.Fatalf("web socket with invalid port error = %v, want nil", err) t.Fatalf("web socket with invalid port error = %v, want nil", err)
@@ -246,6 +264,12 @@ func TestValidateConfigWeb(t *testing.T) {
t.Fatalf("missing web static dir error = %v, want web.static_dir error", err) t.Fatalf("missing web static dir error = %v, want web.static_dir error", err)
} }
cfg = defaultConfig()
cfg.Web.MapTileCacheDir = ""
if err := validateConfig(cfg); err == nil || !strings.Contains(err.Error(), "web.map_tile_cache_dir") {
t.Fatalf("missing map tile cache dir error = %v, want web.map_tile_cache_dir error", err)
}
cfg = defaultConfig() cfg = defaultConfig()
cfg.Web.Enabled = false cfg.Web.Enabled = false
cfg.Web.Port = 0 cfg.Web.Port = 0
+60 -1
View File
@@ -115,6 +115,24 @@ func (runtimeSettingRecord) TableName() string {
return "runtime_settings" return "runtime_settings"
} }
type mapTileSourceRecord struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
Name string `gorm:"column:name;not null;uniqueIndex"`
URLTemplate string `gorm:"column:url_template;not null;uniqueIndex"`
URLTemplateHash string `gorm:"column:url_template_hash;size:64;not null;uniqueIndex"`
Attribution string `gorm:"column:attribution"`
MaxZoom int `gorm:"column:max_zoom;not null"`
Enabled bool `gorm:"column:enabled;not null;index"`
IsDefault bool `gorm:"column:is_default;not null;index"`
ProxyEnabled bool `gorm:"column:proxy_enabled;not null;index"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;index"`
}
func (mapTileSourceRecord) TableName() string {
return "map_tile_sources"
}
type discardDetailsRecord struct { type discardDetailsRecord struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"` ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
Topic string `gorm:"column:topic"` Topic string `gorm:"column:topic"`
@@ -458,6 +476,7 @@ func (s *store) migrate() error {
{label: "login_log", model: &loginLogRecord{}}, {label: "login_log", model: &loginLogRecord{}},
{label: "help_content", model: &helpContentRecord{}}, {label: "help_content", model: &helpContentRecord{}},
{label: "runtime_settings", model: &runtimeSettingRecord{}}, {label: "runtime_settings", model: &runtimeSettingRecord{}},
{label: "map_tile_sources", model: &mapTileSourceRecord{}},
{label: "discard_details", model: &discardDetailsRecord{}}, {label: "discard_details", model: &discardDetailsRecord{}},
{label: "node_blocking", model: &nodeBlockingRecord{}}, {label: "node_blocking", model: &nodeBlockingRecord{}},
{label: "ip_blocking", model: &ipBlockingRecord{}}, {label: "ip_blocking", model: &ipBlockingRecord{}},
@@ -491,10 +510,50 @@ func (s *store) migrate() error {
return err return err
} }
} }
return nil if err := migrateMapTileSourceHash(tx, migrator, s.driver); err != nil {
return err
}
return (&store{db: tx, driver: s.driver}).EnsureDefaultMapTileSource()
}) })
} }
func migrateMapTileSourceHash(tx *gorm.DB, migrator gorm.Migrator, driver string) error {
if !migrator.HasColumn(&mapTileSourceRecord{}, "ProxyEnabled") {
if driver == databaseDriverSQLite {
if err := tx.Exec("ALTER TABLE map_tile_sources ADD COLUMN proxy_enabled numeric NOT NULL DEFAULT true").Error; err != nil {
return fmt.Errorf("migrate map_tile_sources proxy_enabled column: %w", err)
}
} else if err := migrator.AddColumn(&mapTileSourceRecord{}, "ProxyEnabled"); err != nil {
return fmt.Errorf("migrate map_tile_sources proxy_enabled column: %w", err)
}
}
if !migrator.HasColumn(&mapTileSourceRecord{}, "URLTemplateHash") {
if driver == databaseDriverSQLite {
if err := tx.Exec("ALTER TABLE map_tile_sources ADD COLUMN url_template_hash TEXT NOT NULL DEFAULT ''").Error; err != nil {
return fmt.Errorf("migrate map_tile_sources url_template_hash column: %w", err)
}
} else if err := migrator.AddColumn(&mapTileSourceRecord{}, "URLTemplateHash"); err != nil {
return fmt.Errorf("migrate map_tile_sources url_template_hash column: %w", err)
}
}
var rows []mapTileSourceRecord
if err := tx.Model(&mapTileSourceRecord{}).Where("url_template_hash = '' OR url_template_hash IS NULL").Find(&rows).Error; err != nil {
return fmt.Errorf("list map_tile_sources missing url_template_hash: %w", err)
}
for _, row := range rows {
if err := tx.Model(&mapTileSourceRecord{}).Where("id = ?", row.ID).Update("url_template_hash", mapTileSourceHash(row.URLTemplate)).Error; err != nil {
return fmt.Errorf("backfill map_tile_sources url_template_hash: %w", err)
}
}
if !migrator.HasIndex(&mapTileSourceRecord{}, "idx_map_tile_sources_url_template_hash") {
if err := migrator.CreateIndex(&mapTileSourceRecord{}, "idx_map_tile_sources_url_template_hash"); err != nil {
return fmt.Errorf("migrate map_tile_sources index idx_map_tile_sources_url_template_hash: %w", err)
}
}
return nil
}
func createMissingIndexes(migrator gorm.Migrator, model any, label string, indexNames []string) error { func createMissingIndexes(migrator gorm.Migrator, model any, label string, indexNames []string) error {
for _, indexName := range indexNames { for _, indexName := range indexNames {
if !migrator.HasIndex(model, indexName) { if !migrator.HasIndex(model, indexName) {
+1 -1
View File
@@ -15,7 +15,7 @@ func TestOpenStoreCreatesTables(t *testing.T) {
st := openTestStore(t) st := openTestStore(t)
defer st.Close() defer st.Close()
for _, table := range []string{"users", "login_log", "runtime_settings", "discard_details", "node_blocking", "ip_blocking", "forbidden_word_blocking", "nodeinfo", "map_report", "text_message", "position", "telemetry", "routing", "traceroute"} { for _, table := range []string{"users", "login_log", "runtime_settings", "map_tile_sources", "discard_details", "node_blocking", "ip_blocking", "forbidden_word_blocking", "nodeinfo", "map_report", "text_message", "position", "telemetry", "routing", "traceroute"} {
var name string var name string
if err := rawTestDB(t, st).QueryRow("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", table).Scan(&name); err != nil { if err := rawTestDB(t, st).QueryRow("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", table).Scan(&name); err != nil {
t.Fatalf("%s table missing: %v", table, err) t.Fatalf("%s table missing: %v", table, err)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 329 KiB

+4 -4
View File
@@ -3,9 +3,13 @@ module meshtastic_mqtt_server
go 1.25.0 go 1.25.0
require ( require (
github.com/eclipse/paho.mqtt.golang v1.5.1
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0
github.com/glebarez/sqlite v1.11.0 github.com/glebarez/sqlite v1.11.0
github.com/microcosm-cc/bluemonday v1.0.27
github.com/mochi-mqtt/server/v2 v2.7.9 github.com/mochi-mqtt/server/v2 v2.7.9
github.com/yuin/goldmark v1.8.2
golang.org/x/crypto v0.48.0
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.6.0 gorm.io/driver/mysql v1.6.0
@@ -20,7 +24,6 @@ require (
github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/eclipse/paho.mqtt.golang v1.5.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect github.com/glebarez/go-sqlite v1.21.2 // indirect
@@ -39,7 +42,6 @@ require (
github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
@@ -50,10 +52,8 @@ require (
github.com/rs/xid v1.4.0 // indirect github.com/rs/xid v1.4.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect
github.com/yuin/goldmark v1.8.2 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.22.0 // indirect golang.org/x/arch v0.22.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.51.0 // indirect golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect golang.org/x/sys v0.42.0 // indirect
+1
View File
@@ -175,6 +175,7 @@ func parseArgs() (*config, error) {
flag.IntVar(&cfg.Web.Port, "web-port", cfg.Web.Port, "Web server listen port") flag.IntVar(&cfg.Web.Port, "web-port", cfg.Web.Port, "Web server listen port")
flag.StringVar(&cfg.Web.SocketPath, "web-socket-path", cfg.Web.SocketPath, "Web server Unix socket path; empty uses host and port; unsupported on Windows") flag.StringVar(&cfg.Web.SocketPath, "web-socket-path", cfg.Web.SocketPath, "Web server Unix socket path; empty uses host and port; unsupported on Windows")
flag.StringVar(&cfg.Web.StaticDir, "web-static-dir", cfg.Web.StaticDir, "Web frontend static files directory") flag.StringVar(&cfg.Web.StaticDir, "web-static-dir", cfg.Web.StaticDir, "Web frontend static files directory")
flag.StringVar(&cfg.Web.MapTileCacheDir, "web-map-tile-cache-dir", cfg.Web.MapTileCacheDir, "Map tile disk cache root directory")
flag.StringVar(&cfg.Web.Admin.Username, "admin-username", cfg.Web.Admin.Username, "Web admin username") flag.StringVar(&cfg.Web.Admin.Username, "admin-username", cfg.Web.Admin.Username, "Web admin username")
flag.Parse() flag.Parse()
+358
View File
@@ -0,0 +1,358 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/url"
"strings"
"time"
"unicode"
"gorm.io/gorm"
)
const (
defaultMapTileSourceName = "OpenStreetMap Japan"
defaultMapTileSourceURLTemplate = "https://tile.openstreetmap.jp/{z}/{x}/{y}.png"
defaultMapTileSourceAttribution = "&copy; OpenStreetMap contributors"
defaultMapTileSourceMaxZoom = 19
maxMapTileSourceURLLength = 2048
)
var (
errMapTileSourceAlreadyExists = errors.New("map source already exists")
errMapTileSourceCannotDeleteDefault = errors.New("default map source cannot be deleted")
errMapTileSourceCannotDisableDefault = errors.New("default map source cannot be disabled")
errMapTileSourceDefaultMustBeEnabled = errors.New("default map source must be enabled")
)
type mapTileSourceInput struct {
Name string
URLTemplate string
Attribution string
MaxZoom int
Enabled bool
IsDefault bool
ProxyEnabled bool
}
func (s *store) ListMapTileSources(opts listOptions) ([]mapTileSourceRecord, error) {
opts = normalizeListOptions(opts)
var rows []mapTileSourceRecord
q := s.db.Model(&mapTileSourceRecord{}).
Order("is_default DESC").
Order("updated_at DESC").
Order("id DESC").
Limit(opts.Limit).
Offset(opts.Offset)
return rows, q.Find(&rows).Error
}
func (s *store) CountMapTileSources(opts listOptions) (int64, error) {
var total int64
return total, s.db.Model(&mapTileSourceRecord{}).Count(&total).Error
}
func (s *store) ListEnabledMapTileSources() ([]mapTileSourceRecord, error) {
var rows []mapTileSourceRecord
if err := s.db.Model(&mapTileSourceRecord{}).
Where("enabled = ?", true).
Order("is_default DESC").
Order("updated_at DESC").
Order("id DESC").
Find(&rows).Error; err != nil {
return nil, err
}
if len(rows) == 0 {
return []mapTileSourceRecord{defaultMapTileSourceRecord()}, nil
}
return rows, nil
}
func (s *store) GetDefaultMapTileSource() (*mapTileSourceRecord, error) {
var row mapTileSourceRecord
err := s.db.Where("enabled = ? AND is_default = ?", true, true).Order("id ASC").Take(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
fallback := defaultMapTileSourceRecord()
return &fallback, nil
}
if err != nil {
return nil, err
}
return &row, nil
}
func (s *store) GetEnabledMapTileSourceByHash(hash string) (*mapTileSourceRecord, error) {
var row mapTileSourceRecord
if err := s.db.Where("enabled = ? AND proxy_enabled = ? AND url_template_hash = ?", true, true, hash).Take(&row).Error; err != nil {
return nil, err
}
return &row, nil
}
func (s *store) CreateMapTileSource(input mapTileSourceInput) (*mapTileSourceRecord, error) {
row, err := mapTileSourceFromInput(input)
if err != nil {
return nil, err
}
if row.IsDefault && !row.Enabled {
return nil, errMapTileSourceDefaultMustBeEnabled
}
if err := s.ensureMapTileSourceUnique(0, row.Name, row.URLTemplate); err != nil {
return nil, err
}
if err := s.db.Transaction(func(tx *gorm.DB) error {
if row.IsDefault {
if err := tx.Model(&mapTileSourceRecord{}).Where("is_default = ?", true).Update("is_default", false).Error; err != nil {
return err
}
}
return tx.Create(row).Error
}); err != nil {
return nil, err
}
return row, nil
}
func (s *store) UpdateMapTileSource(id uint64, input mapTileSourceInput) (*mapTileSourceRecord, error) {
if id == 0 {
return nil, fmt.Errorf("map source id is required")
}
row, err := mapTileSourceFromInput(input)
if err != nil {
return nil, err
}
var updated mapTileSourceRecord
if err := s.db.Transaction(func(tx *gorm.DB) error {
var existing mapTileSourceRecord
if err := tx.Where("id = ?", id).Take(&existing).Error; err != nil {
return err
}
if existing.IsDefault && !row.Enabled {
return errMapTileSourceCannotDisableDefault
}
if row.IsDefault && !row.Enabled {
return errMapTileSourceDefaultMustBeEnabled
}
if !row.IsDefault && existing.IsDefault {
row.IsDefault = true
}
if err := ensureMapTileSourceUniqueTx(tx, id, row.Name, row.URLTemplate); err != nil {
return err
}
if row.IsDefault {
if err := tx.Model(&mapTileSourceRecord{}).Where("id <> ? AND is_default = ?", id, true).Update("is_default", false).Error; err != nil {
return err
}
}
updates := map[string]any{
"name": row.Name,
"url_template": row.URLTemplate,
"url_template_hash": row.URLTemplateHash,
"attribution": row.Attribution,
"max_zoom": row.MaxZoom,
"enabled": row.Enabled,
"is_default": row.IsDefault,
"proxy_enabled": row.ProxyEnabled,
"updated_at": time.Now(),
}
if err := tx.Model(&mapTileSourceRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
return err
}
return tx.Where("id = ?", id).Take(&updated).Error
}); err != nil {
return nil, err
}
return &updated, nil
}
func (s *store) DeleteMapTileSource(id uint64) error {
if id == 0 {
return fmt.Errorf("map source id is required")
}
return s.db.Transaction(func(tx *gorm.DB) error {
var row mapTileSourceRecord
if err := tx.Where("id = ?", id).Take(&row).Error; err != nil {
return err
}
if row.IsDefault {
return errMapTileSourceCannotDeleteDefault
}
result := tx.Where("id = ?", id).Delete(&mapTileSourceRecord{})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
})
}
func (s *store) SetDefaultMapTileSource(id uint64) (*mapTileSourceRecord, error) {
if id == 0 {
return nil, fmt.Errorf("map source id is required")
}
var row mapTileSourceRecord
if err := s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("id = ?", id).Take(&row).Error; err != nil {
return err
}
if !row.Enabled {
return errMapTileSourceDefaultMustBeEnabled
}
if err := tx.Model(&mapTileSourceRecord{}).Where("is_default = ?", true).Update("is_default", false).Error; err != nil {
return err
}
if err := tx.Model(&mapTileSourceRecord{}).Where("id = ?", id).Updates(map[string]any{"is_default": true, "updated_at": time.Now()}).Error; err != nil {
return err
}
return tx.Where("id = ?", id).Take(&row).Error
}); err != nil {
return nil, err
}
return &row, nil
}
func (s *store) EnsureDefaultMapTileSource() error {
return s.db.Transaction(func(tx *gorm.DB) error {
var count int64
if err := tx.Model(&mapTileSourceRecord{}).Count(&count).Error; err != nil {
return err
}
if count == 0 {
row := defaultMapTileSourceRecord()
return tx.Create(&row).Error
}
var defaults []mapTileSourceRecord
if err := tx.Where("enabled = ? AND is_default = ?", true, true).Order("id ASC").Find(&defaults).Error; err != nil {
return err
}
if len(defaults) > 0 {
return tx.Model(&mapTileSourceRecord{}).Where("id <> ? AND is_default = ?", defaults[0].ID, true).Update("is_default", false).Error
}
var enabled mapTileSourceRecord
err := tx.Where("enabled = ?", true).Order("id ASC").Take(&enabled).Error
if err == nil {
return tx.Model(&mapTileSourceRecord{}).Where("id = ?", enabled.ID).Updates(map[string]any{"is_default": true, "updated_at": time.Now()}).Error
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
row := defaultMapTileSourceRecord()
var existing mapTileSourceRecord
err = tx.Where("name = ? OR url_template = ?", row.Name, row.URLTemplate).Order("id ASC").Take(&existing).Error
if err == nil {
return tx.Model(&mapTileSourceRecord{}).Where("id = ?", existing.ID).Updates(map[string]any{"enabled": true, "is_default": true, "updated_at": time.Now()}).Error
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
return tx.Create(&row).Error
})
}
func mapTileSourceHash(urlTemplate string) string {
h := sha256.Sum256([]byte(urlTemplate))
return hex.EncodeToString(h[:])
}
func defaultMapTileSourceRecord() mapTileSourceRecord {
return mapTileSourceRecord{
Name: defaultMapTileSourceName,
URLTemplate: defaultMapTileSourceURLTemplate,
URLTemplateHash: mapTileSourceHash(defaultMapTileSourceURLTemplate),
Attribution: defaultMapTileSourceAttribution,
MaxZoom: defaultMapTileSourceMaxZoom,
Enabled: true,
IsDefault: true,
ProxyEnabled: true,
}
}
func mapTileSourceFromInput(input mapTileSourceInput) (*mapTileSourceRecord, error) {
name := strings.TrimSpace(input.Name)
if name == "" {
return nil, fmt.Errorf("map source name is required")
}
urlTemplate, err := normalizeMapTileSourceURLTemplate(input.URLTemplate)
if err != nil {
return nil, err
}
maxZoom := input.MaxZoom
if maxZoom == 0 {
maxZoom = defaultMapTileSourceMaxZoom
}
if maxZoom < 1 || maxZoom > 30 {
return nil, fmt.Errorf("max zoom must be between 1 and 30")
}
return &mapTileSourceRecord{
Name: name,
URLTemplate: urlTemplate,
URLTemplateHash: mapTileSourceHash(urlTemplate),
Attribution: strings.TrimSpace(input.Attribution),
MaxZoom: maxZoom,
Enabled: input.Enabled,
IsDefault: input.IsDefault,
ProxyEnabled: input.ProxyEnabled,
}, nil
}
func normalizeMapTileSourceURLTemplate(value string) (string, error) {
value = strings.TrimSpace(value)
if value == "" {
return "", fmt.Errorf("map source url template is required")
}
if len(value) > maxMapTileSourceURLLength {
return "", fmt.Errorf("map source url template is too long")
}
for _, r := range value {
if unicode.IsControl(r) || unicode.IsSpace(r) {
return "", fmt.Errorf("map source url template must not contain whitespace or control characters")
}
}
for _, placeholder := range []string{"{z}", "{x}", "{y}"} {
if strings.Count(value, placeholder) != 1 {
return "", fmt.Errorf("map source url template must contain %s exactly once", placeholder)
}
}
parsed, err := url.Parse(value)
if err != nil {
return "", fmt.Errorf("map source url template is invalid")
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return "", fmt.Errorf("map source url template must use http or https")
}
if parsed.Host == "" {
return "", fmt.Errorf("map source url template host is required")
}
if parsed.User != nil {
return "", fmt.Errorf("map source url template must not contain credentials")
}
return value, nil
}
func (s *store) ensureMapTileSourceUnique(id uint64, name, urlTemplate string) error {
return ensureMapTileSourceUniqueTx(s.db, id, name, urlTemplate)
}
func ensureMapTileSourceUniqueTx(tx *gorm.DB, id uint64, name, urlTemplate string) error {
var existing mapTileSourceRecord
q := tx.Where("name = ? OR url_template = ?", name, urlTemplate)
if id != 0 {
q = q.Where("id <> ?", id)
}
err := q.Take(&existing).Error
if err == nil {
return errMapTileSourceAlreadyExists
}
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
+258
View File
@@ -0,0 +1,258 @@
package main
import (
"errors"
"strings"
"testing"
"gorm.io/gorm"
)
func TestMapTileSourceDefaultSeeded(t *testing.T) {
st := openTestStore(t)
defer st.Close()
row, err := st.GetDefaultMapTileSource()
if err != nil {
t.Fatalf("GetDefaultMapTileSource() error = %v", err)
}
if row.Name != defaultMapTileSourceName || row.URLTemplate != defaultMapTileSourceURLTemplate || !row.Enabled || !row.IsDefault {
t.Fatalf("default map source = %+v, want built-in default", row)
}
}
func TestCreateMapTileSourceValidation(t *testing.T) {
st := openTestStore(t)
defer st.Close()
if _, err := st.CreateMapTileSource(mapTileSourceInput{Name: "bad", URLTemplate: "https://tiles.example.com/{z}/{x}.png", MaxZoom: 19, Enabled: true, ProxyEnabled: true}); err == nil {
t.Fatal("CreateMapTileSource() missing placeholder error = nil, want error")
}
if _, err := st.CreateMapTileSource(mapTileSourceInput{Name: "bad", URLTemplate: "javascript:alert(1)/{z}/{x}/{y}", MaxZoom: 19, Enabled: true, ProxyEnabled: true}); err == nil {
t.Fatal("CreateMapTileSource() invalid scheme error = nil, want error")
}
if _, err := st.CreateMapTileSource(mapTileSourceInput{Name: "bad", URLTemplate: "https://user:pass@tiles.example.com/{z}/{x}/{y}.png", MaxZoom: 19, Enabled: true, ProxyEnabled: true}); err == nil {
t.Fatal("CreateMapTileSource() credentials error = nil, want error")
}
}
func TestListEnabledMapTileSources(t *testing.T) {
st := openTestStore(t)
defer st.Close()
disabled, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Disabled", URLTemplate: "https://disabled.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: false})
if err != nil {
t.Fatalf("CreateMapTileSource(disabled) error = %v", err)
}
custom, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Custom", URLTemplate: "https://custom.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
if err != nil {
t.Fatalf("CreateMapTileSource(custom) error = %v", err)
}
if _, err := st.SetDefaultMapTileSource(custom.ID); err != nil {
t.Fatalf("SetDefaultMapTileSource() error = %v", err)
}
rows, err := st.ListEnabledMapTileSources()
if err != nil {
t.Fatalf("ListEnabledMapTileSources() error = %v", err)
}
if len(rows) < 2 {
t.Fatalf("ListEnabledMapTileSources() length = %d, want at least 2", len(rows))
}
if rows[0].ID != custom.ID {
t.Fatalf("first enabled source id = %d, want default %d", rows[0].ID, custom.ID)
}
for _, row := range rows {
if row.ID == disabled.ID {
t.Fatalf("disabled source was returned: %+v", row)
}
if !row.Enabled {
t.Fatalf("disabled row returned: %+v", row)
}
}
}
func TestMapTileSourceDuplicateAndDefaultRules(t *testing.T) {
st := openTestStore(t)
defer st.Close()
first, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Custom", URLTemplate: "https://tiles.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
if err != nil {
t.Fatalf("CreateMapTileSource() error = %v", err)
}
if _, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Custom", URLTemplate: "https://tiles2.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true}); !errors.Is(err, errMapTileSourceAlreadyExists) {
t.Fatalf("duplicate name error = %v, want errMapTileSourceAlreadyExists", err)
}
if _, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Custom 2", URLTemplate: first.URLTemplate, MaxZoom: 18, Enabled: true, ProxyEnabled: true}); !errors.Is(err, errMapTileSourceAlreadyExists) {
t.Fatalf("duplicate url error = %v, want errMapTileSourceAlreadyExists", err)
}
updated, err := st.SetDefaultMapTileSource(first.ID)
if err != nil {
t.Fatalf("SetDefaultMapTileSource() error = %v", err)
}
if !updated.IsDefault {
t.Fatalf("updated default = %+v, want is_default", updated)
}
oldDefault, err := st.GetDefaultMapTileSource()
if err != nil {
t.Fatalf("GetDefaultMapTileSource() error = %v", err)
}
if oldDefault.ID != first.ID {
t.Fatalf("default id = %d, want %d", oldDefault.ID, first.ID)
}
if _, err := st.UpdateMapTileSource(first.ID, mapTileSourceInput{Name: first.Name, URLTemplate: first.URLTemplate, Attribution: first.Attribution, MaxZoom: first.MaxZoom, Enabled: false, IsDefault: true}); !errors.Is(err, errMapTileSourceCannotDisableDefault) {
t.Fatalf("disable default error = %v, want errMapTileSourceCannotDisableDefault", err)
}
if err := st.DeleteMapTileSource(first.ID); !errors.Is(err, errMapTileSourceCannotDeleteDefault) {
t.Fatalf("delete default error = %v, want errMapTileSourceCannotDeleteDefault", err)
}
}
func TestMapTileSourceHashIsSetOnCreate(t *testing.T) {
st := openTestStore(t)
defer st.Close()
row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Hashed", URLTemplate: "https://test.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
if err != nil {
t.Fatalf("CreateMapTileSource() error = %v", err)
}
want := mapTileSourceHash("https://test.example.com/{z}/{x}/{y}.png")
if row.URLTemplateHash != want {
t.Fatalf("URLTemplateHash = %q, want %q", row.URLTemplateHash, want)
}
if !row.ProxyEnabled {
t.Fatal("ProxyEnabled = false, want true")
}
}
func TestMapTileSourceDefaultHasHash(t *testing.T) {
st := openTestStore(t)
defer st.Close()
row, err := st.GetDefaultMapTileSource()
if err != nil {
t.Fatalf("GetDefaultMapTileSource() error = %v", err)
}
want := mapTileSourceHash(defaultMapTileSourceURLTemplate)
if row.URLTemplateHash != want {
t.Fatalf("default URLTemplateHash = %q, want %q", row.URLTemplateHash, want)
}
}
func TestGetEnabledMapTileSourceByHash(t *testing.T) {
st := openTestStore(t)
defer st.Close()
row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "HashLookup", URLTemplate: "https://lookup.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
if err != nil {
t.Fatalf("CreateMapTileSource() error = %v", err)
}
found, err := st.GetEnabledMapTileSourceByHash(row.URLTemplateHash)
if err != nil {
t.Fatalf("GetEnabledMapTileSourceByHash() error = %v", err)
}
if found.ID != row.ID {
t.Fatalf("found ID = %d, want %d", found.ID, row.ID)
}
}
func TestGetEnabledMapTileSourceByHashDisabled(t *testing.T) {
st := openTestStore(t)
defer st.Close()
row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "DisabledHash", URLTemplate: "https://disabled-hash.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: false})
if err != nil {
t.Fatalf("CreateMapTileSource() error = %v", err)
}
_, err = st.GetEnabledMapTileSourceByHash(row.URLTemplateHash)
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatalf("GetEnabledMapTileSourceByHash(disabled) = %v, want gorm.ErrRecordNotFound", err)
}
}
func TestGetEnabledMapTileSourceByHashProxyDisabled(t *testing.T) {
st := openTestStore(t)
defer st.Close()
row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "ProxyDisabledHash", URLTemplate: "https://proxy-disabled.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: false})
if err != nil {
t.Fatalf("CreateMapTileSource() error = %v", err)
}
_, err = st.GetEnabledMapTileSourceByHash(row.URLTemplateHash)
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatalf("GetEnabledMapTileSourceByHash(proxy disabled) = %v, want gorm.ErrRecordNotFound", err)
}
}
func TestGetEnabledMapTileSourceByHashUnknown(t *testing.T) {
st := openTestStore(t)
defer st.Close()
_, err := st.GetEnabledMapTileSourceByHash("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatalf("GetEnabledMapTileSourceByHash(unknown) = %v, want gorm.ErrRecordNotFound", err)
}
}
func TestPublicMapTileSourceDTOProxyURL(t *testing.T) {
st := openTestStore(t)
defer st.Close()
row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "ProxyTest", URLTemplate: "https://proxy.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
if err != nil {
t.Fatalf("CreateMapTileSource() error = %v", err)
}
dto := publicMapTileSourceDTO(*row)
urlTemplate, ok := dto["url_template"].(string)
if !ok {
t.Fatal("url_template is not a string")
}
wantPrefix := "/api/map/" + row.URLTemplateHash + "?x={x}&y={y}&z={z}"
if urlTemplate != wantPrefix {
t.Fatalf("url_template = %q, want %q", urlTemplate, wantPrefix)
}
if strings.Contains(urlTemplate, "proxy.example.com") {
t.Fatal("url_template should not contain upstream hostname")
}
}
func TestPublicMapTileSourceDTORawURLWhenProxyDisabled(t *testing.T) {
st := openTestStore(t)
defer st.Close()
row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "RawTest", URLTemplate: "https://raw.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: false})
if err != nil {
t.Fatalf("CreateMapTileSource() error = %v", err)
}
dto := publicMapTileSourceDTO(*row)
urlTemplate, ok := dto["url_template"].(string)
if !ok {
t.Fatal("url_template is not a string")
}
if urlTemplate != row.URLTemplate {
t.Fatalf("url_template = %q, want raw %q", urlTemplate, row.URLTemplate)
}
}
func TestMapTileSourceHashFunction(t *testing.T) {
hash1 := mapTileSourceHash("https://tile.openstreetmap.jp/{z}/{x}/{y}.png")
hash2 := mapTileSourceHash("https://tile.openstreetmap.jp/{z}/{x}/{y}.png")
hash3 := mapTileSourceHash("https://other.example.com/{z}/{x}/{y}.png")
if hash1 != hash2 {
t.Fatal("hash should be deterministic")
}
if len(hash1) != 64 {
t.Fatalf("hash length = %d, want 64", len(hash1))
}
if hash1 == hash3 {
t.Fatal("different URLs should produce different hashes")
}
}
+200
View File
@@ -0,0 +1,200 @@
package main
import (
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
const (
mapTileCacheControl = "public, max-age=86400"
maxMapTileBytes = 10 << 20
)
type mapTileProxy struct {
store *store
cacheDir string
client *http.Client
}
func registerMapTileProxyRoutes(r gin.IRouter, store *store, cacheDir string) {
proxy := &mapTileProxy{
store: store,
cacheDir: cacheDir,
client: &http.Client{Timeout: 15 * time.Second},
}
r.GET("/map/:sourceHash", proxy.handle)
}
func (p *mapTileProxy) handle(c *gin.Context) {
sourceHash := strings.ToLower(c.Param("sourceHash"))
if !isMapTileSourceHash(sourceHash) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid map source hash"})
return
}
row, err := p.store.GetEnabledMapTileSourceByHash(sourceHash)
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "map source not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
tile, ok := parseMapTileCoordinates(c, row.MaxZoom)
if !ok {
return
}
cachePath := mapTileCachePath(p.cacheDir, sourceHash, tile)
if data, err := os.ReadFile(cachePath); err == nil {
writeMapTile(c, data)
return
} else if !os.IsNotExist(err) {
// Fall through to upstream fetch. A broken cache file should not prevent map rendering.
}
data, status, err := p.fetchRemoteTile(c.Request, row.URLTemplate, tile)
if err != nil {
c.JSON(status, gin.H{"error": err.Error()})
return
}
_ = writeMapTileCacheFile(cachePath, data)
writeMapTile(c, data)
}
func (p *mapTileProxy) fetchRemoteTile(req *http.Request, template string, tile mapTileCoordinates) ([]byte, int, error) {
remoteURL := expandMapTileURLTemplate(template, tile)
upstreamReq, err := http.NewRequestWithContext(req.Context(), http.MethodGet, remoteURL, nil)
if err != nil {
return nil, http.StatusBadGateway, fmt.Errorf("build upstream map tile request: %w", err)
}
upstreamReq.Header.Set("User-Agent", "mesh_mqtt_go map tile cache")
resp, err := p.client.Do(upstreamReq)
if err != nil {
return nil, http.StatusBadGateway, fmt.Errorf("fetch upstream map tile: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, http.StatusNotFound, fmt.Errorf("upstream map tile not found")
}
if resp.StatusCode != http.StatusOK {
return nil, http.StatusBadGateway, fmt.Errorf("upstream map tile returned status %d", resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, maxMapTileBytes+1))
if err != nil {
return nil, http.StatusBadGateway, fmt.Errorf("read upstream map tile: %w", err)
}
if len(data) > maxMapTileBytes {
return nil, http.StatusBadGateway, fmt.Errorf("upstream map tile is too large")
}
return data, http.StatusOK, nil
}
type mapTileCoordinates struct {
x int64
y int64
z int64
}
func parseMapTileCoordinates(c *gin.Context, maxZoom int) (mapTileCoordinates, bool) {
x, ok := parseMapTileCoordinate(c, "x")
if !ok {
return mapTileCoordinates{}, false
}
y, ok := parseMapTileCoordinate(c, "y")
if !ok {
return mapTileCoordinates{}, false
}
z, ok := parseMapTileCoordinate(c, "z")
if !ok {
return mapTileCoordinates{}, false
}
if z > int64(maxZoom) {
c.JSON(http.StatusBadRequest, gin.H{"error": "map tile z exceeds max zoom"})
return mapTileCoordinates{}, false
}
limit := int64(1) << z
if x >= limit || y >= limit {
c.JSON(http.StatusBadRequest, gin.H{"error": "map tile coordinates out of range"})
return mapTileCoordinates{}, false
}
return mapTileCoordinates{x: x, y: y, z: z}, true
}
func parseMapTileCoordinate(c *gin.Context, name string) (int64, bool) {
value := c.Query(name)
if value == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing map tile " + name})
return 0, false
}
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil || parsed < 0 || parsed > 30_000_000_000 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid map tile " + name})
return 0, false
}
return parsed, true
}
func isMapTileSourceHash(value string) bool {
if len(value) != 64 {
return false
}
for _, r := range value {
if (r < '0' || r > '9') && (r < 'a' || r > 'f') {
return false
}
}
return true
}
func expandMapTileURLTemplate(template string, tile mapTileCoordinates) string {
result := strings.ReplaceAll(template, "{x}", strconv.FormatInt(tile.x, 10))
result = strings.ReplaceAll(result, "{y}", strconv.FormatInt(tile.y, 10))
result = strings.ReplaceAll(result, "{z}", strconv.FormatInt(tile.z, 10))
return result
}
func mapTileCachePath(cacheDir, sourceHash string, tile mapTileCoordinates) string {
return filepath.Join(cacheDir, sourceHash, strconv.FormatInt(tile.z, 10), strconv.FormatInt(tile.x, 10), strconv.FormatInt(tile.y, 10)+".tile")
}
func writeMapTileCacheFile(path string, data []byte) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".*.tmp")
if err != nil {
return err
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpPath, path)
}
func writeMapTile(c *gin.Context, data []byte) {
contentType := http.DetectContentType(data)
c.Header("Cache-Control", mapTileCacheControl)
c.Data(http.StatusOK, contentType, data)
}
+159
View File
@@ -0,0 +1,159 @@
package main
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestMapTileProxyFetchesAndCaches(t *testing.T) {
st := openTestStore(t)
defer st.Close()
requests := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
if r.URL.Path != "/3/1/2.png" {
t.Fatalf("upstream path = %q, want /3/1/2.png", r.URL.Path)
}
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write([]byte("tile-data"))
}))
defer upstream.Close()
row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Tiles", URLTemplate: upstream.URL + "/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
if err != nil {
t.Fatalf("CreateMapTileSource() error = %v", err)
}
cacheDir := t.TempDir()
router := newRouter(webConfig{StaticDir: t.TempDir(), MapTileCacheDir: cacheDir}, st, nil, nil, nil, nil, nil)
url := "/api/map/" + row.URLTemplateHash + "?x=1&y=2&z=3"
for i := 0; i < 2; i++ {
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, url, nil)
router.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("request %d status = %d, body = %s", i+1, recorder.Code, recorder.Body.String())
}
if recorder.Body.String() != "tile-data" {
t.Fatalf("request %d body = %q, want tile-data", i+1, recorder.Body.String())
}
}
if requests != 1 {
t.Fatalf("upstream requests = %d, want 1", requests)
}
cachePath := filepath.Join(cacheDir, row.URLTemplateHash, "3", "1", "2.tile")
data, err := os.ReadFile(cachePath)
if err != nil {
t.Fatalf("read cache file %s: %v", cachePath, err)
}
if string(data) != "tile-data" {
t.Fatalf("cache file = %q, want tile-data", string(data))
}
}
func TestMapTileProxyRejectsInvalidCoordinates(t *testing.T) {
st := openTestStore(t)
defer st.Close()
row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Tiles", URLTemplate: "https://tiles.example.com/{z}/{x}/{y}.png", MaxZoom: 3, Enabled: true, ProxyEnabled: true})
if err != nil {
t.Fatalf("CreateMapTileSource() error = %v", err)
}
router := newRouter(webConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, st, nil, nil, nil, nil, nil)
cases := []string{
"/api/map/" + row.URLTemplateHash + "?y=0&z=0",
"/api/map/" + row.URLTemplateHash + "?x=-1&y=0&z=0",
"/api/map/" + row.URLTemplateHash + "?x=0&y=0&z=4",
"/api/map/" + row.URLTemplateHash + "?x=2&y=0&z=1",
}
for _, url := range cases {
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, url, nil)
router.ServeHTTP(recorder, req)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("%s status = %d, want 400; body = %s", url, recorder.Code, recorder.Body.String())
}
}
}
func TestMapTileProxyUnknownAndDisabledSource(t *testing.T) {
st := openTestStore(t)
defer st.Close()
disabled, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Disabled", URLTemplate: "https://disabled.example.com/{z}/{x}/{y}.png", MaxZoom: 3, Enabled: false})
if err != nil {
t.Fatalf("CreateMapTileSource(disabled) error = %v", err)
}
proxyDisabled, err := st.CreateMapTileSource(mapTileSourceInput{Name: "ProxyDisabled", URLTemplate: "https://proxy-disabled.example.com/{z}/{x}/{y}.png", MaxZoom: 3, Enabled: true, ProxyEnabled: false})
if err != nil {
t.Fatalf("CreateMapTileSource(proxy disabled) error = %v", err)
}
router := newRouter(webConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, st, nil, nil, nil, nil, nil)
cases := []string{
"/api/map/not-a-hash?x=0&y=0&z=0",
"/api/map/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa?x=0&y=0&z=0",
"/api/map/" + disabled.URLTemplateHash + "?x=0&y=0&z=0",
"/api/map/" + proxyDisabled.URLTemplateHash + "?x=0&y=0&z=0",
}
wantStatus := []int{http.StatusBadRequest, http.StatusNotFound, http.StatusNotFound, http.StatusNotFound}
for i, url := range cases {
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, url, nil)
router.ServeHTTP(recorder, req)
if recorder.Code != wantStatus[i] {
t.Fatalf("%s status = %d, want %d; body = %s", url, recorder.Code, wantStatus[i], recorder.Body.String())
}
}
}
func TestMapTileProxyUpstreamStatus(t *testing.T) {
st := openTestStore(t)
defer st.Close()
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/404/") {
http.NotFound(w, r)
return
}
http.Error(w, "upstream error", http.StatusInternalServerError)
}))
defer upstream.Close()
row404, err := st.CreateMapTileSource(mapTileSourceInput{Name: "NotFoundTiles", URLTemplate: upstream.URL + "/404/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
if err != nil {
t.Fatalf("CreateMapTileSource(404) error = %v", err)
}
row500, err := st.CreateMapTileSource(mapTileSourceInput{Name: "StatusTiles", URLTemplate: upstream.URL + "/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
if err != nil {
t.Fatalf("CreateMapTileSource(500) error = %v", err)
}
router := newRouter(webConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, st, nil, nil, nil, nil, nil)
cases := []struct {
url string
want int
}{
{url: "/api/map/" + row404.URLTemplateHash + "?x=0&y=0&z=0", want: http.StatusNotFound},
{url: "/api/map/" + row500.URLTemplateHash + "?x=0&y=0&z=0", want: http.StatusBadGateway},
}
for _, tc := range cases {
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, tc.url, nil)
router.ServeHTTP(recorder, req)
if recorder.Code != tc.want {
t.Fatalf("%s status = %d, want %d; body = %s", tc.url, recorder.Code, tc.want, recorder.Body.String())
}
}
}
+369
View File
@@ -12,10 +12,12 @@
"vue": "^3.5.34" "vue": "^3.5.34"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.3.0",
"@types/leaflet": "^1.9.21", "@types/leaflet": "^1.9.21",
"@types/node": "^24.12.3", "@types/node": "^24.12.3",
"@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue": "^6.0.6",
"@vue/tsconfig": "^0.9.1", "@vue/tsconfig": "^0.9.1",
"tailwindcss": "^4.3.0",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"vite": "^8.0.12", "vite": "^8.0.12",
"vue-tsc": "^3.2.8" "vue-tsc": "^3.2.8"
@@ -101,12 +103,55 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": { "node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5", "version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
@@ -400,6 +445,278 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@tailwindcss/node": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz",
"integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"enhanced-resolve": "^5.21.0",
"jiti": "^2.6.1",
"lightningcss": "1.32.0",
"magic-string": "^0.30.21",
"source-map-js": "^1.2.1",
"tailwindcss": "4.3.0"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz",
"integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.3.0",
"@tailwindcss/oxide-darwin-arm64": "4.3.0",
"@tailwindcss/oxide-darwin-x64": "4.3.0",
"@tailwindcss/oxide-freebsd-x64": "4.3.0",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0",
"@tailwindcss/oxide-linux-arm64-gnu": "4.3.0",
"@tailwindcss/oxide-linux-arm64-musl": "4.3.0",
"@tailwindcss/oxide-linux-x64-gnu": "4.3.0",
"@tailwindcss/oxide-linux-x64-musl": "4.3.0",
"@tailwindcss/oxide-wasm32-wasi": "4.3.0",
"@tailwindcss/oxide-win32-arm64-msvc": "4.3.0",
"@tailwindcss/oxide-win32-x64-msvc": "4.3.0"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz",
"integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz",
"integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz",
"integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz",
"integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz",
"integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz",
"integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz",
"integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz",
"integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz",
"integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz",
"integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
"@emnapi/runtime",
"@tybys/wasm-util",
"@emnapi/wasi-threads",
"tslib"
],
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.10.0",
"@emnapi/runtime": "^1.10.0",
"@emnapi/wasi-threads": "^1.2.1",
"@napi-rs/wasm-runtime": "^1.1.4",
"@tybys/wasm-util": "^0.10.1",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz",
"integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz",
"integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/vite": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz",
"integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@tailwindcss/node": "4.3.0",
"@tailwindcss/oxide": "4.3.0",
"tailwindcss": "4.3.0"
},
"peerDependencies": {
"vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
"node_modules/@tybys/wasm-util": { "node_modules/@tybys/wasm-util": {
"version": "0.10.2", "version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
@@ -642,6 +959,20 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/enhanced-resolve": {
"version": "5.23.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.23.0.tgz",
"integrity": "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
"tapable": "^2.3.3"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/entities": { "node_modules/entities": {
"version": "7.0.1", "version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
@@ -693,6 +1024,23 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC"
},
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/leaflet": { "node_modules/leaflet": {
"version": "1.9.4", "version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
@@ -1091,6 +1439,27 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/tailwindcss": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
"integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==",
"dev": true,
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
"integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+2
View File
@@ -13,10 +13,12 @@
"vue": "^3.5.34" "vue": "^3.5.34"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.3.0",
"@types/leaflet": "^1.9.21", "@types/leaflet": "^1.9.21",
"@types/node": "^24.12.3", "@types/node": "^24.12.3",
"@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue": "^6.0.6",
"@vue/tsconfig": "^0.9.1", "@vue/tsconfig": "^0.9.1",
"tailwindcss": "^4.3.0",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"vite": "^8.0.12", "vite": "^8.0.12",
"vue-tsc": "^3.2.8" "vue-tsc": "^3.2.8"
+24 -1
View File
@@ -8,6 +8,7 @@ import AdminDiscardDetails from './components/AdminDiscardDetails.vue'
import AdminHelpEdit from './components/AdminHelpEdit.vue' import AdminHelpEdit from './components/AdminHelpEdit.vue'
import AdminLogin from './components/AdminLogin.vue' import AdminLogin from './components/AdminLogin.vue'
import AdminLoginLogs from './components/AdminLoginLogs.vue' import AdminLoginLogs from './components/AdminLoginLogs.vue'
import AdminMapSource from './components/AdminMapSource.vue'
import AdminMqttForward from './components/AdminMqttForward.vue' import AdminMqttForward from './components/AdminMqttForward.vue'
import AdminUsers from './components/AdminUsers.vue' import AdminUsers from './components/AdminUsers.vue'
import ChatPanel from './components/ChatPanel.vue' import ChatPanel from './components/ChatPanel.vue'
@@ -16,7 +17,8 @@ import HelpPage from './components/HelpPage.vue'
import MeshMap from './components/MeshMap.vue' import MeshMap from './components/MeshMap.vue'
import NodeDetailedPage from './components/NodeDetailedPage.vue' import NodeDetailedPage from './components/NodeDetailedPage.vue'
import NodeListPanel from './components/NodeListPanel.vue' import NodeListPanel from './components/NodeListPanel.vue'
import type { AdminUser, HealthStatus, MapBoundsChangePayload, MapBoundsQuery, MapRenderable, MapViewportItem, NodeInfo, NodeInfoById, PositionRecord, TextMessage } from './types' import { fallbackMapSource, loadEnabledMapSources } from './mapSource'
import type { AdminUser, HealthStatus, MapBoundsChangePayload, MapBoundsQuery, MapRenderable, MapViewportItem, NodeInfo, NodeInfoById, PositionRecord, PublicMapTileSource, TextMessage } from './types'
const currentPath = window.location.pathname const currentPath = window.location.pathname
const adminPath = currentPath const adminPath = currentPath
@@ -52,6 +54,8 @@ const currentMapBounds = ref<MapBoundsQuery | null>(null)
const currentMapZoom = ref(2) const currentMapZoom = ref(2)
const mapReportsLoading = ref(false) const mapReportsLoading = ref(false)
const mapReportTotal = ref(0) const mapReportTotal = ref(0)
const mapSources = ref<PublicMapTileSource[]>([fallbackMapSource])
const mapSource = ref<PublicMapTileSource>(fallbackMapSource)
const pendingDeleteAction = ref<PendingDeleteAction | null>(null) const pendingDeleteAction = ref<PendingDeleteAction | null>(null)
type DeletableTextMessage = TextMessage & { mergedCount?: number; mergedMessages?: TextMessage[] } type DeletableTextMessage = TextMessage & { mergedCount?: number; mergedMessages?: TextMessage[] }
type NodeActionRequest = { nodeId: string; nodeNum: number | null; message?: DeletableTextMessage } type NodeActionRequest = { nodeId: string; nodeNum: number | null; message?: DeletableTextMessage }
@@ -294,6 +298,19 @@ async function refresh(showLoading = true) {
} }
} }
async function loadMapSource() {
const sources = await loadEnabledMapSources()
mapSources.value = sources
mapSource.value = sources[0] ?? fallbackMapSource
}
function selectMapSource(sourceId: number) {
const source = mapSources.value.find((item) => item.id === sourceId)
if (source) {
mapSource.value = source
}
}
async function checkAdminSession() { async function checkAdminSession() {
adminChecking.value = true adminChecking.value = true
try { try {
@@ -465,6 +482,7 @@ onMounted(() => {
if (isDetailedPage || isHelpPage) { if (isDetailedPage || isHelpPage) {
return return
} }
loadMapSource()
refresh() refresh()
refreshTimer = window.setInterval(() => refresh(false), 5000) refreshTimer = window.setInterval(() => refresh(false), 5000)
}) })
@@ -496,6 +514,7 @@ onBeforeUnmount(() => {
<a href="/admin/blocking_management" :class="{ active: adminPath === '/admin/blocking_management' }">屏蔽管理</a> <a href="/admin/blocking_management" :class="{ active: adminPath === '/admin/blocking_management' }">屏蔽管理</a>
<a href="/admin/mqtt_forward/" :class="{ active: isMqttForwardAdminPage }">MQTT转发</a> <a href="/admin/mqtt_forward/" :class="{ active: isMqttForwardAdminPage }">MQTT转发</a>
<a href="/admin/bot" :class="{ active: isBotAdminPage }">机器人</a> <a href="/admin/bot" :class="{ active: isBotAdminPage }">机器人</a>
<a href="/admin/map_source" :class="{ active: adminPath === '/admin/map_source' }">地图图源</a>
<a href="/admin/help_edit" :class="{ active: adminPath === '/admin/help_edit' }">帮助编辑</a> <a href="/admin/help_edit" :class="{ active: adminPath === '/admin/help_edit' }">帮助编辑</a>
<a href="/admin/log/login" :class="{ active: adminPath === '/admin/log/login' }">登录日志</a> <a href="/admin/log/login" :class="{ active: adminPath === '/admin/log/login' }">登录日志</a>
<a href="/admin/discard_details" :class="{ active: adminPath === '/admin/discard_details' }">丢弃数据</a> <a href="/admin/discard_details" :class="{ active: adminPath === '/admin/discard_details' }">丢弃数据</a>
@@ -536,6 +555,7 @@ onBeforeUnmount(() => {
<AdminBlockingManagement v-else-if="adminPath === '/admin/blocking_management'" /> <AdminBlockingManagement v-else-if="adminPath === '/admin/blocking_management'" />
<AdminMqttForward v-else-if="isMqttForwardAdminPage" /> <AdminMqttForward v-else-if="isMqttForwardAdminPage" />
<AdminBot v-else-if="isBotAdminPage" /> <AdminBot v-else-if="isBotAdminPage" />
<AdminMapSource v-else-if="adminPath === '/admin/map_source'" />
<AdminHelpEdit v-else-if="adminPath === '/admin/help_edit'" /> <AdminHelpEdit v-else-if="adminPath === '/admin/help_edit'" />
<AdminLoginLogs v-else-if="adminPath === '/admin/log/login'" /> <AdminLoginLogs v-else-if="adminPath === '/admin/log/login'" />
<AdminDiscardDetails v-else-if="adminPath === '/admin/discard_details'" /> <AdminDiscardDetails v-else-if="adminPath === '/admin/discard_details'" />
@@ -574,6 +594,9 @@ onBeforeUnmount(() => {
:is-admin="!!adminUser" :is-admin="!!adminUser"
:auto-fit="false" :auto-fit="false"
:loading="mapReportsLoading" :loading="mapReportsLoading"
:map-source="mapSource"
:map-sources="mapSources"
@map-source-change="selectMapSource"
@bounds-change="handleMapBoundsChange" @bounds-change="handleMapBoundsChange"
@select-node="selectedNodeId = $event" @select-node="selectedNodeId = $event"
@clear-node="selectedNodeId = null" @clear-node="selectedNodeId = null"
+51 -5
View File
@@ -24,6 +24,9 @@ import type {
ListResponse, ListResponse,
MapBoundsQuery, MapBoundsQuery,
MapReport, MapReport,
MapTileSource,
MapTileSourcePayload,
MapTileSourceResponse,
MapViewportResponse, MapViewportResponse,
MQTTForwarder, MQTTForwarder,
MQTTForwarderPayload, MQTTForwarderPayload,
@@ -35,6 +38,8 @@ import type {
NodeBlockingRulePayload, NodeBlockingRulePayload,
NodeInfo, NodeInfo,
PositionRecord, PositionRecord,
PublicMapTileSourceResponse,
PublicMapTileSourcesResponse,
TelemetryRecord, TelemetryRecord,
TextMessage, TextMessage,
} from './types' } from './types'
@@ -56,10 +61,23 @@ async function requestJSON<T>(path: string, init?: RequestInit): Promise<T> {
return response.json() as Promise<T> return response.json() as Promise<T>
} }
function listPath(path: string, limit: number, offset: number, nodeId = ''): string { type ListQueryOptions = {
nodeId?: string
since?: string
until?: string
}
function listPath(path: string, limit: number, offset: number, nodeIdOrOptions: string | ListQueryOptions = ''): string {
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) }) const params = new URLSearchParams({ limit: String(limit), offset: String(offset) })
if (nodeId) { const options = typeof nodeIdOrOptions === 'string' ? { nodeId: nodeIdOrOptions } : nodeIdOrOptions
params.set('node_id', nodeId) if (options.nodeId) {
params.set('node_id', options.nodeId)
}
if (options.since) {
params.set('since', options.since)
}
if (options.until) {
params.set('until', options.until)
} }
return `${path}?${params.toString()}` return `${path}?${params.toString()}`
} }
@@ -131,6 +149,14 @@ export function getMapReportViewport(bounds: MapBoundsQuery, zoom: number, limit
return getJSON<MapViewportResponse>(`/api/map-reports/viewport?${params.toString()}`) return getJSON<MapViewportResponse>(`/api/map-reports/viewport?${params.toString()}`)
} }
export function getDefaultMapSource(): Promise<PublicMapTileSourceResponse> {
return getJSON<PublicMapTileSourceResponse>('/api/map-source/default')
}
export function getEnabledMapSources(): Promise<PublicMapTileSourcesResponse> {
return getJSON<PublicMapTileSourcesResponse>('/api/map-source/enabled')
}
export function getTextMessages(limit = 100, offset = 0, nodeId = ''): Promise<ListResponse<TextMessage>> { export function getTextMessages(limit = 100, offset = 0, nodeId = ''): Promise<ListResponse<TextMessage>> {
return getJSON<ListResponse<TextMessage>>(listPath('/api/text-messages', limit, offset, nodeId)) return getJSON<ListResponse<TextMessage>>(listPath('/api/text-messages', limit, offset, nodeId))
} }
@@ -143,8 +169,8 @@ export function deleteNode(nodeId: string): Promise<{ status: string }> {
return deleteJSON<{ status: string }>(`/api/admin/nodes/${encodeURIComponent(nodeId)}`) return deleteJSON<{ status: string }>(`/api/admin/nodes/${encodeURIComponent(nodeId)}`)
} }
export function getPositions(limit = 500, offset = 0, nodeId = ''): Promise<ListResponse<PositionRecord>> { export function getPositions(limit = 500, offset = 0, nodeIdOrOptions: string | ListQueryOptions = ''): Promise<ListResponse<PositionRecord>> {
return getJSON<ListResponse<PositionRecord>>(listPath('/api/positions', limit, offset, nodeId)) return getJSON<ListResponse<PositionRecord>>(listPath('/api/positions', limit, offset, nodeIdOrOptions))
} }
export function getDiscardDetails(limit = 100, offset = 0): Promise<ListResponse<DiscardDetails>> { export function getDiscardDetails(limit = 100, offset = 0): Promise<ListResponse<DiscardDetails>> {
@@ -207,6 +233,26 @@ export function getAdminLoginLogs(limit = 100, offset = 0): Promise<AdminLoginLo
return getJSON<AdminLoginLogsResponse>(`/api/admin/log/login?limit=${limit}&offset=${offset}`) return getJSON<AdminLoginLogsResponse>(`/api/admin/log/login?limit=${limit}&offset=${offset}`)
} }
export function getAdminMapSources(limit = 100, offset = 0): Promise<ListResponse<MapTileSource>> {
return getJSON<ListResponse<MapTileSource>>(listPath('/api/admin/map-source', limit, offset))
}
export function createAdminMapSource(payload: MapTileSourcePayload): Promise<MapTileSourceResponse> {
return postJSON<MapTileSourceResponse>('/api/admin/map-source', payload)
}
export function updateAdminMapSource(id: number, payload: MapTileSourcePayload): Promise<MapTileSourceResponse> {
return putJSON<MapTileSourceResponse>(`/api/admin/map-source/${id}`, payload)
}
export function deleteAdminMapSource(id: number): Promise<{ status: string }> {
return deleteJSON<{ status: string }>(`/api/admin/map-source/${id}`)
}
export function setDefaultAdminMapSource(id: number): Promise<MapTileSourceResponse> {
return postJSON<MapTileSourceResponse>(`/api/admin/map-source/${id}/default`)
}
export function getNodeBlockingRules(limit = 100, offset = 0): Promise<ListResponse<NodeBlockingRule>> { export function getNodeBlockingRules(limit = 100, offset = 0): Promise<ListResponse<NodeBlockingRule>> {
return getJSON<ListResponse<NodeBlockingRule>>(listPath('/api/admin/blocking/nodes', limit, offset)) return getJSON<ListResponse<NodeBlockingRule>>(listPath('/api/admin/blocking/nodes', limit, offset))
} }
@@ -418,7 +418,7 @@ onMounted(() => {
<h2>屏蔽管理</h2> <h2>屏蔽管理</h2>
</div> </div>
</div> </div>
<p class="empty">管理节点IP/CIDR违禁词三类屏蔽规则当前页面只维护规则不改变 MQTT 转发行为</p> <p class="empty">管理节点IP/CIDR违禁词三类屏蔽规则</p>
</div> </div>
<div class="panel admin-status-panel"> <div class="panel admin-status-panel">
@@ -190,10 +190,8 @@ onBeforeUnmount(() => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
border: 1px solid rgba(37, 99, 235, 0.14); border: 1px solid var(--color-border);
background: background: linear-gradient(135deg, var(--color-surface) 0%, var(--color-surface-soft) 100%);
radial-gradient(circle at top right, rgba(59, 130, 246, 0.16), transparent 32%),
linear-gradient(135deg, #ffffff 0%, #f8fbff 52%, #eef6ff 100%);
} }
.control-header { .control-header {
@@ -210,19 +208,19 @@ onBeforeUnmount(() => {
.control-badge { .control-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
border: 1px solid #cbd5e1; border: 1px solid var(--color-border);
border-radius: 999px; border-radius: 999px;
padding: 6px 12px; padding: 6px 12px;
color: #475569; color: var(--color-muted);
font-size: 12px; font-size: 12px;
font-weight: 800; font-weight: 800;
background: rgba(255, 255, 255, 0.8); background: color-mix(in srgb, var(--color-surface) 84%, transparent);
} }
.control-badge.active { .control-badge.active {
border-color: rgba(22, 163, 74, 0.32); border-color: color-mix(in srgb, var(--color-success) 36%, white);
color: #15803d; color: color-mix(in srgb, var(--color-success) 72%, var(--color-heading));
background: #dcfce7; background: var(--color-success-soft);
} }
.control-body { .control-body {
@@ -235,10 +233,10 @@ onBeforeUnmount(() => {
.control-copy, .control-copy,
.switch-card { .switch-card {
border: 1px solid rgba(203, 213, 225, 0.78); border: 1px solid var(--color-border);
border-radius: 18px; border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.86); background: color-mix(in srgb, var(--color-surface) 90%, transparent);
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.06); box-shadow: var(--shadow-sm);
} }
.control-copy { .control-copy {
@@ -247,13 +245,13 @@ onBeforeUnmount(() => {
.control-copy h3 { .control-copy h3 {
margin: 0 0 0.45rem; margin: 0 0 0.45rem;
color: #0f172a; color: var(--color-heading);
font-size: 18px; font-size: 18px;
} }
.control-copy p { .control-copy p {
margin: 0; margin: 0;
color: #64748b; color: var(--color-muted);
line-height: 1.7; line-height: 1.7;
} }
@@ -269,20 +267,20 @@ onBeforeUnmount(() => {
gap: 1rem; gap: 1rem;
min-height: 108px; min-height: 108px;
padding: 1rem; padding: 1rem;
color: #334155; color: var(--color-text);
cursor: pointer; cursor: pointer;
transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease; transition: transform 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease, background-color 0.16s ease;
} }
.switch-card:hover { .switch-card:hover {
transform: translateY(-1px); transform: translateY(-1px);
border-color: rgba(37, 99, 235, 0.35); border-color: var(--color-primary);
box-shadow: 0 18px 44px rgba(15, 23, 42, 0.09); box-shadow: var(--shadow-md);
} }
.switch-card.enabled { .switch-card.enabled {
border-color: rgba(22, 163, 74, 0.35); border-color: color-mix(in srgb, var(--color-success) 42%, white);
background: linear-gradient(135deg, #ffffff 0%, #f0fdf4 100%); background: var(--color-success-soft);
} }
.switch-card.saving { .switch-card.saving {
@@ -303,12 +301,12 @@ onBeforeUnmount(() => {
} }
.switch-text strong { .switch-text strong {
color: #0f172a; color: var(--color-heading);
font-size: 15px; font-size: 15px;
} }
.switch-text small { .switch-text small {
color: #64748b; color: var(--color-muted);
font-size: 12px; font-size: 12px;
line-height: 1.45; line-height: 1.45;
} }
@@ -319,9 +317,9 @@ onBeforeUnmount(() => {
width: 54px; width: 54px;
height: 30px; height: 30px;
border-radius: 999px; border-radius: 999px;
background: #cbd5e1; background: var(--color-border-strong);
box-shadow: inset 0 2px 4px rgba(15, 23, 42, 0.14); box-shadow: inset 0 2px 4px rgba(47, 52, 50, 0.12);
transition: background 0.15s ease; transition: background-color 0.16s ease;
} }
.switch-toggle::after { .switch-toggle::after {
@@ -333,12 +331,12 @@ onBeforeUnmount(() => {
height: 22px; height: 22px;
border-radius: 999px; border-radius: 999px;
background: #fff; background: #fff;
box-shadow: 0 4px 10px rgba(15, 23, 42, 0.24); box-shadow: 0 4px 10px rgba(47, 52, 50, 0.18);
transition: transform 0.15s ease; transition: transform 0.16s ease;
} }
.switch-card.enabled .switch-toggle { .switch-card.enabled .switch-toggle {
background: linear-gradient(135deg, #16a34a, #22c55e); background: var(--color-success);
} }
.switch-card.enabled .switch-toggle::after { .switch-card.enabled .switch-toggle::after {
@@ -0,0 +1,549 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { createAdminMapSource, deleteAdminMapSource, getAdminMapSources, setDefaultAdminMapSource, updateAdminMapSource } from '../api'
import type { MapTileSource, MapTileSourcePayload } from '../types'
const items = ref<MapTileSource[]>([])
const loading = ref(false)
const error = ref('')
const message = ref('')
const page = ref(1)
const pageSize = 25
const newSource = ref<MapTileSourcePayload>({
name: '',
url_template: 'https://tile.openstreetmap.jp/{z}/{x}/{y}.png',
attribution: '&copy; OpenStreetMap contributors',
max_zoom: 19,
enabled: true,
is_default: false,
proxy_enabled: true,
})
const canPrev = () => page.value > 1
const canNext = () => items.value.length === pageSize
const enabledCount = computed(() => items.value.filter((item) => item.enabled).length)
const defaultSource = computed(() => items.value.find((item) => item.is_default) ?? null)
function editableCopy(item: MapTileSource): MapTileSourcePayload {
return {
name: item.name,
url_template: item.url_template,
attribution: item.attribution,
max_zoom: item.max_zoom,
enabled: item.enabled,
is_default: item.is_default,
proxy_enabled: item.proxy_enabled,
}
}
const drafts = ref<Record<number, MapTileSourcePayload>>({})
function resetNewSource() {
newSource.value = {
name: '',
url_template: '',
attribution: '&copy; OpenStreetMap contributors',
max_zoom: 19,
enabled: true,
is_default: false,
proxy_enabled: true,
}
}
function validatePayload(payload: MapTileSourcePayload): string {
if (!payload.name.trim()) {
return '请输入图源名称'
}
const url = payload.url_template.trim()
if (!url) {
return '请输入图源 URL 模板'
}
for (const placeholder of ['{z}', '{x}', '{y}']) {
if (!url.includes(placeholder)) {
return `URL 模板必须包含 ${placeholder}`
}
}
if (!Number.isInteger(payload.max_zoom) || payload.max_zoom < 1 || payload.max_zoom > 30) {
return '最大缩放级别必须是 1 到 30 之间的整数'
}
if (payload.is_default && !payload.enabled) {
return '默认图源必须启用'
}
return ''
}
async function refreshItems() {
loading.value = true
error.value = ''
try {
const response = await getAdminMapSources(pageSize, (page.value - 1) * pageSize)
items.value = response.items
drafts.value = Object.fromEntries(response.items.map((item) => [item.id, editableCopy(item)]))
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
} finally {
loading.value = false
}
}
function changePage(nextPage: number) {
page.value = Math.max(1, nextPage)
refreshItems()
}
async function createSource() {
const validation = validatePayload(newSource.value)
if (validation) {
error.value = validation
return
}
loading.value = true
error.value = ''
message.value = ''
try {
await createAdminMapSource({ ...newSource.value })
message.value = '图源已添加'
resetNewSource()
page.value = 1
await refreshItems()
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
} finally {
loading.value = false
}
}
async function saveSource(item: MapTileSource) {
const draft = drafts.value[item.id]
if (!draft) {
return
}
const validation = validatePayload(draft)
if (validation) {
error.value = validation
return
}
loading.value = true
error.value = ''
message.value = ''
try {
await updateAdminMapSource(item.id, { ...draft })
message.value = '图源已保存'
await refreshItems()
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
} finally {
loading.value = false
}
}
async function setDefaultSource(item: MapTileSource) {
loading.value = true
error.value = ''
message.value = ''
try {
await setDefaultAdminMapSource(item.id)
message.value = '默认图源已更新'
await refreshItems()
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
} finally {
loading.value = false
}
}
async function removeSource(item: MapTileSource) {
if (!window.confirm(`确定要删除图源「${item.name}」吗?`)) {
return
}
loading.value = true
error.value = ''
message.value = ''
try {
await deleteAdminMapSource(item.id)
message.value = '图源已删除'
if (items.value.length === 1 && page.value > 1) {
page.value -= 1
}
await refreshItems()
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
} finally {
loading.value = false
}
}
onMounted(refreshItems)
</script>
<template>
<section class="map-source-page">
<div class="map-source-hero panel">
<div class="hero-copy">
<p class="eyebrow">Map source</p>
<h2>地图图源</h2>
<p class="muted">集中维护 Leaflet 瓦片图源URL 模板必须包含 <code>{z}</code><code>{x}</code><code>{y}</code></p>
</div>
<div class="hero-stats">
<div>
<strong>{{ items.length }}</strong>
<span>当前图源</span>
</div>
<div>
<strong>{{ enabledCount }}</strong>
<span>已启用</span>
</div>
<div>
<strong>{{ defaultSource?.name || '-' }}</strong>
<span>默认图源</span>
</div>
</div>
</div>
<div class="panel map-source-create-panel">
<div class="panel-heading compact">
<div>
<p class="eyebrow">Create</p>
<h2>新增图源</h2>
</div>
<button class="admin-button ghost" type="button" @click="refreshItems" :disabled="loading">{{ loading ? '刷新中...' : '刷新数据' }}</button>
</div>
<form class="map-source-form" @submit.prevent="createSource">
<label class="field">名称<input v-model="newSource.name" placeholder="OpenStreetMap Japan" /></label>
<label class="field url-field">URL 模板<input v-model="newSource.url_template" placeholder="https://tile.example.com/{z}/{x}/{y}.png" /></label>
<label class="field attribution-field">Attribution<input v-model="newSource.attribution" placeholder="&copy; OpenStreetMap contributors" /></label>
<label class="field zoom-field">最大缩放<input v-model.number="newSource.max_zoom" type="number" min="1" max="30" /></label>
<label class="switch-card"><input v-model="newSource.enabled" type="checkbox" /> <span>启用</span></label>
<label class="switch-card"><input v-model="newSource.is_default" type="checkbox" /> <span>设为默认</span></label>
<label class="switch-card"><input v-model="newSource.proxy_enabled" type="checkbox" /> <span>是否代理</span></label>
<div class="form-actions">
<button class="admin-button" type="submit" :disabled="loading">添加图源</button>
</div>
</form>
<p class="template-tip">示例<code>https://tile.openstreetmap.jp/{z}/{x}/{y}.png</code></p>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="message" class="success">{{ message }}</p>
</div>
<div class="panel map-source-list-panel">
<div class="panel-heading">
<div>
<p class="eyebrow">Sources</p>
<h2>图源列表</h2>
</div>
<span class="badge">{{ items.length }} </span>
</div>
<div v-if="items.length === 0" class="empty-state">暂无地图图源先在上方添加一个配置</div>
<article v-for="item in items" :key="item.id" class="map-source-card" :class="{ default: item.is_default, disabled: !item.enabled }">
<header class="source-card-title">
<div>
<div class="source-title-row">
<h3>{{ item.name }}</h3>
<span v-if="item.is_default" class="status-pill ok">默认</span>
<span v-else-if="item.enabled" class="status-pill">启用</span>
<span v-else class="status-pill disabled">停用</span>
</div>
<p class="source-url">{{ item.url_template }}</p>
</div>
<button v-if="!item.is_default" class="admin-button ghost" :disabled="loading || !item.enabled" @click="setDefaultSource(item)">设为默认</button>
</header>
<div v-if="drafts[item.id]" class="source-edit-grid">
<label class="field">名称<input v-model="drafts[item.id].name" /></label>
<label class="field url-field">URL 模板<input v-model="drafts[item.id].url_template" /></label>
<label class="field attribution-field">Attribution<input v-model="drafts[item.id].attribution" /></label>
<label class="field zoom-field">最大缩放<input v-model.number="drafts[item.id].max_zoom" type="number" min="1" max="30" /></label>
<label class="switch-card"><input v-model="drafts[item.id].enabled" type="checkbox" :disabled="item.is_default" /> <span>启用图源</span></label>
<label class="switch-card"><input v-model="drafts[item.id].proxy_enabled" type="checkbox" /> <span>是否代理</span></label>
</div>
<div class="source-meta">
<div><span>ID</span><strong>{{ item.id }}</strong></div>
<div><span>最大缩放</span><strong>{{ item.max_zoom }}</strong></div>
<div><span>Attribution</span><strong>{{ item.attribution || '-' }}</strong></div>
</div>
<div class="actions">
<button class="admin-button" :disabled="loading" @click="saveSource(item)">保存</button>
<button class="admin-button danger" :disabled="loading || item.is_default" @click="removeSource(item)">删除</button>
</div>
</article>
<div class="pagination">
<button :disabled="loading || !canPrev()" @click="changePage(page - 1)">上一页</button>
<span> {{ page }} </span>
<span>每页 {{ pageSize }} </span>
<button :disabled="loading || !canNext()" @click="changePage(page + 1)">下一页</button>
</div>
</div>
</section>
</template>
<style scoped>
.map-source-page {
display: grid;
gap: 12px;
}
.map-source-page :deep(input) {
width: 100%;
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-sm);
padding: 9px 11px;
color: var(--color-heading);
font: inherit;
background: var(--color-surface);
outline: none;
transition: border-color 0.16s ease, box-shadow 0.16s ease;
}
.map-source-page :deep(input:focus) {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 20%, transparent);
}
.map-source-page :deep(input[type='checkbox']) {
width: auto;
}
.map-source-hero,
.map-source-create-panel,
.map-source-list-panel {
padding: 18px;
}
.map-source-hero {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
background: linear-gradient(135deg, var(--color-surface) 0%, var(--color-surface-soft) 100%);
}
.hero-copy {
min-width: 260px;
}
.hero-stats {
display: grid;
grid-template-columns: repeat(3, minmax(120px, 1fr));
gap: 0.75rem;
}
.hero-stats div {
min-width: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 12px 16px;
text-align: center;
background: color-mix(in srgb, var(--color-surface) 84%, transparent);
}
.hero-stats strong {
display: block;
overflow: hidden;
color: color-mix(in srgb, var(--color-primary) 72%, var(--color-heading));
font-size: 22px;
text-overflow: ellipsis;
white-space: nowrap;
}
.hero-stats span,
.source-meta span,
.template-tip,
.source-url {
color: var(--color-muted);
font-size: 13px;
}
.panel-heading,
.source-card-title,
.source-title-row,
.actions {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
}
.panel-heading,
.source-card-title {
justify-content: space-between;
}
.panel-heading.compact {
margin-bottom: 1rem;
}
.map-source-form,
.source-edit-grid {
display: grid;
grid-template-columns: minmax(180px, 1fr) minmax(320px, 2fr) minmax(220px, 1.4fr) minmax(100px, 0.5fr) auto auto;
gap: 0.75rem;
align-items: end;
}
.field {
display: grid;
gap: 6px;
color: var(--color-text);
font-size: 13px;
font-weight: 700;
}
.url-field {
min-width: 320px;
}
.zoom-field {
min-width: 96px;
}
.form-actions {
display: flex;
justify-content: flex-end;
}
.switch-card {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 39px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 9px 11px;
color: var(--color-text);
font-size: 13px;
font-weight: 700;
background: var(--color-surface-soft);
}
.template-tip {
margin: 12px 0 0;
}
.map-source-card {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 1rem;
margin-top: 1rem;
background: var(--color-surface);
box-shadow: inset 4px 0 0 var(--color-primary-soft);
}
.map-source-card.default {
box-shadow: inset 4px 0 0 var(--color-success);
}
.map-source-card.disabled {
background: var(--color-surface-soft);
box-shadow: inset 4px 0 0 var(--color-border-strong);
}
.source-title-row h3 {
margin: 0;
color: var(--color-heading);
font-size: 18px;
}
.source-url {
max-width: 860px;
margin: 6px 0 0;
overflow-wrap: anywhere;
font-family: var(--font-mono);
}
.status-pill {
border-radius: 999px;
padding: 7px 12px;
color: color-mix(in srgb, var(--color-primary) 72%, var(--color-heading));
font-size: 13px;
font-weight: 800;
background: var(--color-primary-soft);
}
.status-pill.ok {
color: color-mix(in srgb, var(--color-success) 72%, var(--color-heading));
background: var(--color-success-soft);
}
.status-pill.disabled {
color: var(--color-muted);
background: var(--color-surface-muted);
}
.source-edit-grid {
grid-template-columns: minmax(180px, 1fr) minmax(320px, 2fr) minmax(220px, 1.4fr) minmax(100px, 0.5fr) auto;
margin-top: 1rem;
}
.source-meta {
display: grid;
grid-template-columns: minmax(70px, 0.4fr) minmax(100px, 0.5fr) minmax(220px, 2fr);
gap: 0.75rem;
margin: 1rem 0;
}
.source-meta div {
min-width: 0;
border-radius: var(--radius-md);
padding: 10px 12px;
background: var(--color-surface-soft);
}
.source-meta strong {
display: block;
margin-top: 3px;
overflow-wrap: anywhere;
color: var(--color-heading);
}
.actions {
justify-content: flex-end;
}
.empty-state {
border: 1px dashed var(--color-border-strong);
border-radius: var(--radius-md);
padding: 24px;
color: var(--color-muted);
text-align: center;
background: var(--color-surface-soft);
}
@media (max-width: 1100px) {
.map-source-hero,
.panel-heading,
.source-card-title {
align-items: stretch;
flex-direction: column;
}
.hero-stats,
.map-source-form,
.source-edit-grid,
.source-meta {
grid-template-columns: 1fr 1fr;
}
.url-field,
.attribution-field {
grid-column: 1 / -1;
min-width: 0;
}
}
@media (max-width: 700px) {
.hero-stats,
.map-source-form,
.source-edit-grid,
.source-meta {
grid-template-columns: 1fr;
}
}
</style>
@@ -582,21 +582,20 @@ onBeforeUnmount(() => {
.mqtt-forward-page :deep(input), .mqtt-forward-page :deep(input),
.mqtt-forward-page :deep(select) { .mqtt-forward-page :deep(select) {
width: 100%; width: 100%;
box-sizing: border-box; border: 1px solid var(--color-border-strong);
border: 1px solid #cbd5e1; border-radius: var(--radius-sm);
border-radius: 10px;
padding: 9px 11px; padding: 9px 11px;
color: #0f172a; color: var(--color-heading);
font: inherit; font: inherit;
background: #fff; background: var(--color-surface);
outline: none; outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease; transition: border-color 0.16s ease, box-shadow 0.16s ease;
} }
.mqtt-forward-page :deep(input:focus), .mqtt-forward-page :deep(input:focus),
.mqtt-forward-page :deep(select:focus) { .mqtt-forward-page :deep(select:focus) {
border-color: #2563eb; border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.14); box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 20%, transparent);
} }
.mqtt-hero, .mqtt-hero,
@@ -610,7 +609,7 @@ onBeforeUnmount(() => {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 1rem; gap: 1rem;
background: linear-gradient(135deg, #ffffff 0%, #eff6ff 100%); background: linear-gradient(135deg, var(--color-surface) 0%, var(--color-surface-soft) 100%);
} }
.mqtt-hero h2 { .mqtt-hero h2 {
@@ -624,23 +623,23 @@ onBeforeUnmount(() => {
} }
.hero-stats div { .hero-stats div {
border: 1px solid #dbeafe; border: 1px solid var(--color-border);
border-radius: 16px; border-radius: var(--radius-md);
padding: 12px 16px; padding: 12px 16px;
text-align: center; text-align: center;
background: rgba(255, 255, 255, 0.78); background: color-mix(in srgb, var(--color-surface) 84%, transparent);
} }
.hero-stats strong { .hero-stats strong {
display: block; display: block;
color: #1d4ed8; color: color-mix(in srgb, var(--color-primary) 72%, var(--color-heading));
font-size: 24px; font-size: 24px;
} }
.hero-stats span, .hero-stats span,
.endpoint-line, .endpoint-line,
.runtime-grid span { .runtime-grid span {
color: #64748b; color: var(--color-muted);
font-size: 13px; font-size: 13px;
} }
@@ -674,7 +673,7 @@ onBeforeUnmount(() => {
.field { .field {
display: grid; display: grid;
gap: 6px; gap: 6px;
color: #334155; color: var(--color-text);
font-size: 13px; font-size: 13px;
font-weight: 700; font-weight: 700;
} }
@@ -687,9 +686,9 @@ onBeforeUnmount(() => {
.edit-section, .edit-section,
.forwarder-card, .forwarder-card,
.topics-box { .topics-box {
border: 1px solid #dbe4ef; border: 1px solid var(--color-border);
border-radius: 16px; border-radius: var(--radius-md);
background: #fff; background: var(--color-surface);
} }
.broker-card { .broker-card {
@@ -702,16 +701,16 @@ onBeforeUnmount(() => {
.broker-card legend { .broker-card legend {
padding: 0 8px; padding: 0 8px;
color: #334155; color: var(--color-text);
font-weight: 800; font-weight: 800;
} }
.source-card { .source-card {
background: linear-gradient(180deg, #f8fbff 0%, #fff 100%); background: linear-gradient(180deg, var(--color-surface-soft) 0%, var(--color-surface) 100%);
} }
.target-card { .target-card {
background: linear-gradient(180deg, #f8fffb 0%, #fff 100%); background: linear-gradient(180deg, var(--color-success-soft) 0%, var(--color-surface) 100%);
} }
.form-actions { .form-actions {
@@ -726,13 +725,13 @@ onBeforeUnmount(() => {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 8px; gap: 8px;
border: 1px solid #dbe4ef; border: 1px solid var(--color-border);
border-radius: 12px; border-radius: var(--radius-sm);
padding: 9px 11px; padding: 9px 11px;
color: #334155; color: var(--color-text);
font-size: 13px; font-size: 13px;
font-weight: 700; font-weight: 700;
background: #f8fafc; background: var(--color-surface-soft);
} }
.switch-card input, .switch-card input,
@@ -743,34 +742,34 @@ onBeforeUnmount(() => {
.forwarder-card { .forwarder-card {
padding: 1rem; padding: 1rem;
margin-top: 1rem; margin-top: 1rem;
box-shadow: inset 4px 0 0 #dbeafe; box-shadow: inset 4px 0 0 var(--color-primary-soft);
} }
.forwarder-title h3 { .forwarder-title h3 {
color: #0f172a; color: var(--color-heading);
font-size: 18px; font-size: 18px;
} }
.status-pill { .status-pill {
border-radius: 999px; border-radius: 999px;
padding: 7px 12px; padding: 7px 12px;
color: #92400e; color: color-mix(in srgb, var(--color-warning) 72%, var(--color-heading));
background: #fffbeb; background: var(--color-warning-soft);
} }
.status-pill.ok { .status-pill.ok {
color: #166534; color: color-mix(in srgb, var(--color-success) 72%, var(--color-heading));
background: #dcfce7; background: var(--color-success-soft);
} }
.status-pill.warn { .status-pill.warn {
color: #92400e; color: color-mix(in srgb, var(--color-warning) 72%, var(--color-heading));
background: #fef3c7; background: var(--color-warning-soft);
} }
.status-pill.disabled { .status-pill.disabled {
color: #475569; color: var(--color-muted);
background: #e2e8f0; background: var(--color-surface-muted);
} }
.runtime-grid { .runtime-grid {
@@ -781,23 +780,23 @@ onBeforeUnmount(() => {
} }
.runtime-grid div { .runtime-grid div {
border-radius: 12px; border-radius: var(--radius-md);
padding: 10px 12px; padding: 10px 12px;
background: #f8fafc; background: var(--color-surface-soft);
} }
.runtime-grid strong { .runtime-grid strong {
display: block; display: block;
margin-top: 3px; margin-top: 3px;
color: #0f172a; color: var(--color-heading);
} }
.inline-error { .inline-error {
border: 1px solid #fecaca; border: 1px solid color-mix(in srgb, var(--color-danger) 36%, white);
border-radius: 12px; border-radius: var(--radius-md);
padding: 10px 12px; padding: 10px 12px;
color: #b91c1c; color: color-mix(in srgb, var(--color-danger) 74%, var(--color-heading));
background: #fef2f2; background: var(--color-danger-soft);
word-break: break-word; word-break: break-word;
} }
@@ -831,24 +830,10 @@ onBeforeUnmount(() => {
margin-top: 0.75rem; margin-top: 0.75rem;
} }
.admin-button.ghost {
color: #1d4ed8;
border: 1px solid #bfdbfe;
background: #eff6ff;
}
.admin-button.secondary {
background: #475569;
}
.admin-button.danger {
background: #dc2626;
}
.topics-box { .topics-box {
margin-top: 1rem; margin-top: 1rem;
padding: 1rem; padding: 1rem;
background: #f8fafc; background: var(--color-surface-soft);
} }
.topic-row { .topic-row {
@@ -856,25 +841,25 @@ onBeforeUnmount(() => {
grid-template-columns: minmax(180px, 1.6fr) minmax(90px, 0.7fr) minmax(150px, 1fr) repeat(2, minmax(120px, 1fr)) minmax(90px, 0.7fr) minmax(90px, 0.7fr) auto auto; grid-template-columns: minmax(180px, 1.6fr) minmax(90px, 0.7fr) minmax(150px, 1fr) repeat(2, minmax(120px, 1fr)) minmax(90px, 0.7fr) minmax(90px, 0.7fr) auto auto;
gap: 0.5rem; gap: 0.5rem;
align-items: center; align-items: center;
border-top: 1px solid #e2e8f0; border-top: 1px solid var(--color-border);
padding-top: 0.75rem; padding-top: 0.75rem;
margin-top: 0.75rem; margin-top: 0.75rem;
} }
.topic-row.new-topic { .topic-row.new-topic {
border: 1px dashed #93c5fd; border: 1px dashed color-mix(in srgb, var(--color-primary) 54%, white);
border-radius: 14px; border-radius: var(--radius-md);
padding: 0.75rem; padding: 0.75rem;
background: #eff6ff; background: var(--color-primary-soft);
} }
.empty-state { .empty-state {
border: 1px dashed #cbd5e1; border: 1px dashed var(--color-border-strong);
border-radius: 16px; border-radius: var(--radius-md);
padding: 24px; padding: 24px;
color: #64748b; color: var(--color-muted);
text-align: center; text-align: center;
background: #f8fafc; background: var(--color-surface-soft);
} }
.pagination { .pagination {
+23 -11
View File
@@ -26,6 +26,7 @@ const menuX = ref(0)
const menuY = ref(0) const menuY = ref(0)
const topThreshold = 8 const topThreshold = 8
const bottomThreshold = 40 const bottomThreshold = 40
const scrollOverflowAllowance = 1
const groupedMessages = computed<GroupedTextMessage[]>(() => { const groupedMessages = computed<GroupedTextMessage[]>(() => {
const groups = new Map<string, GroupedTextMessage>() const groups = new Map<string, GroupedTextMessage>()
@@ -102,25 +103,29 @@ function handleKeydown(event: KeyboardEvent) {
} }
} }
function handleScroll() { function loadOlderFromCurrentScroll(el: HTMLElement) {
closeMessageMenu()
const el = panelRef.value
if ( if (
!el ||
props.loadingOlder || props.loadingOlder ||
!props.hasMoreMessages || !props.hasMoreMessages ||
props.messages.length === 0 || groupedMessages.value.length === 0 ||
restoreScrollHeight != null restoreScrollHeight != null
) { ) {
return return
} }
if (el.scrollTop <= topThreshold) { restoreScrollHeight = el.scrollHeight
restoreScrollHeight = el.scrollHeight restoreScrollTop = el.scrollTop
restoreScrollTop = el.scrollTop restoreMessageCount = groupedMessages.value.length
restoreMessageCount = props.messages.length emit('load-older')
emit('load-older') }
function handleScroll() {
closeMessageMenu()
const el = panelRef.value
if (!el || el.scrollTop > topThreshold) {
return
} }
loadOlderFromCurrentScroll(el)
} }
onBeforeUpdate(() => { onBeforeUpdate(() => {
@@ -138,6 +143,9 @@ onMounted(async () => {
if (el) { if (el) {
el.scrollTop = el.scrollHeight el.scrollTop = el.scrollHeight
didInitialScroll = true didInitialScroll = true
if (el.scrollHeight <= el.clientHeight + scrollOverflowAllowance) {
loadOlderFromCurrentScroll(el)
}
} }
}) })
@@ -153,7 +161,7 @@ onUpdated(() => {
} }
if (restoreScrollHeight != null) { if (restoreScrollHeight != null) {
if (props.messages.length > restoreMessageCount) { if (groupedMessages.value.length > restoreMessageCount) {
el.scrollTop = el.scrollHeight - restoreScrollHeight + restoreScrollTop el.scrollTop = el.scrollHeight - restoreScrollHeight + restoreScrollTop
clearRestoreState() clearRestoreState()
return return
@@ -167,6 +175,10 @@ onUpdated(() => {
el.scrollTop = el.scrollHeight el.scrollTop = el.scrollHeight
didInitialScroll = true didInitialScroll = true
} }
if (el.scrollHeight <= el.clientHeight + scrollOverflowAllowance) {
loadOlderFromCurrentScroll(el)
}
}) })
</script> </script>
+227 -18
View File
@@ -2,7 +2,8 @@
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import L from 'leaflet' import L from 'leaflet'
import 'leaflet/dist/leaflet.css' import 'leaflet/dist/leaflet.css'
import type { MapBoundsChangePayload, MapClusterNode, MapNode, MapRenderable } from '../types' import { fallbackMapSource } from '../mapSource'
import type { MapBoundsChangePayload, MapClusterNode, MapNode, MapRenderable, PublicMapTileSource } from '../types'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
items: MapRenderable[] items: MapRenderable[]
@@ -10,9 +11,13 @@ const props = withDefaults(defineProps<{
isAdmin: boolean isAdmin: boolean
autoFit?: boolean autoFit?: boolean
loading?: boolean loading?: boolean
mapSource?: PublicMapTileSource
mapSources?: PublicMapTileSource[]
}>(), { }>(), {
autoFit: true, autoFit: true,
loading: false, loading: false,
mapSource: () => fallbackMapSource,
mapSources: () => [fallbackMapSource],
}) })
const emit = defineEmits<{ const emit = defineEmits<{
@@ -21,6 +26,7 @@ const emit = defineEmits<{
'delete-node': [nodeId: string] 'delete-node': [nodeId: string]
'delete-and-block-node': [payload: { nodeId: string; nodeNum: number | null }] 'delete-and-block-node': [payload: { nodeId: string; nodeNum: number | null }]
'bounds-change': [payload: MapBoundsChangePayload] 'bounds-change': [payload: MapBoundsChangePayload]
'map-source-change': [sourceId: number]
}>() }>()
const mapEl = ref<HTMLElement | null>(null) const mapEl = ref<HTMLElement | null>(null)
@@ -29,8 +35,11 @@ const menuX = ref(0)
const menuY = ref(0) const menuY = ref(0)
const lastRaisedNodeId = ref<string | null>(null) const lastRaisedNodeId = ref<string | null>(null)
let map: L.Map | null = null let map: L.Map | null = null
let tileLayer: L.TileLayer | null = null
let markerLayer: L.LayerGroup | null = null let markerLayer: L.LayerGroup | null = null
const markersByKey = new Map<string, L.Marker>() const markersByKey = new Map<string, L.Marker>()
const overlapShuffleOrders = new Map<string, string[]>()
const shuffledSelectedNodeIds = new Set<string>()
let hasFitBounds = false let hasFitBounds = false
const minMapZoom = 3 const minMapZoom = 3
@@ -55,15 +64,12 @@ onMounted(async () => {
maxBoundsViscosity: 1.0, maxBoundsViscosity: 1.0,
worldCopyJump: false, worldCopyJump: false,
}).setView(defaultMapCenter, defaultMapZoom) }).setView(defaultMapCenter, defaultMapZoom)
L.tileLayer('https://tile.openstreetmap.jp/{z}/{x}/{y}.png', { map.attributionControl.setPrefix(false)
minZoom: minMapZoom, applyTileLayer()
maxZoom: 19,
noWrap: true,
bounds: worldBounds,
attribution: '&copy; OpenStreetMap contributors',
}).addTo(map)
map.on('click', () => { map.on('click', () => {
closeNodeMenu() closeNodeMenu()
overlapShuffleOrders.clear()
shuffledSelectedNodeIds.clear()
emit('clear-node') emit('clear-node')
}) })
map.on('moveend', emitBoundsChange) map.on('moveend', emitBoundsChange)
@@ -77,8 +83,11 @@ onBeforeUnmount(() => {
window.removeEventListener('keydown', handleKeydown) window.removeEventListener('keydown', handleKeydown)
map?.remove() map?.remove()
map = null map = null
tileLayer = null
markerLayer = null markerLayer = null
markersByKey.clear() markersByKey.clear()
overlapShuffleOrders.clear()
shuffledSelectedNodeIds.clear()
}) })
watch( watch(
@@ -87,6 +96,32 @@ watch(
{ deep: true }, { deep: true },
) )
watch(
() => props.mapSource,
() => applyTileLayer(),
{ deep: true },
)
function selectMapSource(sourceId: number) {
emit('map-source-change', sourceId)
}
function applyTileLayer() {
if (!map) {
return
}
if (tileLayer) {
tileLayer.remove()
}
tileLayer = L.tileLayer(props.mapSource.url_template, {
minZoom: minMapZoom,
maxZoom: props.mapSource.max_zoom || fallbackMapSource.max_zoom,
noWrap: true,
bounds: worldBounds,
attribution: props.mapSource.attribution || fallbackMapSource.attribution,
}).addTo(map)
}
function closeNodeMenu() { function closeNodeMenu() {
menuNode.value = null menuNode.value = null
} }
@@ -149,6 +184,7 @@ function renderMarkers(forceFit: boolean) {
} }
const bounds = L.latLngBounds([]) const bounds = L.latLngBounds([])
const visibleMarkerKeys = new Set<string>() const visibleMarkerKeys = new Set<string>()
const overlapGroups = buildOverlapGroups(props.items)
for (const item of props.items) { for (const item of props.items) {
const markerKey = mapMarkerKey(item) const markerKey = mapMarkerKey(item)
@@ -166,8 +202,14 @@ function renderMarkers(forceFit: boolean) {
} }
const node = item const node = item
const selected = node.node_id === props.selectedNodeId const rawSelected = node.node_id === props.selectedNodeId
const shuffledSelected = rawSelected && shuffledSelectedNodeIds.has(node.node_id)
const selected = rawSelected && !shuffledSelected
const overlapGroupKey = nodeOverlapGroupKey(node, overlapGroups)
const overlapGroup = overlapGroupKey ? overlapGroups.get(overlapGroupKey) : undefined
const overlapIndex = overlapGroup ? nodeOverlapIndex(node, overlapGroup) : 0
const raised = selected || node.node_id === lastRaisedNodeId.value const raised = selected || node.node_id === lastRaisedNodeId.value
const zIndexOffset = raised ? 1000 : overlapIndex
const nodeIcon = L.divIcon({ const nodeIcon = L.divIcon({
className: `node-marker${selected ? ' selected' : ''}`, className: `node-marker${selected ? ' selected' : ''}`,
html: `<span style="--node-color: ${nodeColor(node.node_id)}">${escapeHTML(node.label || 'N')}</span>`, html: `<span style="--node-color: ${nodeColor(node.node_id)}">${escapeHTML(node.label || 'N')}</span>`,
@@ -180,7 +222,7 @@ function renderMarkers(forceFit: boolean) {
marker = L.marker([node.latitude, node.longitude], { marker = L.marker([node.latitude, node.longitude], {
icon: nodeIcon, icon: nodeIcon,
title: node.label, title: node.label,
zIndexOffset: raised ? 1000 : 0, zIndexOffset,
}) })
marker.bindPopup(buildNodePopupHTML(node), { maxWidth: 320, className: 'node-detail-popup' }) marker.bindPopup(buildNodePopupHTML(node), { maxWidth: 320, className: 'node-detail-popup' })
marker.addTo(markerLayer) marker.addTo(markerLayer)
@@ -188,7 +230,7 @@ function renderMarkers(forceFit: boolean) {
} else { } else {
marker.setLatLng([node.latitude, node.longitude]) marker.setLatLng([node.latitude, node.longitude])
marker.setIcon(nodeIcon) marker.setIcon(nodeIcon)
marker.setZIndexOffset(raised ? 1000 : 0) marker.setZIndexOffset(zIndexOffset)
marker.options.title = node.label marker.options.title = node.label
marker.getElement()?.setAttribute('title', node.label) marker.getElement()?.setAttribute('title', node.label)
const popup = marker.getPopup() const popup = marker.getPopup()
@@ -203,8 +245,18 @@ function renderMarkers(forceFit: boolean) {
marker.off('contextmenu') marker.off('contextmenu')
marker.on('click', (event) => { marker.on('click', (event) => {
L.DomEvent.stopPropagation(event) L.DomEvent.stopPropagation(event)
lastRaisedNodeId.value = node.node_id
closeNodeMenu() closeNodeMenu()
if (node.node_id === props.selectedNodeId) {
if (moveSelectedNodeBehindOverlap(node, overlapGroups)) {
shuffledSelectedNodeIds.add(node.node_id)
marker?.closePopup()
emit('clear-node')
renderMarkers(false)
}
return
}
shuffledSelectedNodeIds.clear()
lastRaisedNodeId.value = node.node_id
emit('select-node', node.node_id) emit('select-node', node.node_id)
}) })
marker.on('contextmenu', (event) => openNodeMenu(node, event)) marker.on('contextmenu', (event) => openNodeMenu(node, event))
@@ -235,6 +287,126 @@ function mapMarkerKey(item: MapRenderable): string {
return `node:${item.node_id}` return `node:${item.node_id}`
} }
function buildOverlapGroups(items: MapRenderable[]): Map<string, string[]> {
const groups = new Map<string, string[]>()
if (!map) {
return groups
}
const capsules = items
.filter((item): item is MapNode => item.type !== 'cluster')
.map((node) => ({ node, bounds: nodeCapsuleBounds(node) }))
const visited = new Set<string>()
for (const capsule of capsules) {
if (visited.has(capsule.node.node_id)) {
continue
}
const stack = [capsule]
const group: string[] = []
visited.add(capsule.node.node_id)
while (stack.length > 0) {
const current = stack.pop()
if (!current) {
continue
}
group.push(current.node.node_id)
for (const candidate of capsules) {
if (visited.has(candidate.node.node_id)) {
continue
}
if (capsuleBoundsOverlap(current.bounds, candidate.bounds)) {
visited.add(candidate.node.node_id)
stack.push(candidate)
}
}
}
if (group.length >= 2) {
const key = overlapGroupKey(group)
const existingOrder = overlapShuffleOrders.get(key) ?? []
const activeIds = new Set(group)
const ordered = existingOrder.filter((nodeId) => activeIds.has(nodeId))
for (const nodeId of group) {
if (!ordered.includes(nodeId)) {
ordered.push(nodeId)
}
}
overlapShuffleOrders.set(key, ordered)
groups.set(key, ordered)
}
}
for (const key of overlapShuffleOrders.keys()) {
if (!groups.has(key)) {
overlapShuffleOrders.delete(key)
}
}
return groups
}
function nodeOverlapGroupKey(node: MapNode, overlapGroups: Map<string, string[]>): string | null {
for (const [key, nodeIds] of overlapGroups) {
if (nodeIds.includes(node.node_id)) {
return key
}
}
return null
}
function nodeOverlapIndex(node: MapNode, group: string[]): number {
const index = group.indexOf(node.node_id)
return index === -1 ? 0 : index
}
function moveSelectedNodeBehindOverlap(node: MapNode, overlapGroups: Map<string, string[]>): boolean {
const groupKey = nodeOverlapGroupKey(node, overlapGroups)
if (!groupKey) {
return false
}
const group = overlapGroups.get(groupKey)
if (!group || group.length < 2) {
return false
}
const nextOrder = [node.node_id, ...group.filter((nodeId) => nodeId !== node.node_id)]
overlapShuffleOrders.set(groupKey, nextOrder)
lastRaisedNodeId.value = null
return true
}
function overlapGroupKey(nodeIds: string[]): string {
return [...nodeIds].sort().join('|')
}
function nodeCapsuleBounds(node: MapNode): { left: number; right: number; top: number; bottom: number } {
const point = map!.latLngToLayerPoint([node.latitude, node.longitude])
const width = nodeCapsuleWidth(node)
const height = 22
return {
left: point.x - width / 2,
right: point.x + width / 2,
top: point.y - height / 2,
bottom: point.y + height / 2,
}
}
function nodeCapsuleWidth(node: MapNode): number {
const label = node.label || 'N'
return Math.max(34, Math.ceil(label.length * 6 + 10))
}
function capsuleBoundsOverlap(
left: { left: number; right: number; top: number; bottom: number },
right: { left: number; right: number; top: number; bottom: number },
): boolean {
return left.left <= right.right && left.right >= right.left && left.top <= right.bottom && left.bottom >= right.top
}
function buildClusterMarker(cluster: MapClusterNode): L.Marker { function buildClusterMarker(cluster: MapClusterNode): L.Marker {
const size = clusterIconSize(cluster.count) const size = clusterIconSize(cluster.count)
const marker = L.marker([cluster.latitude, cluster.longitude], { const marker = L.marker([cluster.latitude, cluster.longitude], {
@@ -342,15 +514,15 @@ function nodeColor(nodeId: string): string {
} }
const hueRanges = [ const hueRanges = [
[35, 75], [42, 68],
[95, 165], [92, 136],
[185, 250], [188, 218],
[265, 315], [330, 354],
] ]
const range = hueRanges[hash % hueRanges.length] const range = hueRanges[hash % hueRanges.length]
const hue = range[0] + (hash % (range[1] - range[0])) const hue = range[0] + (hash % (range[1] - range[0]))
const saturation = 68 + (hash % 18) const saturation = 24 + (hash % 14)
const lightness = 32 + (hash % 10) const lightness = 42 + (hash % 12)
return `hsl(${hue} ${saturation}% ${lightness}%)` return `hsl(${hue} ${saturation}% ${lightness}%)`
} }
@@ -371,6 +543,43 @@ function escapeHTML(value: string): string {
<template> <template>
<section class="map-panel panel"> <section class="map-panel panel">
<div ref="mapEl" class="map-container"></div> <div ref="mapEl" class="map-container"></div>
<div
class="map-source-control"
@click.stop
@mousedown.stop
@dblclick.stop
@wheel.stop
>
<button class="map-source-icon" type="button" aria-label="切换地图图源">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M12 18.5l-3 -1.5l-6 3v-13l6 -3l6 3l6 -3v7.5" />
<path d="M9 4v13" />
<path d="M15 7v5.5" />
<path d="M21.121 20.121a3 3 0 1 0 -4.242 0c.418 .419 1.125 1.045 2.121 1.879c1.051 -.89 1.759 -1.516 2.121 -1.879" />
<path d="M19 18v.01" />
</svg>
</button>
<div class="map-source-popover">
<div class="map-source-drawer-header">
<span>地图图源</span>
</div>
<div v-if="mapSources.length > 1" class="map-source-options">
<button
v-for="source in mapSources"
:key="source.id"
class="map-source-option"
:class="{ active: source.id === mapSource.id }"
type="button"
@click="selectMapSource(source.id)"
>
<span class="map-source-option-name">{{ source.name }}</span>
<span v-if="source.id === mapSource.id" class="map-source-option-check">当前</span>
</button>
</div>
<span v-else class="map-source-control-pill">{{ mapSource.name }}</span>
</div>
</div>
<!-- <div v-if="loading" class="map-empty">正在加载当前区域坐标...</div> <!-- <div v-if="loading" class="map-empty">正在加载当前区域坐标...</div>
<div v-else-if="items.length === 0" class="map-empty">暂无可显示坐标的节点</div> --> <div v-else-if="items.length === 0" class="map-empty">暂无可显示坐标的节点</div> -->
<div <div
@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { createNodeBlockingRule, deleteNode, deleteTextMessage, getMapReportById, getNodeInfoById, getPositions, getTelemetry, getTextMessages } from '../api' import { createNodeBlockingRule, deleteNode, deleteTextMessage, getMapReportById, getNodeInfoById, getPositions, getTelemetry, getTextMessages } from '../api'
import type { MapReport, NodeInfo, PositionRecord, TelemetryRecord, TextMessage } from '../types' import type { MapReport, NodeInfo, PositionRecord, PublicMapTileSource, TelemetryRecord, TextMessage } from '../types'
import { fallbackMapSource, loadEnabledMapSources } from '../mapSource'
import ConfirmDeleteModal from './ConfirmDeleteModal.vue' import ConfirmDeleteModal from './ConfirmDeleteModal.vue'
import NodeTrajectoryMap from './NodeTrajectoryMap.vue' import NodeTrajectoryMap from './NodeTrajectoryMap.vue'
@@ -15,12 +16,25 @@ const mapReport = ref<MapReport | null>(null)
const messages = ref<TextMessage[]>([]) const messages = ref<TextMessage[]>([])
const positions = ref<PositionRecord[]>([]) const positions = ref<PositionRecord[]>([])
const telemetry = ref<TelemetryRecord[]>([]) const telemetry = ref<TelemetryRecord[]>([])
const mapSources = ref<PublicMapTileSource[]>([fallbackMapSource])
const mapSource = ref<PublicMapTileSource>(fallbackMapSource)
const loading = ref(true) const loading = ref(true)
const chatLoadingOlder = ref(false) const chatLoadingOlder = ref(false)
const chatHasMore = ref(true) const chatHasMore = ref(true)
const telemetryLoading = ref(false)
const trajectoryLoading = ref(false)
const trajectoryError = ref('')
const trajectoryTruncated = ref(false)
const error = ref('') const error = ref('')
const chatPageSize = 20 const chatPageSize = 20
const telemetryPageSize = 25
const trajectoryPageSize = 500
const maxTrajectoryPoints = 5000
const telemetryPage = ref(1)
const trajectoryStartDate = ref(toDateInputValue())
const trajectoryEndDate = ref(toDateInputValue())
const chatHistoryRef = ref<HTMLElement | null>(null) const chatHistoryRef = ref<HTMLElement | null>(null)
const scrollOverflowAllowance = 1
type GroupedTextMessage = TextMessage & { mergedCount: number; mergedMessages: TextMessage[] } type GroupedTextMessage = TextMessage & { mergedCount: number; mergedMessages: TextMessage[] }
type PendingDeleteAction = type PendingDeleteAction =
| { kind: 'delete-message'; message: GroupedTextMessage } | { kind: 'delete-message'; message: GroupedTextMessage }
@@ -94,6 +108,27 @@ function formatTime(value: string): string {
return new Date(value).toLocaleString() return new Date(value).toLocaleString()
} }
function toDateInputValue(date = new Date()): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function localDateRange(startDate: string, endDate: string): { since: string; until: string } | null {
if (!startDate || !endDate) {
trajectoryError.value = '请选择开始日期和结束日期'
return null
}
const safeStartDate = startDate <= endDate ? startDate : endDate
const safeEndDate = startDate <= endDate ? endDate : startDate
trajectoryStartDate.value = safeStartDate
trajectoryEndDate.value = safeEndDate
const since = new Date(`${safeStartDate}T00:00:00.000`)
const until = new Date(`${safeEndDate}T23:59:59.999`)
return { since: since.toISOString(), until: until.toISOString() }
}
function metricEntries(value: string | null): Array<[string, unknown]> { function metricEntries(value: string | null): Array<[string, unknown]> {
if (!value) { if (!value) {
return [] return []
@@ -175,6 +210,79 @@ async function optional<T>(request: Promise<T>): Promise<T | null> {
} }
} }
function canTelemetryPrev(): boolean {
return telemetryPage.value > 1
}
function canTelemetryNext(): boolean {
return telemetry.value.length === telemetryPageSize
}
async function loadTelemetryPage() {
telemetryLoading.value = true
try {
const response = await getTelemetry(telemetryPageSize, (telemetryPage.value - 1) * telemetryPageSize, props.nodeId)
telemetry.value = response.items
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
} finally {
telemetryLoading.value = false
}
}
function changeTelemetryPage(nextPage: number) {
telemetryPage.value = Math.max(1, nextPage)
loadTelemetryPage()
}
async function loadTrajectoryRange() {
const range = localDateRange(trajectoryStartDate.value, trajectoryEndDate.value)
if (!range) {
return
}
trajectoryLoading.value = true
trajectoryError.value = ''
trajectoryTruncated.value = false
positions.value = []
try {
const items: PositionRecord[] = []
for (let offset = 0; offset < maxTrajectoryPoints; offset += trajectoryPageSize) {
const response = await getPositions(trajectoryPageSize, offset, {
nodeId: props.nodeId,
since: range.since,
until: range.until,
})
items.push(...response.items)
if (response.items.length < trajectoryPageSize) {
break
}
if (items.length >= maxTrajectoryPoints) {
trajectoryTruncated.value = true
break
}
}
positions.value = items.slice(0, maxTrajectoryPoints)
} catch (err) {
trajectoryError.value = err instanceof Error ? err.message : String(err)
} finally {
trajectoryLoading.value = false
}
}
function applyRecentTrajectory(days: number) {
const end = new Date()
const start = new Date()
start.setDate(end.getDate() - days + 1)
trajectoryStartDate.value = toDateInputValue(start)
trajectoryEndDate.value = toDateInputValue(end)
loadTrajectoryRange()
}
function applyTodayTrajectory() {
applyRecentTrajectory(1)
}
async function loadInitialMessages() { async function loadInitialMessages() {
const response = await getTextMessages(chatPageSize, 0, props.nodeId) const response = await getTextMessages(chatPageSize, 0, props.nodeId)
messages.value = toChronological(response.items) messages.value = toChronological(response.items)
@@ -183,24 +291,30 @@ async function loadInitialMessages() {
const el = chatHistoryRef.value const el = chatHistoryRef.value
if (el) { if (el) {
el.scrollTop = el.scrollHeight el.scrollTop = el.scrollHeight
await loadMoreUntilScrollable(el)
} }
} }
async function loadOlderMessages() { async function loadOlderMessages() {
const el = chatHistoryRef.value
await loadOlderMessagesFromCurrentScroll(el)
}
async function loadOlderMessagesFromCurrentScroll(el: HTMLElement | null) {
if (chatLoadingOlder.value || !chatHasMore.value) { if (chatLoadingOlder.value || !chatHasMore.value) {
return return
} }
const el = chatHistoryRef.value
const previousScrollHeight = el?.scrollHeight ?? 0 const previousScrollHeight = el?.scrollHeight ?? 0
const previousScrollTop = el?.scrollTop ?? 0 const previousScrollTop = el?.scrollTop ?? 0
const previousGroupedMessageCount = groupedMessages.value.length
chatLoadingOlder.value = true chatLoadingOlder.value = true
try { try {
const response = await getTextMessages(chatPageSize, messages.value.length, props.nodeId) const response = await getTextMessages(chatPageSize, messages.value.length, props.nodeId)
messages.value = mergeMessages(messages.value, toChronological(response.items)) messages.value = mergeMessages(messages.value, toChronological(response.items))
chatHasMore.value = response.items.length === chatPageSize chatHasMore.value = response.items.length === chatPageSize
await nextTick() await nextTick()
if (el) { if (el && groupedMessages.value.length > previousGroupedMessageCount) {
el.scrollTop = el.scrollHeight - previousScrollHeight + previousScrollTop el.scrollTop = el.scrollHeight - previousScrollHeight + previousScrollTop
} }
} catch (err) { } catch (err) {
@@ -210,6 +324,16 @@ async function loadOlderMessages() {
} }
} }
async function loadMoreUntilScrollable(el: HTMLElement) {
while (chatHasMore.value && el.scrollHeight <= el.clientHeight + scrollOverflowAllowance) {
const previousGroupedMessageCount = groupedMessages.value.length
await loadOlderMessagesFromCurrentScroll(el)
if (groupedMessages.value.length <= previousGroupedMessageCount) {
break
}
}
}
function closeMessageMenu() { function closeMessageMenu() {
menuMessage.value = null menuMessage.value = null
} }
@@ -367,21 +491,36 @@ function handleChatScroll() {
loadOlderMessages() loadOlderMessages()
} }
async function loadMapSource() {
const sources = await loadEnabledMapSources()
mapSources.value = sources
mapSource.value = sources[0] ?? fallbackMapSource
}
function selectMapSource(sourceId: number) {
const source = mapSources.value.find((item) => item.id === sourceId)
if (source) {
mapSource.value = source
}
}
async function loadDetails() { async function loadDetails() {
loading.value = true loading.value = true
error.value = '' error.value = ''
trajectoryError.value = ''
telemetryPage.value = 1
try { try {
const [nodeData, reportData, positionData, telemetryData] = await Promise.all([ const [nodeData, reportData] = await Promise.all([
optional(getNodeInfoById(props.nodeId)), optional(getNodeInfoById(props.nodeId)),
optional(getMapReportById(props.nodeId)), optional(getMapReportById(props.nodeId)),
getPositions(500, 0, props.nodeId),
getTelemetry(200, 0, props.nodeId),
]) ])
nodeInfo.value = nodeData nodeInfo.value = nodeData
mapReport.value = reportData mapReport.value = reportData
positions.value = positionData.items await Promise.all([
telemetry.value = telemetryData.items loadTrajectoryRange(),
await loadInitialMessages() loadTelemetryPage(),
loadInitialMessages(),
])
} catch (err) { } catch (err) {
error.value = err instanceof Error ? err.message : String(err) error.value = err instanceof Error ? err.message : String(err)
} finally { } finally {
@@ -392,6 +531,7 @@ async function loadDetails() {
onMounted(() => { onMounted(() => {
window.addEventListener('click', closeMessageMenu) window.addEventListener('click', closeMessageMenu)
window.addEventListener('keydown', handleKeydown) window.addEventListener('keydown', handleKeydown)
loadMapSource()
loadDetails() loadDetails()
}) })
@@ -492,7 +632,28 @@ onBeforeUnmount(() => {
</div> </div>
<span class="badge">{{ positions.length }}</span> <span class="badge">{{ positions.length }}</span>
</div> </div>
<NodeTrajectoryMap :positions="positions" /> <div class="trajectory-toolbar">
<label class="trajectory-date-field">
<span>开始日期</span>
<input v-model="trajectoryStartDate" type="date" :disabled="trajectoryLoading" @change="loadTrajectoryRange" />
</label>
<label class="trajectory-date-field">
<span>结束日期</span>
<input v-model="trajectoryEndDate" type="date" :disabled="trajectoryLoading" @change="loadTrajectoryRange" />
</label>
<button type="button" :disabled="trajectoryLoading" @click="applyTodayTrajectory">今天</button>
<button type="button" :disabled="trajectoryLoading" @click="applyRecentTrajectory(3)">最近三天</button>
<button type="button" :disabled="trajectoryLoading" @click="applyRecentTrajectory(7)">最近七天</button>
</div>
<p v-if="trajectoryError" class="error trajectory-status">{{ trajectoryError }}</p>
<p v-else-if="trajectoryTruncated" class="trajectory-status">轨迹点较多仅显示前 {{ maxTrajectoryPoints }} 请缩小日期范围</p>
<p v-else-if="trajectoryLoading" class="trajectory-status">正在加载轨迹...</p>
<NodeTrajectoryMap
:positions="positions"
:map-source="mapSource"
:map-sources="mapSources"
@map-source-change="selectMapSource"
/>
</div> </div>
</div> </div>
@@ -502,8 +663,9 @@ onBeforeUnmount(() => {
<p class="eyebrow">Telemetry</p> <p class="eyebrow">Telemetry</p>
<h2>遥测数据{{ nodeTitle }}</h2> <h2>遥测数据{{ nodeTitle }}</h2>
</div> </div>
<span class="badge">{{ telemetry.length }}</span> <span class="badge">本页 {{ telemetry.length }}</span>
</div> </div>
<div v-if="telemetryLoading" class="admin-loading">正在加载遥测数据...</div>
<div class="node-table-wrap"> <div class="node-table-wrap">
<table class="node-table"> <table class="node-table">
<thead> <thead>
@@ -530,6 +692,12 @@ onBeforeUnmount(() => {
</table> </table>
<div v-if="telemetry.length === 0" class="empty">暂无遥测数据</div> <div v-if="telemetry.length === 0" class="empty">暂无遥测数据</div>
</div> </div>
<div class="pagination">
<button :disabled="telemetryLoading || !canTelemetryPrev()" @click="changeTelemetryPage(telemetryPage - 1)">上一页</button>
<span> {{ telemetryPage }} </span>
<span>每页 {{ telemetryPageSize }} </span>
<button :disabled="telemetryLoading || !canTelemetryNext()" @click="changeTelemetryPage(telemetryPage + 1)">下一页</button>
</div>
</div> </div>
<ConfirmDeleteModal <ConfirmDeleteModal
@@ -2,16 +2,44 @@
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import L from 'leaflet' import L from 'leaflet'
import 'leaflet/dist/leaflet.css' import 'leaflet/dist/leaflet.css'
import type { PositionRecord } from '../types' import { fallbackMapSource } from '../mapSource'
import type { PositionRecord, PublicMapTileSource } from '../types'
const props = defineProps<{ const props = withDefaults(defineProps<{
positions: PositionRecord[] positions: PositionRecord[]
mapSource?: PublicMapTileSource
mapSources?: PublicMapTileSource[]
}>(), {
mapSource: () => fallbackMapSource,
mapSources: () => [fallbackMapSource],
})
const emit = defineEmits<{
'map-source-change': [sourceId: number]
}>() }>()
const mapEl = ref<HTMLElement | null>(null) const mapEl = ref<HTMLElement | null>(null)
let map: L.Map | null = null let map: L.Map | null = null
let tileLayer: L.TileLayer | null = null
let layer: L.LayerGroup | null = null let layer: L.LayerGroup | null = null
function selectMapSource(sourceId: number) {
emit('map-source-change', sourceId)
}
function applyTileLayer() {
if (!map) {
return
}
if (tileLayer) {
tileLayer.remove()
}
tileLayer = L.tileLayer(props.mapSource.url_template, {
maxZoom: props.mapSource.max_zoom || fallbackMapSource.max_zoom,
attribution: props.mapSource.attribution || fallbackMapSource.attribution,
}).addTo(map)
}
function renderTrajectory() { function renderTrajectory() {
if (!map || !layer) { if (!map || !layer) {
return return
@@ -28,10 +56,10 @@ function renderTrajectory() {
} }
if (points.length > 1) { if (points.length > 1) {
L.polyline(points, { color: '#2563eb', weight: 4, opacity: 0.8 }).addTo(layer) L.polyline(points, { color: '#7d8f9a', weight: 4, opacity: 0.78 }).addTo(layer)
} }
L.circleMarker(points[0], { radius: 6, color: '#16a34a', fillColor: '#22c55e', fillOpacity: 0.9 }).bindPopup('起点').addTo(layer) L.circleMarker(points[0], { radius: 6, color: '#7f9183', fillColor: '#9aaa95', fillOpacity: 0.88 }).bindPopup('起点').addTo(layer)
L.circleMarker(points[points.length - 1], { radius: 6, color: '#dc2626', fillColor: '#ef4444', fillOpacity: 0.9 }).bindPopup('终点').addTo(layer) L.circleMarker(points[points.length - 1], { radius: 6, color: '#b4877f', fillColor: '#c59b93', fillOpacity: 0.88 }).bindPopup('终点').addTo(layer)
map.fitBounds(L.latLngBounds(points), { padding: [24, 24], maxZoom: 14 }) map.fitBounds(L.latLngBounds(points), { padding: [24, 24], maxZoom: 14 })
} }
@@ -49,10 +77,8 @@ onMounted(async () => {
maxBoundsViscosity: 1.0, maxBoundsViscosity: 1.0,
worldCopyJump: false, worldCopyJump: false,
}).setView([0, 0], 2) }).setView([0, 0], 2)
L.tileLayer('https://tile.openstreetmap.jp/{z}/{x}/{y}.png', { map.attributionControl.setPrefix(false)
maxZoom: 19, applyTileLayer()
attribution: '&copy; OpenStreetMap contributors',
}).addTo(map)
layer = L.layerGroup().addTo(map) layer = L.layerGroup().addTo(map)
renderTrajectory() renderTrajectory()
}) })
@@ -60,6 +86,7 @@ onMounted(async () => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
map?.remove() map?.remove()
map = null map = null
tileLayer = null
layer = null layer = null
}) })
@@ -68,8 +95,53 @@ watch(
() => renderTrajectory(), () => renderTrajectory(),
{ deep: true }, { deep: true },
) )
watch(
() => props.mapSource,
() => applyTileLayer(),
{ deep: true },
)
</script> </script>
<template> <template>
<div ref="mapEl" class="trajectory-map"></div> <div class="trajectory-map-shell">
<div ref="mapEl" class="trajectory-map"></div>
<div
class="map-source-control"
@click.stop
@mousedown.stop
@dblclick.stop
@wheel.stop
>
<button class="map-source-icon" type="button" aria-label="切换地图图源">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M12 18.5l-3 -1.5l-6 3v-13l6 -3l6 3l6 -3v7.5" />
<path d="M9 4v13" />
<path d="M15 7v5.5" />
<path d="M21.121 20.121a3 3 0 1 0 -4.242 0c.418 .419 1.125 1.045 2.121 1.879c1.051 -.89 1.759 -1.516 2.121 -1.879" />
<path d="M19 18v.01" />
</svg>
</button>
<div class="map-source-popover">
<div class="map-source-drawer-header">
<span>地图图源</span>
</div>
<div v-if="mapSources.length > 1" class="map-source-options">
<button
v-for="source in mapSources"
:key="source.id"
class="map-source-option"
:class="{ active: source.id === mapSource.id }"
type="button"
@click="selectMapSource(source.id)"
>
<span class="map-source-option-name">{{ source.name }}</span>
<span v-if="source.id === mapSource.id" class="map-source-option-check">当前</span>
</button>
</div>
<span v-else class="map-source-control-pill">{{ mapSource.name }}</span>
</div>
</div>
</div>
</template> </template>
+28
View File
@@ -0,0 +1,28 @@
import { getDefaultMapSource, getEnabledMapSources } from './api'
import type { PublicMapTileSource } from './types'
export const fallbackMapSource: PublicMapTileSource = {
id: 0,
name: 'OpenStreetMap Japan',
url_template: 'https://tile.openstreetmap.jp/{z}/{x}/{y}.png',
attribution: '&copy; OpenStreetMap contributors',
max_zoom: 19,
}
export async function loadDefaultMapSource(): Promise<PublicMapTileSource> {
try {
const response = await getDefaultMapSource()
return response.item
} catch {
return fallbackMapSource
}
}
export async function loadEnabledMapSources(): Promise<PublicMapTileSource[]> {
try {
const response = await getEnabledMapSources()
return response.items.length > 0 ? response.items : [fallbackMapSource]
} catch {
return [fallbackMapSource]
}
}
File diff suppressed because it is too large Load Diff
+38
View File
@@ -76,6 +76,44 @@ export interface MapBoundsChangePayload {
zoom: number zoom: number
} }
export interface PublicMapTileSource {
id: number
name: string
url_template: string
attribution: string
max_zoom: number
}
export interface MapTileSource extends PublicMapTileSource {
enabled: boolean
is_default: boolean
proxy_enabled: boolean
created_at: string
updated_at: string
}
export interface MapTileSourcePayload {
name: string
url_template: string
attribution: string
max_zoom: number
enabled: boolean
is_default: boolean
proxy_enabled: boolean
}
export interface MapTileSourceResponse {
item: MapTileSource
}
export interface PublicMapTileSourceResponse {
item: PublicMapTileSource
}
export interface PublicMapTileSourcesResponse {
items: PublicMapTileSource[]
}
export interface MapViewportPoint extends MapReport { export interface MapViewportPoint extends MapReport {
type: 'point' type: 'point'
} }
+2 -1
View File
@@ -1,9 +1,10 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [vue(), tailwindcss()],
build: { build: {
outDir: "../dist", outDir: "../dist",
assetsDir: "assets", assetsDir: "assets",
+5 -2
View File
@@ -51,13 +51,13 @@ func newRouter(cfg webConfig, store *store, sessions *sessionManager, mqttStatus
r := gin.New() r := gin.New()
r.Use(gin.Logger(), gin.Recovery()) r.Use(gin.Logger(), gin.Recovery())
api := r.Group("/api") api := r.Group("/api")
registerAPIRoutes(api, store) registerAPIRoutes(api, store, cfg.MapTileCacheDir)
registerAdminRoutes(api.Group("/admin"), store, sessions, mqttStatus, blocking, forwarder, settings, botSender) registerAdminRoutes(api.Group("/admin"), store, sessions, mqttStatus, blocking, forwarder, settings, botSender)
registerStaticRoutes(r, cfg.StaticDir) registerStaticRoutes(r, cfg.StaticDir)
return r return r
} }
func registerAPIRoutes(r gin.IRouter, store *store) { func registerAPIRoutes(r gin.IRouter, store *store, mapTileCacheDir string) {
r.GET("/health", func(c *gin.Context) { r.GET("/health", func(c *gin.Context) {
status := gin.H{"status": "ok", "database": "ok"} status := gin.H{"status": "ok", "database": "ok"}
if err := store.Ping(); err != nil { if err := store.Ping(); err != nil {
@@ -72,6 +72,8 @@ func registerAPIRoutes(r gin.IRouter, store *store) {
registerNodeInfoRoutes(r, store, "/nodeinfo") registerNodeInfoRoutes(r, store, "/nodeinfo")
registerNodeInfoRoutes(r, store, "/nodes") registerNodeInfoRoutes(r, store, "/nodes")
registerMapReportRoutes(r, store) registerMapReportRoutes(r, store)
registerMapSourceRoutes(r, store)
registerMapTileProxyRoutes(r, store, mapTileCacheDir)
registerHelpRoutes(r, store) registerHelpRoutes(r, store)
r.GET("/text-messages", func(c *gin.Context) { r.GET("/text-messages", func(c *gin.Context) {
opts, ok := parseListOptions(c) opts, ok := parseListOptions(c)
@@ -186,6 +188,7 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager,
registerAdminBlockingRoutes(protected, store, blocking) registerAdminBlockingRoutes(protected, store, blocking)
registerAdminMQTTForwardRoutes(protected, store, forwarder) registerAdminMQTTForwardRoutes(protected, store, forwarder)
registerAdminRuntimeSettingsRoutes(protected, store, settings) registerAdminRuntimeSettingsRoutes(protected, store, settings)
registerAdminMapSourceRoutes(protected, store)
registerAdminHelpRoutes(protected, store) registerAdminHelpRoutes(protected, store)
registerAdminBotRoutes(protected, store, botSender) registerAdminBotRoutes(protected, store, botSender)
protected.GET("/me", func(c *gin.Context) { protected.GET("/me", func(c *gin.Context) {