基本功能差不多完成

This commit is contained in:
2026-06-04 09:52:57 +08:00
parent c441fed1b3
commit f73d79b7d4
15 changed files with 395 additions and 14 deletions
+6 -2
View File
@@ -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`
## 本地验证 ## 本地验证
+21
View File
@@ -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
View File
@@ -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()
+45
View File
@@ -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 ""
}
+5
View File
@@ -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()
+16 -4
View File
@@ -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>
+5
View File
@@ -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>
+47 -4
View File
@@ -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;
+16
View File
@@ -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
View File
@@ -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
+27
View File
@@ -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 {
+20 -1
View File
@@ -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}
} }