Merge branch 'main' of https://git.lmve.net/kevin/meshtastic_mqtt_server
# Conflicts: # meshmap_frontend/src/App.vue # web.go
This commit is contained in:
@@ -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.
|
||||
@@ -1,42 +1,144 @@
|
||||
# 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
|
||||
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
|
||||
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 PSK,Base64 格式 | `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
|
||||
mqtt:
|
||||
@@ -46,19 +148,24 @@ mqtt:
|
||||
enabled: false
|
||||
cert_file: ""
|
||||
key_file: ""
|
||||
|
||||
meshtastic:
|
||||
psk: AQ==
|
||||
|
||||
database:
|
||||
driver: sqlite
|
||||
sqlite:
|
||||
path: /srv/mesh_mqtt_go/mesh_mqtt_go.db
|
||||
mysql:
|
||||
dsn: ""
|
||||
|
||||
web:
|
||||
enabled: true
|
||||
host: 0.0.0.0
|
||||
port: 8080
|
||||
socket_path: ""
|
||||
static_dir: ./dist
|
||||
map_tile_cache_dir: /srv/mesh_mqtt_go
|
||||
admin:
|
||||
username: admin
|
||||
password: admin
|
||||
@@ -66,319 +173,112 @@ web:
|
||||
session_secure: false
|
||||
```
|
||||
|
||||
配置优先级:
|
||||
> 生产环境请修改默认管理员密码,并设置足够长、随机的 `session_secret`。如果通过 HTTPS 访问 Web 管理后台,建议将 `session_secure` 设置为 `true`。
|
||||
|
||||
```text
|
||||
内置默认值 < 配置文件 < 环境变量 < 命令行参数
|
||||
```
|
||||
## 使用 SQLite 部署
|
||||
|
||||
也可以用命令行临时覆盖监听地址、PSK 和 TLS 设置:
|
||||
SQLite 是默认数据库,适合单机部署:
|
||||
|
||||
```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
|
||||
--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 前端
|
||||
|
||||
开发模式:
|
||||
如果需要使用 MySQL,启动时指定数据库驱动和 DSN:
|
||||
|
||||
```bash
|
||||
go run . --web-host 127.0.0.1 --web-port 8080
|
||||
cd meshmap_frontend
|
||||
npm run dev
|
||||
./meshtastic_mqtt_server \
|
||||
-db-driver mysql \
|
||||
-mysql-dsn 'user:password@tcp(127.0.0.1:3306)/meshtastic?charset=utf8mb4&parseTime=True&loc=Local'
|
||||
```
|
||||
|
||||
生产构建:
|
||||
## 启用 MQTT TLS
|
||||
|
||||
准备证书和私钥后启动:
|
||||
|
||||
```bash
|
||||
cd meshmap_frontend
|
||||
npm run build
|
||||
cd ..
|
||||
go run .
|
||||
./meshtastic_mqtt_server \
|
||||
-tls \
|
||||
-tls-cert /path/to/server.crt \
|
||||
-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
|
||||
GET /api/health
|
||||
POST /api/admin/login
|
||||
POST /api/admin/logout
|
||||
GET /api/admin/me
|
||||
GET /api/admin/mqtt/status
|
||||
GET /api/admin/log/login
|
||||
GET /api/admin/users
|
||||
POST /api/admin/users
|
||||
PUT /api/admin/users/:id/password
|
||||
DELETE /api/admin/text-messages/:id
|
||||
DELETE /api/admin/nodes/:id
|
||||
GET /api/nodeinfo
|
||||
GET /api/nodeinfo/:id
|
||||
GET /api/map-reports
|
||||
GET /api/map-reports/:id
|
||||
GET /api/nodes # /api/nodeinfo 的兼容别名
|
||||
GET /api/nodes/:id # /api/nodeinfo/:id 的兼容别名
|
||||
GET /api/text-messages
|
||||
GET /api/discard-details
|
||||
GET /api/positions
|
||||
GET /api/telemetry
|
||||
GET /api/routing
|
||||
GET /api/traceroute
|
||||
Web 管理后台默认账号:
|
||||
|
||||
- 用户名:`admin`
|
||||
- 密码:`admin`
|
||||
|
||||
生产环境请务必修改默认密码。
|
||||
|
||||
## systemd 部署示例
|
||||
|
||||
以下示例假设:
|
||||
|
||||
- 后端可执行文件位于 `/opt/mesh_mqtt_go/meshtastic_mqtt_server`
|
||||
- 前端静态文件位于 `/opt/mesh_mqtt_go/dist`
|
||||
- 数据与缓存目录位于 `/srv/mesh_mqtt_go`
|
||||
- 配置文件位于 `/etc/mesh_mqtt_go/config.yaml`
|
||||
|
||||
创建服务文件 `/etc/systemd/system/mesh_mqtt_go.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Meshtastic MQTT Server
|
||||
After=network.target
|
||||
|
||||
[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
|
||||
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
|
||||
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`。
|
||||
|
||||
## 控制台颜色说明
|
||||
|
||||
程序会按数据包类型使用不同背景色,方便快速区分消息类型。
|
||||
|
||||
| 背景色 | 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}
|
||||
```
|
||||
本项目采用 MIT License 开源。详见项目许可证文件。
|
||||
@@ -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}
|
||||
}
|
||||
@@ -56,6 +56,7 @@ type webConfig struct {
|
||||
Port int `yaml:"port"`
|
||||
SocketPath string `yaml:"socket_path"`
|
||||
StaticDir string `yaml:"static_dir"`
|
||||
MapTileCacheDir string `yaml:"map_tile_cache_dir"`
|
||||
Admin webAdminConfig `yaml:"admin"`
|
||||
}
|
||||
|
||||
@@ -109,6 +110,7 @@ type rawWebConfig struct {
|
||||
Port *int `yaml:"port"`
|
||||
SocketPath *string `yaml:"socket_path"`
|
||||
StaticDir *string `yaml:"static_dir"`
|
||||
MapTileCacheDir *string `yaml:"map_tile_cache_dir"`
|
||||
Admin *rawWebAdminConfig `yaml:"admin"`
|
||||
}
|
||||
|
||||
@@ -145,6 +147,7 @@ func defaultConfig() *config {
|
||||
Port: 8080,
|
||||
SocketPath: defaultWebSocketPath(),
|
||||
StaticDir: "./dist",
|
||||
MapTileCacheDir: defaultMapTileCacheDir(),
|
||||
Admin: webAdminConfig{
|
||||
Username: "admin",
|
||||
Password: "admin",
|
||||
@@ -176,6 +179,17 @@ func defaultWebSocketPath() string {
|
||||
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 {
|
||||
if goos == "windows" {
|
||||
return ""
|
||||
@@ -342,6 +356,11 @@ func normalizeConfig(raw rawConfig) (*config, bool) {
|
||||
} else {
|
||||
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 {
|
||||
changed = true
|
||||
} else {
|
||||
@@ -394,6 +413,9 @@ func validateConfig(cfg *config) error {
|
||||
if cfg.Web.StaticDir == "" {
|
||||
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 == "" {
|
||||
return fmt.Errorf("web.admin.username is required when web is enabled")
|
||||
}
|
||||
|
||||
+25
-1
@@ -44,6 +44,9 @@ func TestLoadConfigCreatesDefaultFile(t *testing.T) {
|
||||
if cfg.Web.StaticDir != "./dist" {
|
||||
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 {
|
||||
t.Fatalf("default config was not written: %v", err)
|
||||
}
|
||||
@@ -80,7 +83,7 @@ func TestLoadConfigFillsMissingFields(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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) {
|
||||
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) {
|
||||
if windowsPath := defaultWebSocketPathForGOOS("windows"); windowsPath != "" {
|
||||
t.Fatalf("windows web socket path = %q, want empty", windowsPath)
|
||||
@@ -228,6 +245,7 @@ func TestValidateConfigWeb(t *testing.T) {
|
||||
}
|
||||
|
||||
cfg = defaultConfig()
|
||||
cfg.Web.SocketPath = filepath.Join(string(filepath.Separator), "tmp", "mesh_mqtt_go.sock")
|
||||
cfg.Web.Port = 0
|
||||
if err := validateConfig(cfg); err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
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.Web.Enabled = false
|
||||
cfg.Web.Port = 0
|
||||
|
||||
@@ -115,6 +115,24 @@ func (runtimeSettingRecord) TableName() string {
|
||||
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 {
|
||||
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
Topic string `gorm:"column:topic"`
|
||||
@@ -458,6 +476,7 @@ func (s *store) migrate() error {
|
||||
{label: "login_log", model: &loginLogRecord{}},
|
||||
{label: "help_content", model: &helpContentRecord{}},
|
||||
{label: "runtime_settings", model: &runtimeSettingRecord{}},
|
||||
{label: "map_tile_sources", model: &mapTileSourceRecord{}},
|
||||
{label: "discard_details", model: &discardDetailsRecord{}},
|
||||
{label: "node_blocking", model: &nodeBlockingRecord{}},
|
||||
{label: "ip_blocking", model: &ipBlockingRecord{}},
|
||||
@@ -491,10 +510,50 @@ func (s *store) migrate() error {
|
||||
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 {
|
||||
for _, indexName := range indexNames {
|
||||
if !migrator.HasIndex(model, indexName) {
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ func TestOpenStoreCreatesTables(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
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
|
||||
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)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 329 KiB |
@@ -3,9 +3,13 @@ module meshtastic_mqtt_server
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.1
|
||||
github.com/gin-gonic/gin v1.12.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/yuin/goldmark v1.8.2
|
||||
golang.org/x/crypto v0.48.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
@@ -20,7 +24,6 @@ require (
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // 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/gin-contrib/sse v1.1.0 // 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/leodido/go-urn v1.4.0 // 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/reflect2 v1.0.2 // 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/twitchyliquid64/golang-asm v0.15.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
|
||||
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/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
|
||||
@@ -175,6 +175,7 @@ func parseArgs() (*config, error) {
|
||||
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.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.Parse()
|
||||
|
||||
|
||||
@@ -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 = "© 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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+369
@@ -12,10 +12,12 @@
|
||||
"vue": "^3.5.34"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^24.12.3",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.0.12",
|
||||
"vue-tsc": "^3.2.8"
|
||||
@@ -101,12 +103,55 @@
|
||||
"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": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"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": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
||||
@@ -400,6 +445,278 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||
@@ -642,6 +959,20 @@
|
||||
"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": {
|
||||
"version": "7.0.1",
|
||||
"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_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": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
@@ -1091,6 +1439,27 @@
|
||||
"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": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
|
||||
@@ -13,10 +13,12 @@
|
||||
"vue": "^3.5.34"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^24.12.3",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.0.12",
|
||||
"vue-tsc": "^3.2.8"
|
||||
|
||||
@@ -8,6 +8,7 @@ import AdminDiscardDetails from './components/AdminDiscardDetails.vue'
|
||||
import AdminHelpEdit from './components/AdminHelpEdit.vue'
|
||||
import AdminLogin from './components/AdminLogin.vue'
|
||||
import AdminLoginLogs from './components/AdminLoginLogs.vue'
|
||||
import AdminMapSource from './components/AdminMapSource.vue'
|
||||
import AdminMqttForward from './components/AdminMqttForward.vue'
|
||||
import AdminUsers from './components/AdminUsers.vue'
|
||||
import ChatPanel from './components/ChatPanel.vue'
|
||||
@@ -16,7 +17,8 @@ import HelpPage from './components/HelpPage.vue'
|
||||
import MeshMap from './components/MeshMap.vue'
|
||||
import NodeDetailedPage from './components/NodeDetailedPage.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 adminPath = currentPath
|
||||
@@ -52,6 +54,8 @@ const currentMapBounds = ref<MapBoundsQuery | null>(null)
|
||||
const currentMapZoom = ref(2)
|
||||
const mapReportsLoading = ref(false)
|
||||
const mapReportTotal = ref(0)
|
||||
const mapSources = ref<PublicMapTileSource[]>([fallbackMapSource])
|
||||
const mapSource = ref<PublicMapTileSource>(fallbackMapSource)
|
||||
const pendingDeleteAction = ref<PendingDeleteAction | null>(null)
|
||||
type DeletableTextMessage = TextMessage & { mergedCount?: number; mergedMessages?: TextMessage[] }
|
||||
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() {
|
||||
adminChecking.value = true
|
||||
try {
|
||||
@@ -465,6 +482,7 @@ onMounted(() => {
|
||||
if (isDetailedPage || isHelpPage) {
|
||||
return
|
||||
}
|
||||
loadMapSource()
|
||||
refresh()
|
||||
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/mqtt_forward/" :class="{ active: isMqttForwardAdminPage }">MQTT转发</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/log/login" :class="{ active: adminPath === '/admin/log/login' }">登录日志</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'" />
|
||||
<AdminMqttForward v-else-if="isMqttForwardAdminPage" />
|
||||
<AdminBot v-else-if="isBotAdminPage" />
|
||||
<AdminMapSource v-else-if="adminPath === '/admin/map_source'" />
|
||||
<AdminHelpEdit v-else-if="adminPath === '/admin/help_edit'" />
|
||||
<AdminLoginLogs v-else-if="adminPath === '/admin/log/login'" />
|
||||
<AdminDiscardDetails v-else-if="adminPath === '/admin/discard_details'" />
|
||||
@@ -574,6 +594,9 @@ onBeforeUnmount(() => {
|
||||
:is-admin="!!adminUser"
|
||||
:auto-fit="false"
|
||||
:loading="mapReportsLoading"
|
||||
:map-source="mapSource"
|
||||
:map-sources="mapSources"
|
||||
@map-source-change="selectMapSource"
|
||||
@bounds-change="handleMapBoundsChange"
|
||||
@select-node="selectedNodeId = $event"
|
||||
@clear-node="selectedNodeId = null"
|
||||
|
||||
@@ -24,6 +24,9 @@ import type {
|
||||
ListResponse,
|
||||
MapBoundsQuery,
|
||||
MapReport,
|
||||
MapTileSource,
|
||||
MapTileSourcePayload,
|
||||
MapTileSourceResponse,
|
||||
MapViewportResponse,
|
||||
MQTTForwarder,
|
||||
MQTTForwarderPayload,
|
||||
@@ -35,6 +38,8 @@ import type {
|
||||
NodeBlockingRulePayload,
|
||||
NodeInfo,
|
||||
PositionRecord,
|
||||
PublicMapTileSourceResponse,
|
||||
PublicMapTileSourcesResponse,
|
||||
TelemetryRecord,
|
||||
TextMessage,
|
||||
} from './types'
|
||||
@@ -56,10 +61,23 @@ async function requestJSON<T>(path: string, init?: RequestInit): 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) })
|
||||
if (nodeId) {
|
||||
params.set('node_id', nodeId)
|
||||
const options = typeof nodeIdOrOptions === 'string' ? { nodeId: nodeIdOrOptions } : nodeIdOrOptions
|
||||
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()}`
|
||||
}
|
||||
@@ -131,6 +149,14 @@ export function getMapReportViewport(bounds: MapBoundsQuery, zoom: number, limit
|
||||
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>> {
|
||||
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)}`)
|
||||
}
|
||||
|
||||
export function getPositions(limit = 500, offset = 0, nodeId = ''): Promise<ListResponse<PositionRecord>> {
|
||||
return getJSON<ListResponse<PositionRecord>>(listPath('/api/positions', limit, offset, nodeId))
|
||||
export function getPositions(limit = 500, offset = 0, nodeIdOrOptions: string | ListQueryOptions = ''): Promise<ListResponse<PositionRecord>> {
|
||||
return getJSON<ListResponse<PositionRecord>>(listPath('/api/positions', limit, offset, nodeIdOrOptions))
|
||||
}
|
||||
|
||||
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}`)
|
||||
}
|
||||
|
||||
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>> {
|
||||
return getJSON<ListResponse<NodeBlockingRule>>(listPath('/api/admin/blocking/nodes', limit, offset))
|
||||
}
|
||||
|
||||
@@ -418,7 +418,7 @@ onMounted(() => {
|
||||
<h2>屏蔽管理</h2>
|
||||
</div>
|
||||
</div>
|
||||
<p class="empty">管理节点、IP/CIDR、违禁词三类屏蔽规则。当前页面只维护规则,不改变 MQTT 转发行为。</p>
|
||||
<p class="empty">管理节点、IP/CIDR、违禁词三类屏蔽规则。</p>
|
||||
</div>
|
||||
|
||||
<div class="panel admin-status-panel">
|
||||
|
||||
@@ -190,10 +190,8 @@ onBeforeUnmount(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
border: 1px solid rgba(37, 99, 235, 0.14);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(59, 130, 246, 0.16), transparent 32%),
|
||||
linear-gradient(135deg, #ffffff 0%, #f8fbff 52%, #eef6ff 100%);
|
||||
border: 1px solid var(--color-border);
|
||||
background: linear-gradient(135deg, var(--color-surface) 0%, var(--color-surface-soft) 100%);
|
||||
}
|
||||
|
||||
.control-header {
|
||||
@@ -210,19 +208,19 @@ onBeforeUnmount(() => {
|
||||
.control-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid #cbd5e1;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
padding: 6px 12px;
|
||||
color: #475569;
|
||||
color: var(--color-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
background: color-mix(in srgb, var(--color-surface) 84%, transparent);
|
||||
}
|
||||
|
||||
.control-badge.active {
|
||||
border-color: rgba(22, 163, 74, 0.32);
|
||||
color: #15803d;
|
||||
background: #dcfce7;
|
||||
border-color: color-mix(in srgb, var(--color-success) 36%, white);
|
||||
color: color-mix(in srgb, var(--color-success) 72%, var(--color-heading));
|
||||
background: var(--color-success-soft);
|
||||
}
|
||||
|
||||
.control-body {
|
||||
@@ -235,10 +233,10 @@ onBeforeUnmount(() => {
|
||||
|
||||
.control-copy,
|
||||
.switch-card {
|
||||
border: 1px solid rgba(203, 213, 225, 0.78);
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.06);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: color-mix(in srgb, var(--color-surface) 90%, transparent);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.control-copy {
|
||||
@@ -247,13 +245,13 @@ onBeforeUnmount(() => {
|
||||
|
||||
.control-copy h3 {
|
||||
margin: 0 0 0.45rem;
|
||||
color: #0f172a;
|
||||
color: var(--color-heading);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.control-copy p {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
color: var(--color-muted);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
@@ -269,20 +267,20 @@ onBeforeUnmount(() => {
|
||||
gap: 1rem;
|
||||
min-height: 108px;
|
||||
padding: 1rem;
|
||||
color: #334155;
|
||||
color: var(--color-text);
|
||||
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 {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(37, 99, 235, 0.35);
|
||||
box-shadow: 0 18px 44px rgba(15, 23, 42, 0.09);
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.switch-card.enabled {
|
||||
border-color: rgba(22, 163, 74, 0.35);
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f0fdf4 100%);
|
||||
border-color: color-mix(in srgb, var(--color-success) 42%, white);
|
||||
background: var(--color-success-soft);
|
||||
}
|
||||
|
||||
.switch-card.saving {
|
||||
@@ -303,12 +301,12 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.switch-text strong {
|
||||
color: #0f172a;
|
||||
color: var(--color-heading);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.switch-text small {
|
||||
color: #64748b;
|
||||
color: var(--color-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
@@ -319,9 +317,9 @@ onBeforeUnmount(() => {
|
||||
width: 54px;
|
||||
height: 30px;
|
||||
border-radius: 999px;
|
||||
background: #cbd5e1;
|
||||
box-shadow: inset 0 2px 4px rgba(15, 23, 42, 0.14);
|
||||
transition: background 0.15s ease;
|
||||
background: var(--color-border-strong);
|
||||
box-shadow: inset 0 2px 4px rgba(47, 52, 50, 0.12);
|
||||
transition: background-color 0.16s ease;
|
||||
}
|
||||
|
||||
.switch-toggle::after {
|
||||
@@ -333,12 +331,12 @@ onBeforeUnmount(() => {
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
box-shadow: 0 4px 10px rgba(15, 23, 42, 0.24);
|
||||
transition: transform 0.15s ease;
|
||||
box-shadow: 0 4px 10px rgba(47, 52, 50, 0.18);
|
||||
transition: transform 0.16s ease;
|
||||
}
|
||||
|
||||
.switch-card.enabled .switch-toggle {
|
||||
background: linear-gradient(135deg, #16a34a, #22c55e);
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.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: '© 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: '© 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="© 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(select) {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--color-border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 9px 11px;
|
||||
color: #0f172a;
|
||||
color: var(--color-heading);
|
||||
font: inherit;
|
||||
background: #fff;
|
||||
background: var(--color-surface);
|
||||
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(select:focus) {
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.14);
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 20%, transparent);
|
||||
}
|
||||
|
||||
.mqtt-hero,
|
||||
@@ -610,7 +609,7 @@ onBeforeUnmount(() => {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
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 {
|
||||
@@ -624,23 +623,23 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.hero-stats div {
|
||||
border: 1px solid #dbeafe;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px 16px;
|
||||
text-align: center;
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
background: color-mix(in srgb, var(--color-surface) 84%, transparent);
|
||||
}
|
||||
|
||||
.hero-stats strong {
|
||||
display: block;
|
||||
color: #1d4ed8;
|
||||
color: color-mix(in srgb, var(--color-primary) 72%, var(--color-heading));
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.hero-stats span,
|
||||
.endpoint-line,
|
||||
.runtime-grid span {
|
||||
color: #64748b;
|
||||
color: var(--color-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@@ -674,7 +673,7 @@ onBeforeUnmount(() => {
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: #334155;
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -687,9 +686,9 @@ onBeforeUnmount(() => {
|
||||
.edit-section,
|
||||
.forwarder-card,
|
||||
.topics-box {
|
||||
border: 1px solid #dbe4ef;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.broker-card {
|
||||
@@ -702,16 +701,16 @@ onBeforeUnmount(() => {
|
||||
|
||||
.broker-card legend {
|
||||
padding: 0 8px;
|
||||
color: #334155;
|
||||
color: var(--color-text);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.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 {
|
||||
background: linear-gradient(180deg, #f8fffb 0%, #fff 100%);
|
||||
background: linear-gradient(180deg, var(--color-success-soft) 0%, var(--color-surface) 100%);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
@@ -726,13 +725,13 @@ onBeforeUnmount(() => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border: 1px solid #dbe4ef;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 9px 11px;
|
||||
color: #334155;
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
background: #f8fafc;
|
||||
background: var(--color-surface-soft);
|
||||
}
|
||||
|
||||
.switch-card input,
|
||||
@@ -743,34 +742,34 @@ onBeforeUnmount(() => {
|
||||
.forwarder-card {
|
||||
padding: 1rem;
|
||||
margin-top: 1rem;
|
||||
box-shadow: inset 4px 0 0 #dbeafe;
|
||||
box-shadow: inset 4px 0 0 var(--color-primary-soft);
|
||||
}
|
||||
|
||||
.forwarder-title h3 {
|
||||
color: #0f172a;
|
||||
color: var(--color-heading);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
border-radius: 999px;
|
||||
padding: 7px 12px;
|
||||
color: #92400e;
|
||||
background: #fffbeb;
|
||||
color: color-mix(in srgb, var(--color-warning) 72%, var(--color-heading));
|
||||
background: var(--color-warning-soft);
|
||||
}
|
||||
|
||||
.status-pill.ok {
|
||||
color: #166534;
|
||||
background: #dcfce7;
|
||||
color: color-mix(in srgb, var(--color-success) 72%, var(--color-heading));
|
||||
background: var(--color-success-soft);
|
||||
}
|
||||
|
||||
.status-pill.warn {
|
||||
color: #92400e;
|
||||
background: #fef3c7;
|
||||
color: color-mix(in srgb, var(--color-warning) 72%, var(--color-heading));
|
||||
background: var(--color-warning-soft);
|
||||
}
|
||||
|
||||
.status-pill.disabled {
|
||||
color: #475569;
|
||||
background: #e2e8f0;
|
||||
color: var(--color-muted);
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.runtime-grid {
|
||||
@@ -781,23 +780,23 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.runtime-grid div {
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 12px;
|
||||
background: #f8fafc;
|
||||
background: var(--color-surface-soft);
|
||||
}
|
||||
|
||||
.runtime-grid strong {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
color: #0f172a;
|
||||
color: var(--color-heading);
|
||||
}
|
||||
|
||||
.inline-error {
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-danger) 36%, white);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 12px;
|
||||
color: #b91c1c;
|
||||
background: #fef2f2;
|
||||
color: color-mix(in srgb, var(--color-danger) 74%, var(--color-heading));
|
||||
background: var(--color-danger-soft);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@@ -831,24 +830,10 @@ onBeforeUnmount(() => {
|
||||
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 {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: #f8fafc;
|
||||
background: var(--color-surface-soft);
|
||||
}
|
||||
|
||||
.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;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: 0.75rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.topic-row.new-topic {
|
||||
border: 1px dashed #93c5fd;
|
||||
border-radius: 14px;
|
||||
border: 1px dashed color-mix(in srgb, var(--color-primary) 54%, white);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.75rem;
|
||||
background: #eff6ff;
|
||||
background: var(--color-primary-soft);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
border: 1px dashed #cbd5e1;
|
||||
border-radius: 16px;
|
||||
border: 1px dashed var(--color-border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 24px;
|
||||
color: #64748b;
|
||||
color: var(--color-muted);
|
||||
text-align: center;
|
||||
background: #f8fafc;
|
||||
background: var(--color-surface-soft);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
|
||||
@@ -26,6 +26,7 @@ const menuX = ref(0)
|
||||
const menuY = ref(0)
|
||||
const topThreshold = 8
|
||||
const bottomThreshold = 40
|
||||
const scrollOverflowAllowance = 1
|
||||
|
||||
const groupedMessages = computed<GroupedTextMessage[]>(() => {
|
||||
const groups = new Map<string, GroupedTextMessage>()
|
||||
@@ -102,25 +103,29 @@ function handleKeydown(event: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
closeMessageMenu()
|
||||
const el = panelRef.value
|
||||
function loadOlderFromCurrentScroll(el: HTMLElement) {
|
||||
if (
|
||||
!el ||
|
||||
props.loadingOlder ||
|
||||
!props.hasMoreMessages ||
|
||||
props.messages.length === 0 ||
|
||||
groupedMessages.value.length === 0 ||
|
||||
restoreScrollHeight != null
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (el.scrollTop <= topThreshold) {
|
||||
restoreScrollHeight = el.scrollHeight
|
||||
restoreScrollTop = el.scrollTop
|
||||
restoreMessageCount = props.messages.length
|
||||
restoreMessageCount = groupedMessages.value.length
|
||||
emit('load-older')
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
closeMessageMenu()
|
||||
const el = panelRef.value
|
||||
if (!el || el.scrollTop > topThreshold) {
|
||||
return
|
||||
}
|
||||
loadOlderFromCurrentScroll(el)
|
||||
}
|
||||
|
||||
onBeforeUpdate(() => {
|
||||
@@ -138,6 +143,9 @@ onMounted(async () => {
|
||||
if (el) {
|
||||
el.scrollTop = el.scrollHeight
|
||||
didInitialScroll = true
|
||||
if (el.scrollHeight <= el.clientHeight + scrollOverflowAllowance) {
|
||||
loadOlderFromCurrentScroll(el)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -153,7 +161,7 @@ onUpdated(() => {
|
||||
}
|
||||
|
||||
if (restoreScrollHeight != null) {
|
||||
if (props.messages.length > restoreMessageCount) {
|
||||
if (groupedMessages.value.length > restoreMessageCount) {
|
||||
el.scrollTop = el.scrollHeight - restoreScrollHeight + restoreScrollTop
|
||||
clearRestoreState()
|
||||
return
|
||||
@@ -167,6 +175,10 @@ onUpdated(() => {
|
||||
el.scrollTop = el.scrollHeight
|
||||
didInitialScroll = true
|
||||
}
|
||||
|
||||
if (el.scrollHeight <= el.clientHeight + scrollOverflowAllowance) {
|
||||
loadOlderFromCurrentScroll(el)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import L from 'leaflet'
|
||||
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<{
|
||||
items: MapRenderable[]
|
||||
@@ -10,9 +11,13 @@ const props = withDefaults(defineProps<{
|
||||
isAdmin: boolean
|
||||
autoFit?: boolean
|
||||
loading?: boolean
|
||||
mapSource?: PublicMapTileSource
|
||||
mapSources?: PublicMapTileSource[]
|
||||
}>(), {
|
||||
autoFit: true,
|
||||
loading: false,
|
||||
mapSource: () => fallbackMapSource,
|
||||
mapSources: () => [fallbackMapSource],
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -21,6 +26,7 @@ const emit = defineEmits<{
|
||||
'delete-node': [nodeId: string]
|
||||
'delete-and-block-node': [payload: { nodeId: string; nodeNum: number | null }]
|
||||
'bounds-change': [payload: MapBoundsChangePayload]
|
||||
'map-source-change': [sourceId: number]
|
||||
}>()
|
||||
|
||||
const mapEl = ref<HTMLElement | null>(null)
|
||||
@@ -29,8 +35,11 @@ const menuX = ref(0)
|
||||
const menuY = ref(0)
|
||||
const lastRaisedNodeId = ref<string | null>(null)
|
||||
let map: L.Map | null = null
|
||||
let tileLayer: L.TileLayer | null = null
|
||||
let markerLayer: L.LayerGroup | null = null
|
||||
const markersByKey = new Map<string, L.Marker>()
|
||||
const overlapShuffleOrders = new Map<string, string[]>()
|
||||
const shuffledSelectedNodeIds = new Set<string>()
|
||||
let hasFitBounds = false
|
||||
|
||||
const minMapZoom = 3
|
||||
@@ -55,15 +64,12 @@ onMounted(async () => {
|
||||
maxBoundsViscosity: 1.0,
|
||||
worldCopyJump: false,
|
||||
}).setView(defaultMapCenter, defaultMapZoom)
|
||||
L.tileLayer('https://tile.openstreetmap.jp/{z}/{x}/{y}.png', {
|
||||
minZoom: minMapZoom,
|
||||
maxZoom: 19,
|
||||
noWrap: true,
|
||||
bounds: worldBounds,
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
}).addTo(map)
|
||||
map.attributionControl.setPrefix(false)
|
||||
applyTileLayer()
|
||||
map.on('click', () => {
|
||||
closeNodeMenu()
|
||||
overlapShuffleOrders.clear()
|
||||
shuffledSelectedNodeIds.clear()
|
||||
emit('clear-node')
|
||||
})
|
||||
map.on('moveend', emitBoundsChange)
|
||||
@@ -77,8 +83,11 @@ onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
map?.remove()
|
||||
map = null
|
||||
tileLayer = null
|
||||
markerLayer = null
|
||||
markersByKey.clear()
|
||||
overlapShuffleOrders.clear()
|
||||
shuffledSelectedNodeIds.clear()
|
||||
})
|
||||
|
||||
watch(
|
||||
@@ -87,6 +96,32 @@ watch(
|
||||
{ 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() {
|
||||
menuNode.value = null
|
||||
}
|
||||
@@ -149,6 +184,7 @@ function renderMarkers(forceFit: boolean) {
|
||||
}
|
||||
const bounds = L.latLngBounds([])
|
||||
const visibleMarkerKeys = new Set<string>()
|
||||
const overlapGroups = buildOverlapGroups(props.items)
|
||||
|
||||
for (const item of props.items) {
|
||||
const markerKey = mapMarkerKey(item)
|
||||
@@ -166,8 +202,14 @@ function renderMarkers(forceFit: boolean) {
|
||||
}
|
||||
|
||||
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 zIndexOffset = raised ? 1000 : overlapIndex
|
||||
const nodeIcon = L.divIcon({
|
||||
className: `node-marker${selected ? ' selected' : ''}`,
|
||||
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], {
|
||||
icon: nodeIcon,
|
||||
title: node.label,
|
||||
zIndexOffset: raised ? 1000 : 0,
|
||||
zIndexOffset,
|
||||
})
|
||||
marker.bindPopup(buildNodePopupHTML(node), { maxWidth: 320, className: 'node-detail-popup' })
|
||||
marker.addTo(markerLayer)
|
||||
@@ -188,7 +230,7 @@ function renderMarkers(forceFit: boolean) {
|
||||
} else {
|
||||
marker.setLatLng([node.latitude, node.longitude])
|
||||
marker.setIcon(nodeIcon)
|
||||
marker.setZIndexOffset(raised ? 1000 : 0)
|
||||
marker.setZIndexOffset(zIndexOffset)
|
||||
marker.options.title = node.label
|
||||
marker.getElement()?.setAttribute('title', node.label)
|
||||
const popup = marker.getPopup()
|
||||
@@ -203,8 +245,18 @@ function renderMarkers(forceFit: boolean) {
|
||||
marker.off('contextmenu')
|
||||
marker.on('click', (event) => {
|
||||
L.DomEvent.stopPropagation(event)
|
||||
lastRaisedNodeId.value = node.node_id
|
||||
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)
|
||||
})
|
||||
marker.on('contextmenu', (event) => openNodeMenu(node, event))
|
||||
@@ -235,6 +287,126 @@ function mapMarkerKey(item: MapRenderable): string {
|
||||
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 {
|
||||
const size = clusterIconSize(cluster.count)
|
||||
const marker = L.marker([cluster.latitude, cluster.longitude], {
|
||||
@@ -342,15 +514,15 @@ function nodeColor(nodeId: string): string {
|
||||
}
|
||||
|
||||
const hueRanges = [
|
||||
[35, 75],
|
||||
[95, 165],
|
||||
[185, 250],
|
||||
[265, 315],
|
||||
[42, 68],
|
||||
[92, 136],
|
||||
[188, 218],
|
||||
[330, 354],
|
||||
]
|
||||
const range = hueRanges[hash % hueRanges.length]
|
||||
const hue = range[0] + (hash % (range[1] - range[0]))
|
||||
const saturation = 68 + (hash % 18)
|
||||
const lightness = 32 + (hash % 10)
|
||||
const saturation = 24 + (hash % 14)
|
||||
const lightness = 42 + (hash % 12)
|
||||
return `hsl(${hue} ${saturation}% ${lightness}%)`
|
||||
}
|
||||
|
||||
@@ -371,6 +543,43 @@ function escapeHTML(value: string): string {
|
||||
<template>
|
||||
<section class="map-panel panel">
|
||||
<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-else-if="items.length === 0" class="map-empty">暂无可显示坐标的节点</div> -->
|
||||
<div
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
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 NodeTrajectoryMap from './NodeTrajectoryMap.vue'
|
||||
|
||||
@@ -15,12 +16,25 @@ const mapReport = ref<MapReport | null>(null)
|
||||
const messages = ref<TextMessage[]>([])
|
||||
const positions = ref<PositionRecord[]>([])
|
||||
const telemetry = ref<TelemetryRecord[]>([])
|
||||
const mapSources = ref<PublicMapTileSource[]>([fallbackMapSource])
|
||||
const mapSource = ref<PublicMapTileSource>(fallbackMapSource)
|
||||
const loading = ref(true)
|
||||
const chatLoadingOlder = ref(false)
|
||||
const chatHasMore = ref(true)
|
||||
const telemetryLoading = ref(false)
|
||||
const trajectoryLoading = ref(false)
|
||||
const trajectoryError = ref('')
|
||||
const trajectoryTruncated = ref(false)
|
||||
const error = ref('')
|
||||
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 scrollOverflowAllowance = 1
|
||||
type GroupedTextMessage = TextMessage & { mergedCount: number; mergedMessages: TextMessage[] }
|
||||
type PendingDeleteAction =
|
||||
| { kind: 'delete-message'; message: GroupedTextMessage }
|
||||
@@ -94,6 +108,27 @@ function formatTime(value: string): string {
|
||||
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]> {
|
||||
if (!value) {
|
||||
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() {
|
||||
const response = await getTextMessages(chatPageSize, 0, props.nodeId)
|
||||
messages.value = toChronological(response.items)
|
||||
@@ -183,24 +291,30 @@ async function loadInitialMessages() {
|
||||
const el = chatHistoryRef.value
|
||||
if (el) {
|
||||
el.scrollTop = el.scrollHeight
|
||||
await loadMoreUntilScrollable(el)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOlderMessages() {
|
||||
const el = chatHistoryRef.value
|
||||
await loadOlderMessagesFromCurrentScroll(el)
|
||||
}
|
||||
|
||||
async function loadOlderMessagesFromCurrentScroll(el: HTMLElement | null) {
|
||||
if (chatLoadingOlder.value || !chatHasMore.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const el = chatHistoryRef.value
|
||||
const previousScrollHeight = el?.scrollHeight ?? 0
|
||||
const previousScrollTop = el?.scrollTop ?? 0
|
||||
const previousGroupedMessageCount = groupedMessages.value.length
|
||||
chatLoadingOlder.value = true
|
||||
try {
|
||||
const response = await getTextMessages(chatPageSize, messages.value.length, props.nodeId)
|
||||
messages.value = mergeMessages(messages.value, toChronological(response.items))
|
||||
chatHasMore.value = response.items.length === chatPageSize
|
||||
await nextTick()
|
||||
if (el) {
|
||||
if (el && groupedMessages.value.length > previousGroupedMessageCount) {
|
||||
el.scrollTop = el.scrollHeight - previousScrollHeight + previousScrollTop
|
||||
}
|
||||
} 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() {
|
||||
menuMessage.value = null
|
||||
}
|
||||
@@ -367,21 +491,36 @@ function handleChatScroll() {
|
||||
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() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
trajectoryError.value = ''
|
||||
telemetryPage.value = 1
|
||||
try {
|
||||
const [nodeData, reportData, positionData, telemetryData] = await Promise.all([
|
||||
const [nodeData, reportData] = await Promise.all([
|
||||
optional(getNodeInfoById(props.nodeId)),
|
||||
optional(getMapReportById(props.nodeId)),
|
||||
getPositions(500, 0, props.nodeId),
|
||||
getTelemetry(200, 0, props.nodeId),
|
||||
])
|
||||
nodeInfo.value = nodeData
|
||||
mapReport.value = reportData
|
||||
positions.value = positionData.items
|
||||
telemetry.value = telemetryData.items
|
||||
await loadInitialMessages()
|
||||
await Promise.all([
|
||||
loadTrajectoryRange(),
|
||||
loadTelemetryPage(),
|
||||
loadInitialMessages(),
|
||||
])
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err)
|
||||
} finally {
|
||||
@@ -392,6 +531,7 @@ async function loadDetails() {
|
||||
onMounted(() => {
|
||||
window.addEventListener('click', closeMessageMenu)
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
loadMapSource()
|
||||
loadDetails()
|
||||
})
|
||||
|
||||
@@ -492,7 +632,28 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<span class="badge">{{ positions.length }}</span>
|
||||
</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>
|
||||
|
||||
@@ -502,8 +663,9 @@ onBeforeUnmount(() => {
|
||||
<p class="eyebrow">Telemetry</p>
|
||||
<h2>遥测数据:{{ nodeTitle }}</h2>
|
||||
</div>
|
||||
<span class="badge">{{ telemetry.length }}</span>
|
||||
<span class="badge">本页 {{ telemetry.length }}</span>
|
||||
</div>
|
||||
<div v-if="telemetryLoading" class="admin-loading">正在加载遥测数据...</div>
|
||||
<div class="node-table-wrap">
|
||||
<table class="node-table">
|
||||
<thead>
|
||||
@@ -530,6 +692,12 @@ onBeforeUnmount(() => {
|
||||
</table>
|
||||
<div v-if="telemetry.length === 0" class="empty">暂无遥测数据</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>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
|
||||
@@ -2,16 +2,44 @@
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import L from 'leaflet'
|
||||
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[]
|
||||
mapSource?: PublicMapTileSource
|
||||
mapSources?: PublicMapTileSource[]
|
||||
}>(), {
|
||||
mapSource: () => fallbackMapSource,
|
||||
mapSources: () => [fallbackMapSource],
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'map-source-change': [sourceId: number]
|
||||
}>()
|
||||
|
||||
const mapEl = ref<HTMLElement | null>(null)
|
||||
let map: L.Map | null = null
|
||||
let tileLayer: L.TileLayer | 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() {
|
||||
if (!map || !layer) {
|
||||
return
|
||||
@@ -28,10 +56,10 @@ function renderTrajectory() {
|
||||
}
|
||||
|
||||
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[points.length - 1], { radius: 6, color: '#dc2626', fillColor: '#ef4444', 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: '#b4877f', fillColor: '#c59b93', fillOpacity: 0.88 }).bindPopup('终点').addTo(layer)
|
||||
map.fitBounds(L.latLngBounds(points), { padding: [24, 24], maxZoom: 14 })
|
||||
}
|
||||
|
||||
@@ -49,10 +77,8 @@ onMounted(async () => {
|
||||
maxBoundsViscosity: 1.0,
|
||||
worldCopyJump: false,
|
||||
}).setView([0, 0], 2)
|
||||
L.tileLayer('https://tile.openstreetmap.jp/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
}).addTo(map)
|
||||
map.attributionControl.setPrefix(false)
|
||||
applyTileLayer()
|
||||
layer = L.layerGroup().addTo(map)
|
||||
renderTrajectory()
|
||||
})
|
||||
@@ -60,6 +86,7 @@ onMounted(async () => {
|
||||
onBeforeUnmount(() => {
|
||||
map?.remove()
|
||||
map = null
|
||||
tileLayer = null
|
||||
layer = null
|
||||
})
|
||||
|
||||
@@ -68,8 +95,53 @@ watch(
|
||||
() => renderTrajectory(),
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.mapSource,
|
||||
() => applyTileLayer(),
|
||||
{ deep: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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>
|
||||
|
||||
@@ -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: '© 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]
|
||||
}
|
||||
}
|
||||
+624
-271
File diff suppressed because it is too large
Load Diff
@@ -76,6 +76,44 @@ export interface MapBoundsChangePayload {
|
||||
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 {
|
||||
type: 'point'
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
plugins: [vue(), tailwindcss()],
|
||||
build: {
|
||||
outDir: "../dist",
|
||||
assetsDir: "assets",
|
||||
|
||||
@@ -51,13 +51,13 @@ func newRouter(cfg webConfig, store *store, sessions *sessionManager, mqttStatus
|
||||
r := gin.New()
|
||||
r.Use(gin.Logger(), gin.Recovery())
|
||||
api := r.Group("/api")
|
||||
registerAPIRoutes(api, store)
|
||||
registerAPIRoutes(api, store, cfg.MapTileCacheDir)
|
||||
registerAdminRoutes(api.Group("/admin"), store, sessions, mqttStatus, blocking, forwarder, settings, botSender)
|
||||
registerStaticRoutes(r, cfg.StaticDir)
|
||||
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) {
|
||||
status := gin.H{"status": "ok", "database": "ok"}
|
||||
if err := store.Ping(); err != nil {
|
||||
@@ -72,6 +72,8 @@ func registerAPIRoutes(r gin.IRouter, store *store) {
|
||||
registerNodeInfoRoutes(r, store, "/nodeinfo")
|
||||
registerNodeInfoRoutes(r, store, "/nodes")
|
||||
registerMapReportRoutes(r, store)
|
||||
registerMapSourceRoutes(r, store)
|
||||
registerMapTileProxyRoutes(r, store, mapTileCacheDir)
|
||||
registerHelpRoutes(r, store)
|
||||
r.GET("/text-messages", func(c *gin.Context) {
|
||||
opts, ok := parseListOptions(c)
|
||||
@@ -186,6 +188,7 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager,
|
||||
registerAdminBlockingRoutes(protected, store, blocking)
|
||||
registerAdminMQTTForwardRoutes(protected, store, forwarder)
|
||||
registerAdminRuntimeSettingsRoutes(protected, store, settings)
|
||||
registerAdminMapSourceRoutes(protected, store)
|
||||
registerAdminHelpRoutes(protected, store)
|
||||
registerAdminBotRoutes(protected, store, botSender)
|
||||
protected.GET("/me", func(c *gin.Context) {
|
||||
|
||||
Reference in New Issue
Block a user