基本功能差不多完成
This commit is contained in:
@@ -117,7 +117,7 @@ go run .
|
|||||||
|
|
||||||
构建后的文件位于项目根目录 `dist/`,Gin 会提供静态文件服务;`/api` 路径保留给后端接口。
|
构建后的文件位于项目根目录 `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` 登录日志。后台支持新增管理员用户和修改用户密码;密码使用 bcrypt hash 保存,API 不会返回密码 hash。修改密码不会立即使已签发 Session 失效,当前 Session 到期或退出登录后才需要使用新密码。登录成功和失败都会记录到登录日志,包含用户名、结果、原因、来源地址、User-Agent 和时间。管理员可在主页右键删除聊天消息、地图节点或节点列表记录;删除节点会删除 `nodeinfo` 和 `map_report` 当前状态,不会删除历史消息、位置、遥测等 append 记录,后续收到新的节点上报时可能重新出现。
|
管理页面位于 `/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:
|
常用 API:
|
||||||
|
|
||||||
@@ -140,6 +140,7 @@ GET /api/map-reports/:id
|
|||||||
GET /api/nodes # /api/nodeinfo 的兼容别名
|
GET /api/nodes # /api/nodeinfo 的兼容别名
|
||||||
GET /api/nodes/:id # /api/nodeinfo/:id 的兼容别名
|
GET /api/nodes/:id # /api/nodeinfo/:id 的兼容别名
|
||||||
GET /api/text-messages
|
GET /api/text-messages
|
||||||
|
GET /api/discard-details
|
||||||
GET /api/positions
|
GET /api/positions
|
||||||
GET /api/telemetry
|
GET /api/telemetry
|
||||||
GET /api/routing
|
GET /api/routing
|
||||||
@@ -167,6 +168,7 @@ meshtastic:
|
|||||||
程序默认启用 SQLite,数据库表迁移和操作由 GORM 执行,并持久化以下数据:
|
程序默认启用 SQLite,数据库表迁移和操作由 GORM 执行,并持久化以下数据:
|
||||||
|
|
||||||
- `login_log`:追加保存后台登录成功和失败日志
|
- `login_log`:追加保存后台登录成功和失败日志
|
||||||
|
- `discard_details`:追加保存 `MQTTPP` 判定无效而被 broker 丢弃的数据,raw payload 使用 base64 保存
|
||||||
- `nodeinfo`:保存 `type == "nodeinfo"` 的节点身份和设备信息
|
- `nodeinfo`:保存 `type == "nodeinfo"` 的节点身份和设备信息
|
||||||
- `map_report`:保存 `type == "map_report"` 的地图报告信息,前端地图从该表读取
|
- `map_report`:保存 `type == "map_report"` 的地图报告信息,前端地图从该表读取
|
||||||
- `text_message`:追加保存 `type == "text_message"` 的聊天消息
|
- `text_message`:追加保存 `type == "text_message"` 的聊天消息
|
||||||
@@ -252,7 +254,9 @@ database:
|
|||||||
|
|
||||||
`empty_packet` 仍然属于 `valid == true`,会被转发;只是控制台默认不显示它。
|
`empty_packet` 仍然属于 `valid == true`,会被转发;只是控制台默认不显示它。
|
||||||
|
|
||||||
无法解密但能解析的加密包通常会输出为 `encrypted_packet`,仍然属于 `valid == true`,因此会被转发。
|
无法解密的加密包会输出为 `encrypted_packet`,属于 `valid == false`,因此会被拒绝并丢弃。
|
||||||
|
|
||||||
|
丢弃的 publish 会写入 `discard_details`,记录 topic、错误原因、payload 长度、base64 raw payload、MQTT 客户端信息和完整 `content_json`。
|
||||||
|
|
||||||
## 本地验证
|
## 本地验证
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,26 @@ func (loginLogRecord) TableName() string {
|
|||||||
return "login_log"
|
return "login_log"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type discardDetailsRecord struct {
|
||||||
|
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||||
|
Topic string `gorm:"column:topic"`
|
||||||
|
Error string `gorm:"column:error"`
|
||||||
|
PayloadLen int64 `gorm:"column:payload_len"`
|
||||||
|
RawBase64 string `gorm:"column:raw_base64;not null"`
|
||||||
|
ContentJSON string `gorm:"column:content_json;not null"`
|
||||||
|
MQTTClientID *string `gorm:"column:mqtt_client_id"`
|
||||||
|
MQTTUsername *string `gorm:"column:mqtt_username"`
|
||||||
|
MQTTListener *string `gorm:"column:mqtt_listener"`
|
||||||
|
MQTTRemoteAddr *string `gorm:"column:mqtt_remote_addr"`
|
||||||
|
MQTTRemoteHost *string `gorm:"column:mqtt_remote_host"`
|
||||||
|
MQTTRemotePort *string `gorm:"column:mqtt_remote_port"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (discardDetailsRecord) TableName() string {
|
||||||
|
return "discard_details"
|
||||||
|
}
|
||||||
|
|
||||||
type nodeInfoRecord struct {
|
type nodeInfoRecord struct {
|
||||||
NodeID string `gorm:"column:node_id;primaryKey;not null"`
|
NodeID string `gorm:"column:node_id;primaryKey;not null"`
|
||||||
NodeNum int64 `gorm:"column:node_num;not null;index"`
|
NodeNum int64 `gorm:"column:node_num;not null;index"`
|
||||||
@@ -285,6 +305,7 @@ func (s *store) migrate() error {
|
|||||||
}{
|
}{
|
||||||
{label: "users", model: &userRecord{}},
|
{label: "users", model: &userRecord{}},
|
||||||
{label: "login_log", model: &loginLogRecord{}},
|
{label: "login_log", model: &loginLogRecord{}},
|
||||||
|
{label: "discard_details", model: &discardDetailsRecord{}},
|
||||||
{label: "nodeinfo", model: &nodeInfoRecord{}},
|
{label: "nodeinfo", model: &nodeInfoRecord{}},
|
||||||
{label: "map_report", model: &mapReportRecord{}},
|
{label: "map_report", model: &mapReportRecord{}},
|
||||||
{label: "text_message", model: &textMessageRecord{}},
|
{label: "text_message", model: &textMessageRecord{}},
|
||||||
|
|||||||
+51
-1
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -14,7 +15,7 @@ func TestOpenStoreCreatesTables(t *testing.T) {
|
|||||||
st := openTestStore(t)
|
st := openTestStore(t)
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
|
||||||
for _, table := range []string{"users", "login_log", "nodeinfo", "map_report", "text_message", "position", "telemetry", "routing", "traceroute"} {
|
for _, table := range []string{"users", "login_log", "discard_details", "nodeinfo", "map_report", "text_message", "position", "telemetry", "routing", "traceroute"} {
|
||||||
var name string
|
var name string
|
||||||
if err := rawTestDB(t, st).QueryRow("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", table).Scan(&name); err != nil {
|
if err := rawTestDB(t, st).QueryRow("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", table).Scan(&name); err != nil {
|
||||||
t.Fatalf("%s table missing: %v", table, err)
|
t.Fatalf("%s table missing: %v", table, err)
|
||||||
@@ -349,6 +350,55 @@ func TestInsertAndListLoginLogs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestInsertDiscardDetailsStoresRawBase64AndClientInfo(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
|
raw := []byte{0xff, 0x00, 0x01}
|
||||||
|
clientInfo := mqttClientInfo{ClientID: "client-1", Username: "user-1", Listener: "tcp", RemoteAddr: "127.0.0.1:54321", RemoteHost: "127.0.0.1", RemotePort: "54321"}
|
||||||
|
record := map[string]any{"topic": "msh/US/test", "error": "protobuf decode failed", "payload_len": len(raw)}
|
||||||
|
if err := st.InsertDiscardDetails(record, raw, clientInfo); err != nil {
|
||||||
|
t.Fatalf("InsertDiscardDetails() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var topic, errorText, rawBase64, clientID, username, listener, remoteAddr, remoteHost, remotePort, contentJSON string
|
||||||
|
var payloadLen int64
|
||||||
|
if err := rawTestDB(t, st).QueryRow("SELECT topic, error, payload_len, raw_base64, mqtt_client_id, mqtt_username, mqtt_listener, mqtt_remote_addr, mqtt_remote_host, mqtt_remote_port, content_json FROM discard_details LIMIT 1").Scan(&topic, &errorText, &payloadLen, &rawBase64, &clientID, &username, &listener, &remoteAddr, &remoteHost, &remotePort, &contentJSON); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if topic != "msh/US/test" || errorText != "protobuf decode failed" || payloadLen != int64(len(raw)) || rawBase64 != base64.StdEncoding.EncodeToString(raw) {
|
||||||
|
t.Fatalf("discard details row = topic %q error %q len %d raw %q", topic, errorText, payloadLen, rawBase64)
|
||||||
|
}
|
||||||
|
if clientID != "client-1" || username != "user-1" || listener != "tcp" || remoteAddr != "127.0.0.1:54321" || remoteHost != "127.0.0.1" || remotePort != "54321" {
|
||||||
|
t.Fatalf("client info = %q %q %q %q %q %q", clientID, username, listener, remoteAddr, remoteHost, remotePort)
|
||||||
|
}
|
||||||
|
if !strings.Contains(contentJSON, "protobuf decode failed") {
|
||||||
|
t.Fatalf("content_json = %q, want error", contentJSON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListDiscardDetailsOrdersNewestFirst(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
|
if err := st.InsertDiscardDetails(map[string]any{"topic": "first", "error": "first"}, []byte{1}, mqttClientInfo{}); err != nil {
|
||||||
|
t.Fatalf("first InsertDiscardDetails() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := st.InsertDiscardDetails(map[string]any{"topic": "second", "error": "second"}, []byte{2}, mqttClientInfo{}); err != nil {
|
||||||
|
t.Fatalf("second InsertDiscardDetails() error = %v", err)
|
||||||
|
}
|
||||||
|
rows, err := st.ListDiscardDetails(listOptions{Limit: 10})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListDiscardDetails() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 2 {
|
||||||
|
t.Fatalf("discard details len = %d, want 2", len(rows))
|
||||||
|
}
|
||||||
|
if rows[0].ID <= rows[1].ID || rows[0].Topic != "second" {
|
||||||
|
t.Fatalf("discard details order = %#v", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestInsertTextMessageAppendsRows(t *testing.T) {
|
func TestInsertTextMessageAppendsRows(t *testing.T) {
|
||||||
st := openTestStore(t)
|
st := openTestStore(t)
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *store) InsertDiscardDetails(record map[string]any, raw []byte, clientInfo mqttClientInfo) error {
|
||||||
|
details, err := discardDetailsFromRecord(record, raw, clientInfo)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.db.Create(details).Error; err != nil {
|
||||||
|
return fmt.Errorf("insert discard_details: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func discardDetailsFromRecord(record map[string]any, raw []byte, clientInfo mqttClientInfo) (*discardDetailsRecord, error) {
|
||||||
|
contentJSON, err := json.Marshal(record)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode discard_details content_json: %w", err)
|
||||||
|
}
|
||||||
|
return &discardDetailsRecord{
|
||||||
|
Topic: stringValue(record["topic"]),
|
||||||
|
Error: stringValue(record["error"]),
|
||||||
|
PayloadLen: int64(len(raw)),
|
||||||
|
RawBase64: base64.StdEncoding.EncodeToString(raw),
|
||||||
|
ContentJSON: string(contentJSON),
|
||||||
|
MQTTClientID: nullableStringValue(clientInfo.ClientID),
|
||||||
|
MQTTUsername: nullableStringValue(clientInfo.Username),
|
||||||
|
MQTTListener: nullableStringValue(clientInfo.Listener),
|
||||||
|
MQTTRemoteAddr: nullableStringValue(clientInfo.RemoteAddr),
|
||||||
|
MQTTRemoteHost: nullableStringValue(clientInfo.RemoteHost),
|
||||||
|
MQTTRemotePort: nullableStringValue(clientInfo.RemotePort),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringValue(value any) string {
|
||||||
|
if s := nullableStringValue(value); s != nil {
|
||||||
|
return *s
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -54,6 +54,11 @@ func (h *meshtasticFilterHook) OnPublish(cl *mqtt.Client, pk packets.Packet) (pa
|
|||||||
valid, _, record := mqtpp.MQTTPP(pk.TopicName, pk.Payload, h.key)
|
valid, _, record := mqtpp.MQTTPP(pk.TopicName, pk.Payload, h.key)
|
||||||
if !valid {
|
if !valid {
|
||||||
h.stats.IncDropped()
|
h.stats.IncDropped()
|
||||||
|
if h.store != nil {
|
||||||
|
if err := h.store.InsertDiscardDetails(record, pk.Payload, mqttClientInfoFromClient(cl)); err != nil {
|
||||||
|
printJSON(map[string]any{"event": "db_error", "type": "discard_details", "topic": pk.TopicName, "error": err.Error()})
|
||||||
|
}
|
||||||
|
}
|
||||||
return pk, packets.ErrRejectPacket
|
return pk, packets.ErrRejectPacket
|
||||||
}
|
}
|
||||||
h.stats.IncForwarded()
|
h.stats.IncForwarded()
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { adminLogout, deleteNode, deleteTextMessage, getAdminMe, getHealth, getMapReports, getNodeInfo, getPositions, getTextMessages } from './api'
|
import { adminLogout, deleteNode, deleteTextMessage, getAdminMe, getHealth, getMapReports, getNodeInfo, getPositions, getTextMessages } from './api'
|
||||||
import AdminDashboard from './components/AdminDashboard.vue'
|
import AdminDashboard from './components/AdminDashboard.vue'
|
||||||
|
import AdminDiscardDetails from './components/AdminDiscardDetails.vue'
|
||||||
import AdminLogin from './components/AdminLogin.vue'
|
import AdminLogin from './components/AdminLogin.vue'
|
||||||
import AdminLoginLogs from './components/AdminLoginLogs.vue'
|
import AdminLoginLogs from './components/AdminLoginLogs.vue'
|
||||||
import AdminUsers from './components/AdminUsers.vue'
|
import AdminUsers from './components/AdminUsers.vue'
|
||||||
import ChatPanel from './components/ChatPanel.vue'
|
import ChatPanel from './components/ChatPanel.vue'
|
||||||
|
import HelpPage from './components/HelpPage.vue'
|
||||||
import MeshMap from './components/MeshMap.vue'
|
import MeshMap from './components/MeshMap.vue'
|
||||||
import NodeDetailedPage from './components/NodeDetailedPage.vue'
|
import NodeDetailedPage from './components/NodeDetailedPage.vue'
|
||||||
import NodeListPanel from './components/NodeListPanel.vue'
|
import NodeListPanel from './components/NodeListPanel.vue'
|
||||||
@@ -17,6 +19,7 @@ const isAdminPage = adminPath.startsWith('/admin')
|
|||||||
const detailMatch = currentPath.match(/^\/detailed\/(.+)$/)
|
const detailMatch = currentPath.match(/^\/detailed\/(.+)$/)
|
||||||
const detailedNodeId = detailMatch ? decodeURIComponent(detailMatch[1]) : ''
|
const detailedNodeId = detailMatch ? decodeURIComponent(detailMatch[1]) : ''
|
||||||
const isDetailedPage = !!detailedNodeId
|
const isDetailedPage = !!detailedNodeId
|
||||||
|
const isHelpPage = currentPath === '/help'
|
||||||
const adminUser = ref<AdminUser | null>(null)
|
const adminUser = ref<AdminUser | null>(null)
|
||||||
const adminChecking = ref(false)
|
const adminChecking = ref(false)
|
||||||
|
|
||||||
@@ -217,7 +220,7 @@ onMounted(() => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
checkAdminSession()
|
checkAdminSession()
|
||||||
if (isDetailedPage) {
|
if (isDetailedPage || isHelpPage) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
refresh()
|
refresh()
|
||||||
@@ -237,6 +240,7 @@ onBeforeUnmount(() => {
|
|||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Meshtastic MQTT Server</p>
|
<p class="eyebrow">Meshtastic MQTT Server</p>
|
||||||
<h1 v-if="isDetailedPage">节点详情</h1>
|
<h1 v-if="isDetailedPage">节点详情</h1>
|
||||||
|
<h1 v-else-if="isHelpPage">使用帮助</h1>
|
||||||
<h1 v-else>{{ isAdminPage ? 'Admin' : 'MeshMap' }}</h1>
|
<h1 v-else>{{ isAdminPage ? 'Admin' : 'MeshMap' }}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="topbar-actions">
|
<div class="topbar-actions">
|
||||||
@@ -245,6 +249,7 @@ onBeforeUnmount(() => {
|
|||||||
<a href="/admin" :class="{ active: adminPath === '/admin' }">服务状态</a>
|
<a href="/admin" :class="{ active: adminPath === '/admin' }">服务状态</a>
|
||||||
<a href="/admin/users" :class="{ active: adminPath === '/admin/users' }">用户管理</a>
|
<a href="/admin/users" :class="{ active: adminPath === '/admin/users' }">用户管理</a>
|
||||||
<a href="/admin/log/login" :class="{ active: adminPath === '/admin/log/login' }">登录日志</a>
|
<a href="/admin/log/login" :class="{ active: adminPath === '/admin/log/login' }">登录日志</a>
|
||||||
|
<a href="/admin/discard_details" :class="{ active: adminPath === '/admin/discard_details' }">丢弃数据</a>
|
||||||
</nav>
|
</nav>
|
||||||
<a class="topbar-link" href="/">返回地图</a>
|
<a class="topbar-link" href="/">返回地图</a>
|
||||||
</template>
|
</template>
|
||||||
@@ -253,10 +258,12 @@ onBeforeUnmount(() => {
|
|||||||
<a class="topbar-link" href="/">返回地图</a>
|
<a class="topbar-link" href="/">返回地图</a>
|
||||||
<a class="topbar-link" href="/admin">管理</a>
|
<a class="topbar-link" href="/admin">管理</a>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="isHelpPage">
|
||||||
|
<a class="topbar-link" href="/">返回地图</a>
|
||||||
|
<a class="topbar-link" href="/admin">管理</a>
|
||||||
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<span class="status-pill" :class="{ ok: health?.status === 'ok' }">
|
<a class="topbar-link" href="/help">使用帮助</a>
|
||||||
{{ health?.status ?? 'unknown' }} / db {{ health?.database ?? 'unknown' }}
|
|
||||||
</span>
|
|
||||||
<span class="counter">节点 {{ nodeTotal }} · 已加载消息 {{ messages.length }} · 坐标 {{ mapNodes.length }}</span>
|
<span class="counter">节点 {{ nodeTotal }} · 已加载消息 {{ messages.length }} · 坐标 {{ mapNodes.length }}</span>
|
||||||
<a class="topbar-link" href="/admin">管理</a>
|
<a class="topbar-link" href="/admin">管理</a>
|
||||||
<button @click="() => refresh()" :disabled="loading">{{ loading ? '刷新中...' : '刷新' }}</button>
|
<button @click="() => refresh()" :disabled="loading">{{ loading ? '刷新中...' : '刷新' }}</button>
|
||||||
@@ -276,6 +283,7 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
<AdminUsers v-if="adminPath === '/admin/users'" :user="adminUser" />
|
<AdminUsers v-if="adminPath === '/admin/users'" :user="adminUser" />
|
||||||
<AdminLoginLogs v-else-if="adminPath === '/admin/log/login'" />
|
<AdminLoginLogs v-else-if="adminPath === '/admin/log/login'" />
|
||||||
|
<AdminDiscardDetails v-else-if="adminPath === '/admin/discard_details'" />
|
||||||
<AdminDashboard v-else />
|
<AdminDashboard v-else />
|
||||||
</template>
|
</template>
|
||||||
<AdminLogin v-else @login="adminUser = $event" />
|
<AdminLogin v-else @login="adminUser = $event" />
|
||||||
@@ -285,6 +293,10 @@ onBeforeUnmount(() => {
|
|||||||
<NodeDetailedPage :node-id="detailedNodeId" :is-admin="!!adminUser" />
|
<NodeDetailedPage :node-id="detailedNodeId" :is-admin="!!adminUser" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="isHelpPage">
|
||||||
|
<HelpPage />
|
||||||
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<p v-if="error" class="error">{{ error }}</p>
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
AdminManagedUserResponse,
|
AdminManagedUserResponse,
|
||||||
AdminMqttStatus,
|
AdminMqttStatus,
|
||||||
AdminUsersResponse,
|
AdminUsersResponse,
|
||||||
|
DiscardDetails,
|
||||||
HealthStatus,
|
HealthStatus,
|
||||||
ListResponse,
|
ListResponse,
|
||||||
MapReport,
|
MapReport,
|
||||||
@@ -98,6 +99,10 @@ export function getPositions(limit = 500, offset = 0, nodeId = ''): Promise<List
|
|||||||
return getJSON<ListResponse<PositionRecord>>(listPath('/api/positions', limit, offset, nodeId))
|
return getJSON<ListResponse<PositionRecord>>(listPath('/api/positions', limit, offset, nodeId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getDiscardDetails(limit = 100, offset = 0): Promise<ListResponse<DiscardDetails>> {
|
||||||
|
return getJSON<ListResponse<DiscardDetails>>(listPath('/api/discard-details', limit, offset))
|
||||||
|
}
|
||||||
|
|
||||||
export function getTelemetry(limit = 500, offset = 0, nodeId = ''): Promise<ListResponse<TelemetryRecord>> {
|
export function getTelemetry(limit = 500, offset = 0, nodeId = ''): Promise<ListResponse<TelemetryRecord>> {
|
||||||
return getJSON<ListResponse<TelemetryRecord>>(listPath('/api/telemetry', limit, offset, nodeId))
|
return getJSON<ListResponse<TelemetryRecord>>(listPath('/api/telemetry', limit, offset, nodeId))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ onBeforeUnmount(() => {
|
|||||||
<div><span>当前连接</span><strong>{{ status.clients_connected }}</strong></div>
|
<div><span>当前连接</span><strong>{{ status.clients_connected }}</strong></div>
|
||||||
<div><span>订阅数</span><strong>{{ status.subscriptions }}</strong></div>
|
<div><span>订阅数</span><strong>{{ status.subscriptions }}</strong></div>
|
||||||
<div><span>转发消息</span><strong>{{ status.messages_sent }}</strong></div>
|
<div><span>转发消息</span><strong>{{ status.messages_sent }}</strong></div>
|
||||||
<div><span>丢弃消息</span><strong>{{ status.messages_dropped }}</strong></div>
|
<a class="status-card-link" href="/admin/discard_details"><span>丢弃消息</span><strong>{{ status.messages_dropped }}</strong></a>
|
||||||
<div><span>收到包</span><strong>{{ status.packets_received }}</strong></div>
|
<div><span>收到包</span><strong>{{ status.packets_received }}</strong></div>
|
||||||
<div><span>发送包</span><strong>{{ status.packets_sent }}</strong></div>
|
<div><span>发送包</span><strong>{{ status.packets_sent }}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { getDiscardDetails } from '../api'
|
||||||
|
import type { DiscardDetails } from '../types'
|
||||||
|
|
||||||
|
const items = ref<DiscardDetails[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = 25
|
||||||
|
|
||||||
|
const canPrev = () => page.value > 1
|
||||||
|
const canNext = () => items.value.length === pageSize
|
||||||
|
|
||||||
|
function formatTime(value: string): string {
|
||||||
|
return new Date(value).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshItems() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const response = await getDiscardDetails(pageSize, (page.value - 1) * pageSize)
|
||||||
|
items.value = response.items
|
||||||
|
} 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(refreshItems)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="admin-dashboard">
|
||||||
|
<div class="panel admin-status-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Discard details</p>
|
||||||
|
<h2>丢弃数据</h2>
|
||||||
|
</div>
|
||||||
|
<button class="admin-button" @click="refreshItems" :disabled="loading">{{ loading ? '刷新中...' : '刷新数据' }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
|
<div class="node-table-wrap">
|
||||||
|
<table class="node-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>时间</th>
|
||||||
|
<th>Topic</th>
|
||||||
|
<th>Error</th>
|
||||||
|
<th>Payload Len</th>
|
||||||
|
<th>Client ID</th>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Listener</th>
|
||||||
|
<th>Remote Host</th>
|
||||||
|
<th>Raw Base64</th>
|
||||||
|
<th>Content JSON</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="item in items" :key="item.id">
|
||||||
|
<td>{{ formatTime(item.created_at) }}</td>
|
||||||
|
<td>{{ item.topic || '-' }}</td>
|
||||||
|
<td>{{ item.error || '-' }}</td>
|
||||||
|
<td>{{ item.payload_len }}</td>
|
||||||
|
<td>{{ item.mqtt_client_id || '-' }}</td>
|
||||||
|
<td>{{ item.mqtt_username || '-' }}</td>
|
||||||
|
<td>{{ item.mqtt_listener || '-' }}</td>
|
||||||
|
<td>{{ item.mqtt_remote_host || '-' }}</td>
|
||||||
|
<td><pre class="discard-raw">{{ item.raw_base64 }}</pre></td>
|
||||||
|
<td><pre class="discard-json">{{ item.content_json }}</pre></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div v-if="items.length === 0" class="empty">暂无丢弃数据</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<template>
|
||||||
|
<section class="help-page">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Help</p>
|
||||||
|
<h2>如何连接 MQTT</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-content">
|
||||||
|
<section>
|
||||||
|
<h3>连接地址</h3>
|
||||||
|
<p>将 Meshtastic 设备或客户端连接到本服务提供的 MQTT broker。</p>
|
||||||
|
<ul>
|
||||||
|
<li>默认地址:<strong>localhost</strong></li>
|
||||||
|
<li>默认端口:<strong>1883</strong></li>
|
||||||
|
<li>如果部署在其他机器,请使用服务器 IP 或域名。</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>频道加密要求</h3>
|
||||||
|
<p>为了让服务能够解析 Meshtastic MQTT payload,频道需要满足以下任一条件:</p>
|
||||||
|
<ul>
|
||||||
|
<li>频道不加密。</li>
|
||||||
|
<li>使用 Meshtastic 默认 PSK:<strong>AQ==</strong>。</li>
|
||||||
|
</ul>
|
||||||
|
<p>如果使用自定义加密密钥,需要在服务配置中设置对应的 <strong>meshtastic.psk</strong>,否则数据可能会被判定为无法解密并丢弃。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>Topic 说明</h3>
|
||||||
|
<p>客户端发布到 Meshtastic MQTT topic 后,服务会先解析 payload。解析成功的数据会被转发并写入数据库;解析失败的数据会被丢弃并记录到后台的丢弃数据页面。</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -343,11 +343,29 @@ h3 {
|
|||||||
min-height: 240px;
|
min-height: 240px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-page {
|
.detail-page,
|
||||||
|
.help-page {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.help-content {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 18px;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-content section {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-content ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.detail-section-grid {
|
.detail-section-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(320px, 1fr) minmax(320px, 1fr);
|
grid-template-columns: minmax(320px, 1fr) minmax(320px, 1fr);
|
||||||
@@ -605,7 +623,8 @@ h3 {
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-status-grid div {
|
.admin-status-grid div,
|
||||||
|
.status-card-link {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
@@ -614,15 +633,39 @@ h3 {
|
|||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-status-grid span {
|
.status-card-link {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card-link:hover {
|
||||||
|
border-color: #bfdbfe;
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-status-grid span,
|
||||||
|
.status-card-link span {
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-status-grid strong {
|
.admin-status-grid strong,
|
||||||
|
.status-card-link strong {
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.discard-raw,
|
||||||
|
.discard-json {
|
||||||
|
max-width: 360px;
|
||||||
|
max-height: 120px;
|
||||||
|
margin: 0;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.pagination {
|
.pagination {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -116,6 +116,22 @@ export interface AdminManagedUserResponse {
|
|||||||
user: AdminManagedUser
|
user: AdminManagedUser
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DiscardDetails {
|
||||||
|
id: number
|
||||||
|
topic: string
|
||||||
|
error: string
|
||||||
|
payload_len: number
|
||||||
|
raw_base64: string
|
||||||
|
mqtt_client_id: string | null
|
||||||
|
mqtt_username: string | null
|
||||||
|
mqtt_listener: string | null
|
||||||
|
mqtt_remote_addr: string | null
|
||||||
|
mqtt_remote_host: string | null
|
||||||
|
mqtt_remote_port: string | null
|
||||||
|
created_at: string
|
||||||
|
content_json: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminLoginLog {
|
export interface AdminLoginLog {
|
||||||
id: number
|
id: number
|
||||||
username: string
|
username: string
|
||||||
|
|||||||
+2
-1
@@ -128,7 +128,8 @@ func MQTTPP(topic string, raw []byte, key []byte) (bool, []byte, map[string]any)
|
|||||||
return false, nil, map[string]any{"topic": topic, "error": err.Error(), "payload_len": len(raw)}
|
return false, nil, map[string]any{"topic": topic, "error": err.Error(), "payload_len": len(raw)}
|
||||||
}
|
}
|
||||||
if record["type"] == "encrypted_packet" {
|
if record["type"] == "encrypted_packet" {
|
||||||
return false, nil, map[string]any{"topic": topic, "error": "cannot be decrypted", "payload_len": len(raw)}
|
record["error"] = "cannot be decrypted"
|
||||||
|
return false, nil, record
|
||||||
}
|
}
|
||||||
|
|
||||||
return true, raw, record
|
return true, raw, record
|
||||||
|
|||||||
@@ -118,6 +118,33 @@ func (s *store) ListTextMessages(opts listOptions) ([]textMessageRecord, error)
|
|||||||
return rows, s.listAppendRows(opts, &rows).Error
|
return rows, s.listAppendRows(opts, &rows).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *store) ListDiscardDetails(opts listOptions) ([]discardDetailsRecord, error) {
|
||||||
|
opts = normalizeListOptions(opts)
|
||||||
|
var rows []discardDetailsRecord
|
||||||
|
q := applyDiscardDetailsFilters(s.db.Model(&discardDetailsRecord{}), opts).
|
||||||
|
Order("created_at DESC").
|
||||||
|
Order("id DESC").
|
||||||
|
Limit(opts.Limit).
|
||||||
|
Offset(opts.Offset)
|
||||||
|
return rows, q.Find(&rows).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) CountDiscardDetails(opts listOptions) (int64, error) {
|
||||||
|
var total int64
|
||||||
|
q := applyDiscardDetailsFilters(s.db.Model(&discardDetailsRecord{}), opts)
|
||||||
|
return total, q.Count(&total).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyDiscardDetailsFilters(q *gorm.DB, opts listOptions) *gorm.DB {
|
||||||
|
if opts.Since != nil {
|
||||||
|
q = q.Where("created_at >= ?", *opts.Since)
|
||||||
|
}
|
||||||
|
if opts.Until != nil {
|
||||||
|
q = q.Where("created_at <= ?", *opts.Until)
|
||||||
|
}
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
|
||||||
func (s *store) DeleteTextMessage(id uint64) error {
|
func (s *store) DeleteTextMessage(id uint64) error {
|
||||||
result := s.db.Where("id = ?", id).Delete(&textMessageRecord{})
|
result := s.db.Where("id = ?", id).Delete(&textMessageRecord{})
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
|
|||||||
@@ -54,6 +54,14 @@ func registerAPIRoutes(r gin.IRouter, store *store) {
|
|||||||
rows, err := store.ListTextMessages(opts)
|
rows, err := store.ListTextMessages(opts)
|
||||||
writeListResponse(c, rows, opts, err, textMessageDTO)
|
writeListResponse(c, rows, opts, err, textMessageDTO)
|
||||||
})
|
})
|
||||||
|
r.GET("/discard-details", func(c *gin.Context) {
|
||||||
|
opts, ok := parseListOptions(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := store.ListDiscardDetails(opts)
|
||||||
|
writeListResponse(c, rows, opts, err, discardDetailsDTO)
|
||||||
|
})
|
||||||
r.GET("/positions", func(c *gin.Context) {
|
r.GET("/positions", func(c *gin.Context) {
|
||||||
opts, ok := parseListOptions(c)
|
opts, ok := parseListOptions(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -157,7 +165,14 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager,
|
|||||||
c.JSON(http.StatusOK, adminMqttStatus{Running: false})
|
c.JSON(http.StatusOK, adminMqttStatus{Running: false})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, mqttStatus.Status())
|
status := mqttStatus.Status()
|
||||||
|
discardCount, err := store.CountDiscardDetails(listOptions{})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
status.MessagesDropped = discardCount
|
||||||
|
c.JSON(http.StatusOK, status)
|
||||||
})
|
})
|
||||||
protected.GET("/users", func(c *gin.Context) {
|
protected.GET("/users", func(c *gin.Context) {
|
||||||
users, err := store.ListUsers()
|
users, err := store.ListUsers()
|
||||||
@@ -418,6 +433,10 @@ func textMessageDTO(row textMessageRecord) gin.H {
|
|||||||
return gin.H{"id": row.ID, "from_id": row.FromID, "from_num": row.FromNum, "text": ptrString(row.Text), "topic": row.Topic, "created_at": row.CreatedAt, "mqtt_remote_host": ptrString(row.MQTTRemoteHost), "content_json": row.ContentJSON}
|
return gin.H{"id": row.ID, "from_id": row.FromID, "from_num": row.FromNum, "text": ptrString(row.Text), "topic": row.Topic, "created_at": row.CreatedAt, "mqtt_remote_host": ptrString(row.MQTTRemoteHost), "content_json": row.ContentJSON}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func discardDetailsDTO(row discardDetailsRecord) gin.H {
|
||||||
|
return gin.H{"id": row.ID, "topic": row.Topic, "error": row.Error, "payload_len": row.PayloadLen, "raw_base64": row.RawBase64, "mqtt_client_id": ptrString(row.MQTTClientID), "mqtt_username": ptrString(row.MQTTUsername), "mqtt_listener": ptrString(row.MQTTListener), "mqtt_remote_addr": ptrString(row.MQTTRemoteAddr), "mqtt_remote_host": ptrString(row.MQTTRemoteHost), "mqtt_remote_port": ptrString(row.MQTTRemotePort), "created_at": row.CreatedAt, "content_json": row.ContentJSON}
|
||||||
|
}
|
||||||
|
|
||||||
func positionDTO(row positionRecord) gin.H {
|
func positionDTO(row positionRecord) gin.H {
|
||||||
return gin.H{"id": row.ID, "from_id": row.FromID, "from_num": row.FromNum, "latitude": ptrFloat64(row.Latitude), "longitude": ptrFloat64(row.Longitude), "altitude": ptrInt64(row.Altitude), "created_at": row.CreatedAt, "content_json": row.ContentJSON}
|
return gin.H{"id": row.ID, "from_id": row.FromID, "from_num": row.FromNum, "latitude": ptrFloat64(row.Latitude), "longitude": ptrFloat64(row.Longitude), "altitude": ptrInt64(row.Altitude), "created_at": row.CreatedAt, "content_json": row.ContentJSON}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user