基本功能差不多完成
This commit is contained in:
@@ -117,7 +117,7 @@ go run .
|
||||
|
||||
构建后的文件位于项目根目录 `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:
|
||||
|
||||
@@ -140,6 +140,7 @@ 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
|
||||
@@ -167,6 +168,7 @@ meshtastic:
|
||||
程序默认启用 SQLite,数据库表迁移和操作由 GORM 执行,并持久化以下数据:
|
||||
|
||||
- `login_log`:追加保存后台登录成功和失败日志
|
||||
- `discard_details`:追加保存 `MQTTPP` 判定无效而被 broker 丢弃的数据,raw payload 使用 base64 保存
|
||||
- `nodeinfo`:保存 `type == "nodeinfo"` 的节点身份和设备信息
|
||||
- `map_report`:保存 `type == "map_report"` 的地图报告信息,前端地图从该表读取
|
||||
- `text_message`:追加保存 `type == "text_message"` 的聊天消息
|
||||
@@ -252,7 +254,9 @@ database:
|
||||
|
||||
`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"
|
||||
}
|
||||
|
||||
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 {
|
||||
NodeID string `gorm:"column:node_id;primaryKey;not null"`
|
||||
NodeNum int64 `gorm:"column:node_num;not null;index"`
|
||||
@@ -285,6 +305,7 @@ func (s *store) migrate() error {
|
||||
}{
|
||||
{label: "users", model: &userRecord{}},
|
||||
{label: "login_log", model: &loginLogRecord{}},
|
||||
{label: "discard_details", model: &discardDetailsRecord{}},
|
||||
{label: "nodeinfo", model: &nodeInfoRecord{}},
|
||||
{label: "map_report", model: &mapReportRecord{}},
|
||||
{label: "text_message", model: &textMessageRecord{}},
|
||||
|
||||
+51
-1
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -14,7 +15,7 @@ func TestOpenStoreCreatesTables(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
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
|
||||
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)
|
||||
@@ -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) {
|
||||
st := openTestStore(t)
|
||||
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)
|
||||
if !valid {
|
||||
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
|
||||
}
|
||||
h.stats.IncForwarded()
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { adminLogout, deleteNode, deleteTextMessage, getAdminMe, getHealth, getMapReports, getNodeInfo, getPositions, getTextMessages } from './api'
|
||||
import AdminDashboard from './components/AdminDashboard.vue'
|
||||
import AdminDiscardDetails from './components/AdminDiscardDetails.vue'
|
||||
import AdminLogin from './components/AdminLogin.vue'
|
||||
import AdminLoginLogs from './components/AdminLoginLogs.vue'
|
||||
import AdminUsers from './components/AdminUsers.vue'
|
||||
import ChatPanel from './components/ChatPanel.vue'
|
||||
import HelpPage from './components/HelpPage.vue'
|
||||
import MeshMap from './components/MeshMap.vue'
|
||||
import NodeDetailedPage from './components/NodeDetailedPage.vue'
|
||||
import NodeListPanel from './components/NodeListPanel.vue'
|
||||
@@ -17,6 +19,7 @@ const isAdminPage = adminPath.startsWith('/admin')
|
||||
const detailMatch = currentPath.match(/^\/detailed\/(.+)$/)
|
||||
const detailedNodeId = detailMatch ? decodeURIComponent(detailMatch[1]) : ''
|
||||
const isDetailedPage = !!detailedNodeId
|
||||
const isHelpPage = currentPath === '/help'
|
||||
const adminUser = ref<AdminUser | null>(null)
|
||||
const adminChecking = ref(false)
|
||||
|
||||
@@ -217,7 +220,7 @@ onMounted(() => {
|
||||
return
|
||||
}
|
||||
checkAdminSession()
|
||||
if (isDetailedPage) {
|
||||
if (isDetailedPage || isHelpPage) {
|
||||
return
|
||||
}
|
||||
refresh()
|
||||
@@ -237,6 +240,7 @@ onBeforeUnmount(() => {
|
||||
<div>
|
||||
<p class="eyebrow">Meshtastic MQTT Server</p>
|
||||
<h1 v-if="isDetailedPage">节点详情</h1>
|
||||
<h1 v-else-if="isHelpPage">使用帮助</h1>
|
||||
<h1 v-else>{{ isAdminPage ? 'Admin' : 'MeshMap' }}</h1>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
@@ -245,6 +249,7 @@ onBeforeUnmount(() => {
|
||||
<a href="/admin" :class="{ active: adminPath === '/admin' }">服务状态</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/discard_details" :class="{ active: adminPath === '/admin/discard_details' }">丢弃数据</a>
|
||||
</nav>
|
||||
<a class="topbar-link" href="/">返回地图</a>
|
||||
</template>
|
||||
@@ -253,10 +258,12 @@ onBeforeUnmount(() => {
|
||||
<a class="topbar-link" href="/">返回地图</a>
|
||||
<a class="topbar-link" href="/admin">管理</a>
|
||||
</template>
|
||||
<template v-else-if="isHelpPage">
|
||||
<a class="topbar-link" href="/">返回地图</a>
|
||||
<a class="topbar-link" href="/admin">管理</a>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="status-pill" :class="{ ok: health?.status === 'ok' }">
|
||||
{{ health?.status ?? 'unknown' }} / db {{ health?.database ?? 'unknown' }}
|
||||
</span>
|
||||
<a class="topbar-link" href="/help">使用帮助</a>
|
||||
<span class="counter">节点 {{ nodeTotal }} · 已加载消息 {{ messages.length }} · 坐标 {{ mapNodes.length }}</span>
|
||||
<a class="topbar-link" href="/admin">管理</a>
|
||||
<button @click="() => refresh()" :disabled="loading">{{ loading ? '刷新中...' : '刷新' }}</button>
|
||||
@@ -276,6 +283,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<AdminUsers v-if="adminPath === '/admin/users'" :user="adminUser" />
|
||||
<AdminLoginLogs v-else-if="adminPath === '/admin/log/login'" />
|
||||
<AdminDiscardDetails v-else-if="adminPath === '/admin/discard_details'" />
|
||||
<AdminDashboard v-else />
|
||||
</template>
|
||||
<AdminLogin v-else @login="adminUser = $event" />
|
||||
@@ -285,6 +293,10 @@ onBeforeUnmount(() => {
|
||||
<NodeDetailedPage :node-id="detailedNodeId" :is-admin="!!adminUser" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="isHelpPage">
|
||||
<HelpPage />
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
AdminManagedUserResponse,
|
||||
AdminMqttStatus,
|
||||
AdminUsersResponse,
|
||||
DiscardDetails,
|
||||
HealthStatus,
|
||||
ListResponse,
|
||||
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))
|
||||
}
|
||||
|
||||
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>> {
|
||||
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.subscriptions }}</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_sent }}</strong></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;
|
||||
}
|
||||
|
||||
.detail-page {
|
||||
.detail-page,
|
||||
.help-page {
|
||||
display: grid;
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 1fr) minmax(320px, 1fr);
|
||||
@@ -605,7 +623,8 @@ h3 {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.admin-status-grid div {
|
||||
.admin-status-grid div,
|
||||
.status-card-link {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
border: 1px solid #e2e8f0;
|
||||
@@ -614,15 +633,39 @@ h3 {
|
||||
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;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.admin-status-grid strong {
|
||||
.admin-status-grid strong,
|
||||
.status-card-link strong {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -116,6 +116,22 @@ export interface AdminManagedUserResponse {
|
||||
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 {
|
||||
id: number
|
||||
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)}
|
||||
}
|
||||
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
|
||||
|
||||
@@ -118,6 +118,33 @@ func (s *store) ListTextMessages(opts listOptions) ([]textMessageRecord, 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 {
|
||||
result := s.db.Where("id = ?", id).Delete(&textMessageRecord{})
|
||||
if result.Error != nil {
|
||||
|
||||
@@ -54,6 +54,14 @@ func registerAPIRoutes(r gin.IRouter, store *store) {
|
||||
rows, err := store.ListTextMessages(opts)
|
||||
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) {
|
||||
opts, ok := parseListOptions(c)
|
||||
if !ok {
|
||||
@@ -157,7 +165,14 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager,
|
||||
c.JSON(http.StatusOK, adminMqttStatus{Running: false})
|
||||
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) {
|
||||
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}
|
||||
}
|
||||
|
||||
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 {
|
||||
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