This commit is contained in:
2026-06-03 22:34:25 +08:00
parent f471905b33
commit 3ae2ffa098
14 changed files with 600 additions and 244 deletions
+15 -9
View File
@@ -116,8 +116,12 @@ go run .
```text ```text
GET /api/health GET /api/health
GET /api/nodes GET /api/nodeinfo
GET /api/nodes/:id 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/text-messages
GET /api/positions GET /api/positions
GET /api/telemetry GET /api/telemetry
@@ -145,21 +149,23 @@ meshtastic:
程序默认启用 SQLite,数据库表迁移和操作由 GORM 执行,并持久化以下数据: 程序默认启用 SQLite,数据库表迁移和操作由 GORM 执行,并持久化以下数据:
- `nodeinfo_map`融合 `type == "nodeinfo"` `type == "map_report"` 的节点信息 - `nodeinfo`保存 `type == "nodeinfo"` 的节点身份和设备信息
- `map_report`:保存 `type == "map_report"` 的地图报告信息,前端地图从该表读取
- `text_message`:追加保存 `type == "text_message"` 的聊天消息 - `text_message`:追加保存 `type == "text_message"` 的聊天消息
- `position`:追加保存 `type == "position"` 的位置包 - `position`:追加保存 `type == "position"` 的位置包
- `telemetry`:追加保存 `type == "telemetry"` 的遥测包 - `telemetry`:追加保存 `type == "telemetry"` 的遥测包
- `routing`:追加保存 `type == "routing"` 的路由控制包 - `routing`:追加保存 `type == "routing"` 的路由控制包
- `traceroute`:追加保存 `type == "traceroute"` 的路径追踪包 - `traceroute`:追加保存 `type == "traceroute"` 的路径追踪包
`nodeinfo_map` 规则: `nodeinfo` / `map_report` 规则:
- `nodeinfo` 表不再使用;如果旧数据库中已经存在该表,程序不会自动删除它 - 两张表都以 `node_id`(即解析结果中的 `from`,例如 `!a8dfd867`)作为主键
- 同一节点以 `node_id`(即解析结果中的 `from`,例如 `!a8dfd867`)作为主键 - `nodeinfo` 只保存节点身份和设备字段,例如 `user_id`、名称、硬件型号、角色、授权状态和公钥
- 重复收到同一节点时不会插入重复行,只更新 `updated_at``content_json``latest_type` 和本次记录中有值的字段 - `map_report` 只保存地图报告字段,例如名称、硬件型号、角色、固件版本、区域、调制预设、经纬度、海拔、位置精度和在线节点数
- `nodeinfo` 独有字段和 `map_report` 独有字段会互相保留;例如后续 `map_report` 不会清空已有的 `public_key` - 重复收到同一节点时不会插入重复行,只更新 `updated_at``content_json` 和本次记录中有值的字段
- `first_seen_at` 保留第一次写入时间 - `first_seen_at` 保留第一次写入时间
- `content_json` 保存最新一次 `nodeinfo``map_report` 的完整解析结果 JSON - `content_json` 分别保存最新一次 `nodeinfo``map_report` 的完整解析结果 JSON
- 旧版本创建的 `nodeinfo_map` 融合表不会被自动删除,新版本不再写入该表;新表会从新收到的数据开始填充
`text_message` 规则: `text_message` 规则:
+137 -48
View File
@@ -62,10 +62,9 @@ type MQTTClientRecordFields struct {
MQTTRemotePort *string `gorm:"column:mqtt_remote_port"` MQTTRemotePort *string `gorm:"column:mqtt_remote_port"`
} }
type nodeInfoMapRecord 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"` NodeNum int64 `gorm:"column:node_num;not null;index"`
LatestType string `gorm:"column:latest_type;not null"`
UserID *string `gorm:"column:user_id"` UserID *string `gorm:"column:user_id"`
LongName *string `gorm:"column:long_name"` LongName *string `gorm:"column:long_name"`
ShortName *string `gorm:"column:short_name"` ShortName *string `gorm:"column:short_name"`
@@ -73,22 +72,38 @@ type nodeInfoMapRecord struct {
Role *string `gorm:"column:role"` Role *string `gorm:"column:role"`
IsLicensed *bool `gorm:"column:is_licensed"` IsLicensed *bool `gorm:"column:is_licensed"`
PublicKey *string `gorm:"column:public_key"` PublicKey *string `gorm:"column:public_key"`
ContentJSON string `gorm:"column:content_json;not null"`
FirstSeenAt time.Time `gorm:"column:first_seen_at;autoCreateTime"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;index"`
}
func (nodeInfoRecord) TableName() string {
return "nodeinfo"
}
type mapReportRecord struct {
NodeID string `gorm:"column:node_id;primaryKey;not null"`
NodeNum int64 `gorm:"column:node_num;not null;index"`
LongName *string `gorm:"column:long_name"`
ShortName *string `gorm:"column:short_name"`
HWModel *string `gorm:"column:hw_model"`
Role *string `gorm:"column:role"`
FirmwareVersion *string `gorm:"column:firmware_version"` FirmwareVersion *string `gorm:"column:firmware_version"`
Region *string `gorm:"column:region"` Region *string `gorm:"column:region"`
ModemPreset *string `gorm:"column:modem_preset"` ModemPreset *string `gorm:"column:modem_preset"`
Latitude *float64 `gorm:"column:latitude"` Latitude *float64 `gorm:"column:latitude;index"`
Longitude *float64 `gorm:"column:longitude"` Longitude *float64 `gorm:"column:longitude;index"`
Altitude *int64 `gorm:"column:altitude"` Altitude *int64 `gorm:"column:altitude"`
PositionPrecision *int64 `gorm:"column:position_precision"` PositionPrecision *int64 `gorm:"column:position_precision"`
NumOnlineLocalNodes *int64 `gorm:"column:num_online_local_nodes"` NumOnlineLocalNodes *int64 `gorm:"column:num_online_local_nodes"`
HasOptedReportLocation *bool `gorm:"column:has_opted_report_location"` HasOptedReportLocation *bool `gorm:"column:has_opted_report_location"`
ContentJSON string `gorm:"column:content_json;not null"` ContentJSON string `gorm:"column:content_json;not null"`
FirstSeenAt time.Time `gorm:"column:first_seen_at;autoCreateTime"` FirstSeenAt time.Time `gorm:"column:first_seen_at;autoCreateTime"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"` UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;index"`
} }
func (nodeInfoMapRecord) TableName() string { func (mapReportRecord) TableName() string {
return "nodeinfo_map" return "map_report"
} }
type textMessageRecord struct { type textMessageRecord struct {
@@ -239,7 +254,8 @@ func (s *store) migrate() error {
label string label string
model any model any
}{ }{
{label: "nodeinfo_map", model: &nodeInfoMapRecord{}}, {label: "nodeinfo", model: &nodeInfoRecord{}},
{label: "map_report", model: &mapReportRecord{}},
{label: "text_message", model: &textMessageRecord{}}, {label: "text_message", model: &textMessageRecord{}},
{label: "position", model: &positionRecord{}}, {label: "position", model: &positionRecord{}},
{label: "telemetry", model: &telemetryRecord{}}, {label: "telemetry", model: &telemetryRecord{}},
@@ -278,43 +294,75 @@ func createMissingIndexes(migrator gorm.Migrator, model any, label string, index
return nil return nil
} }
func (s *store) UpsertNodeInfoMap(record map[string]any) error { func (s *store) UpsertNodeInfo(record map[string]any) error {
node, err := nodeInfoMapFromRecord(record) node, err := nodeInfoFromRecord(record)
if err != nil { if err != nil {
return err return err
} }
if err := s.upsertNodeInfoMapRecord(node); err != nil { if err := s.upsertNodeInfoRecord(node); err != nil {
return fmt.Errorf("upsert nodeinfo_map %s: %w", node.NodeID, err) return fmt.Errorf("upsert nodeinfo %s: %w", node.NodeID, err)
} }
return nil return nil
} }
func (s *store) upsertNodeInfoMapRecord(node *nodeInfoMapRecord) error { func (s *store) UpsertMapReport(record map[string]any) error {
report, err := mapReportFromRecord(record)
if err != nil {
return err
}
if err := s.upsertMapReportRecord(report); err != nil {
return fmt.Errorf("upsert map_report %s: %w", report.NodeID, err)
}
return nil
}
func (s *store) upsertNodeInfoRecord(node *nodeInfoRecord) error {
return s.db.Transaction(func(tx *gorm.DB) error { return s.db.Transaction(func(tx *gorm.DB) error {
var existing nodeInfoMapRecord var existing nodeInfoRecord
err := tx.Where("node_id = ?", node.NodeID).Take(&existing).Error err := tx.Where("node_id = ?", node.NodeID).Take(&existing).Error
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
if err := tx.Create(node).Error; err != nil { if err := tx.Create(node).Error; err != nil {
return s.updateNodeInfoMapRecord(tx, node) return s.updateNodeInfoRecord(tx, node)
} }
return nil return nil
} }
if err != nil { if err != nil {
return err return err
} }
return s.updateNodeInfoMapRecord(tx, node) return s.updateNodeInfoRecord(tx, node)
}) })
} }
func (s *store) updateNodeInfoMapRecord(tx *gorm.DB, node *nodeInfoMapRecord) error { func (s *store) upsertMapReportRecord(report *mapReportRecord) error {
updates := nodeInfoMapUpdates(node) return s.db.Transaction(func(tx *gorm.DB) error {
return tx.Model(&nodeInfoMapRecord{}).Where("node_id = ?", node.NodeID).Updates(updates).Error var existing mapReportRecord
err := tx.Where("node_id = ?", report.NodeID).Take(&existing).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
if err := tx.Create(report).Error; err != nil {
return s.updateMapReportRecord(tx, report)
}
return nil
}
if err != nil {
return err
}
return s.updateMapReportRecord(tx, report)
})
} }
func nodeInfoMapUpdates(node *nodeInfoMapRecord) map[string]any { func (s *store) updateNodeInfoRecord(tx *gorm.DB, node *nodeInfoRecord) error {
updates := nodeInfoUpdates(node)
return tx.Model(&nodeInfoRecord{}).Where("node_id = ?", node.NodeID).Updates(updates).Error
}
func (s *store) updateMapReportRecord(tx *gorm.DB, report *mapReportRecord) error {
updates := mapReportUpdates(report)
return tx.Model(&mapReportRecord{}).Where("node_id = ?", report.NodeID).Updates(updates).Error
}
func nodeInfoUpdates(node *nodeInfoRecord) map[string]any {
updates := map[string]any{ updates := map[string]any{
"node_num": node.NodeNum, "node_num": node.NodeNum,
"latest_type": node.LatestType,
"content_json": node.ContentJSON, "content_json": node.ContentJSON,
"updated_at": time.Now(), "updated_at": time.Now(),
} }
@@ -325,15 +373,28 @@ func nodeInfoMapUpdates(node *nodeInfoMapRecord) map[string]any {
addStringUpdate(updates, "role", node.Role) addStringUpdate(updates, "role", node.Role)
addBoolUpdate(updates, "is_licensed", node.IsLicensed) addBoolUpdate(updates, "is_licensed", node.IsLicensed)
addStringUpdate(updates, "public_key", node.PublicKey) addStringUpdate(updates, "public_key", node.PublicKey)
addStringUpdate(updates, "firmware_version", node.FirmwareVersion) return updates
addStringUpdate(updates, "region", node.Region) }
addStringUpdate(updates, "modem_preset", node.ModemPreset)
addFloat64Update(updates, "latitude", node.Latitude) func mapReportUpdates(report *mapReportRecord) map[string]any {
addFloat64Update(updates, "longitude", node.Longitude) updates := map[string]any{
addInt64Update(updates, "altitude", node.Altitude) "node_num": report.NodeNum,
addInt64Update(updates, "position_precision", node.PositionPrecision) "content_json": report.ContentJSON,
addInt64Update(updates, "num_online_local_nodes", node.NumOnlineLocalNodes) "updated_at": time.Now(),
addBoolUpdate(updates, "has_opted_report_location", node.HasOptedReportLocation) }
addStringUpdate(updates, "long_name", report.LongName)
addStringUpdate(updates, "short_name", report.ShortName)
addStringUpdate(updates, "hw_model", report.HWModel)
addStringUpdate(updates, "role", report.Role)
addStringUpdate(updates, "firmware_version", report.FirmwareVersion)
addStringUpdate(updates, "region", report.Region)
addStringUpdate(updates, "modem_preset", report.ModemPreset)
addFloat64Update(updates, "latitude", report.Latitude)
addFloat64Update(updates, "longitude", report.Longitude)
addInt64Update(updates, "altitude", report.Altitude)
addInt64Update(updates, "position_precision", report.PositionPrecision)
addInt64Update(updates, "num_online_local_nodes", report.NumOnlineLocalNodes)
addBoolUpdate(updates, "has_opted_report_location", report.HasOptedReportLocation)
return updates return updates
} }
@@ -392,28 +453,19 @@ func (s *store) InsertTraceroute(record map[string]any, clientInfo mqttClientInf
return nil return nil
} }
func nodeInfoMapFromRecord(record map[string]any) (*nodeInfoMapRecord, error) { func nodeInfoFromRecord(record map[string]any) (*nodeInfoRecord, error) {
latestType, ok := record["type"].(string) recordType, ok := record["type"].(string)
if !ok || (latestType != "nodeinfo" && latestType != "map_report") { if !ok || recordType != "nodeinfo" {
return nil, fmt.Errorf("record type %v is not nodeinfo or map_report", record["type"]) return nil, fmt.Errorf("record type %v is not nodeinfo", record["type"])
} }
nodeID, ok := record["from"].(string) nodeID, nodeNum, contentJSON, err := nodeRecordBase(record, "nodeinfo")
if !ok || nodeID == "" {
return nil, fmt.Errorf("nodeinfo_map missing from")
}
nodeNum, err := int64FromAny(record["from_num"])
if err != nil { if err != nil {
return nil, fmt.Errorf("nodeinfo_map from_num: %w", err) return nil, err
}
contentJSON, err := json.Marshal(record)
if err != nil {
return nil, fmt.Errorf("encode nodeinfo_map content_json: %w", err)
} }
return &nodeInfoMapRecord{ return &nodeInfoRecord{
NodeID: nodeID, NodeID: nodeID,
NodeNum: nodeNum, NodeNum: nodeNum,
LatestType: latestType,
UserID: nullableString(record["user_id"]), UserID: nullableString(record["user_id"]),
LongName: nullableString(record["long_name"]), LongName: nullableString(record["long_name"]),
ShortName: nullableString(record["short_name"]), ShortName: nullableString(record["short_name"]),
@@ -421,6 +473,27 @@ func nodeInfoMapFromRecord(record map[string]any) (*nodeInfoMapRecord, error) {
Role: nullableString(record["role"]), Role: nullableString(record["role"]),
IsLicensed: nullableBool(record["is_licensed"]), IsLicensed: nullableBool(record["is_licensed"]),
PublicKey: nullableString(record["public_key"]), PublicKey: nullableString(record["public_key"]),
ContentJSON: contentJSON,
}, nil
}
func mapReportFromRecord(record map[string]any) (*mapReportRecord, error) {
recordType, ok := record["type"].(string)
if !ok || recordType != "map_report" {
return nil, fmt.Errorf("record type %v is not map_report", record["type"])
}
nodeID, nodeNum, contentJSON, err := nodeRecordBase(record, "map_report")
if err != nil {
return nil, err
}
return &mapReportRecord{
NodeID: nodeID,
NodeNum: nodeNum,
LongName: nullableString(record["long_name"]),
ShortName: nullableString(record["short_name"]),
HWModel: nullableString(record["hw_model"]),
Role: nullableString(record["role"]),
FirmwareVersion: nullableString(record["firmware_version"]), FirmwareVersion: nullableString(record["firmware_version"]),
Region: nullableString(record["region"]), Region: nullableString(record["region"]),
ModemPreset: nullableString(record["modem_preset"]), ModemPreset: nullableString(record["modem_preset"]),
@@ -430,10 +503,26 @@ func nodeInfoMapFromRecord(record map[string]any) (*nodeInfoMapRecord, error) {
PositionPrecision: nullableInt64(record["position_precision"]), PositionPrecision: nullableInt64(record["position_precision"]),
NumOnlineLocalNodes: nullableInt64(record["num_online_local_nodes"]), NumOnlineLocalNodes: nullableInt64(record["num_online_local_nodes"]),
HasOptedReportLocation: nullableBool(record["has_opted_report_location"]), HasOptedReportLocation: nullableBool(record["has_opted_report_location"]),
ContentJSON: string(contentJSON), ContentJSON: contentJSON,
}, nil }, nil
} }
func nodeRecordBase(record map[string]any, label string) (string, int64, string, error) {
nodeID, ok := record["from"].(string)
if !ok || nodeID == "" {
return "", 0, "", fmt.Errorf("%s missing from", label)
}
nodeNum, err := int64FromAny(record["from_num"])
if err != nil {
return "", 0, "", fmt.Errorf("%s from_num: %w", label, err)
}
contentJSON, err := json.Marshal(record)
if err != nil {
return "", 0, "", fmt.Errorf("encode %s content_json: %w", label, err)
}
return nodeID, nodeNum, string(contentJSON), nil
}
func textMessageFromRecord(record map[string]any, clientInfo mqttClientInfo) (*textMessageRecord, error) { func textMessageFromRecord(record map[string]any, clientInfo mqttClientInfo) (*textMessageRecord, error) {
recordType, ok := record["type"].(string) recordType, ok := record["type"].(string)
if !ok || recordType != "text_message" { if !ok || recordType != "text_message" {
+79 -75
View File
@@ -11,7 +11,7 @@ func TestOpenStoreCreatesTables(t *testing.T) {
st := openTestStore(t) st := openTestStore(t)
defer st.Close() defer st.Close()
for _, table := range []string{"nodeinfo_map", "text_message", "position", "telemetry", "routing", "traceroute"} { for _, table := range []string{"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)
@@ -22,160 +22,164 @@ func TestOpenStoreCreatesTables(t *testing.T) {
} }
var oldCount int var oldCount int
if err := rawTestDB(t, st).QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'nodeinfo'").Scan(&oldCount); err != nil { if err := rawTestDB(t, st).QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'nodeinfo_map'").Scan(&oldCount); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if oldCount != 0 { if oldCount != 0 {
t.Fatalf("old nodeinfo table count = %d, want 0", oldCount) t.Fatalf("nodeinfo_map table count = %d, want 0", oldCount)
} }
} }
func TestUpsertNodeInfoMapInsertsAndUpdatesSameNode(t *testing.T) { func TestUpsertNodeInfoInsertsAndUpdatesSameNode(t *testing.T) {
st := openTestStore(t) st := openTestStore(t)
defer st.Close() defer st.Close()
first := nodeInfoRecord("first name") first := nodeInfoTestRecord("first name")
if err := st.UpsertNodeInfoMap(first); err != nil { if err := st.UpsertNodeInfo(first); err != nil {
t.Fatalf("first UpsertNodeInfoMap() error = %v", err) t.Fatalf("first UpsertNodeInfo() error = %v", err)
} }
second := nodeInfoRecord("second name") second := nodeInfoTestRecord("second name")
second["short_name"] = "snd" second["short_name"] = "snd"
if err := st.UpsertNodeInfoMap(second); err != nil { if err := st.UpsertNodeInfo(second); err != nil {
t.Fatalf("second UpsertNodeInfoMap() error = %v", err) t.Fatalf("second UpsertNodeInfo() error = %v", err)
} }
var count int var count int
if err := rawTestDB(t, st).QueryRow("SELECT COUNT(*) FROM nodeinfo_map WHERE node_id = ?", "!12345678").Scan(&count); err != nil { if err := rawTestDB(t, st).QueryRow("SELECT COUNT(*) FROM nodeinfo WHERE node_id = ?", "!12345678").Scan(&count); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if count != 1 { if count != 1 {
t.Fatalf("node row count = %d, want 1", count) t.Fatalf("nodeinfo row count = %d, want 1", count)
} }
var latestType, longName, content string var longName, shortName, content string
if err := rawTestDB(t, st).QueryRow("SELECT latest_type, long_name, content_json FROM nodeinfo_map WHERE node_id = ?", "!12345678").Scan(&latestType, &longName, &content); err != nil { if err := rawTestDB(t, st).QueryRow("SELECT long_name, short_name, content_json FROM nodeinfo WHERE node_id = ?", "!12345678").Scan(&longName, &shortName, &content); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if latestType != "nodeinfo" { if longName != "second name" || shortName != "snd" {
t.Fatalf("latest_type = %q, want nodeinfo", latestType) t.Fatalf("nodeinfo names = %q/%q, want second name/snd", longName, shortName)
}
if longName != "second name" {
t.Fatalf("long_name = %q, want second name", longName)
} }
if !strings.Contains(content, "second name") { if !strings.Contains(content, "second name") {
t.Fatalf("content_json = %q, want updated content", content) t.Fatalf("content_json = %q, want updated content", content)
} }
} }
func TestUpsertNodeInfoMapMergesNodeInfoThenMapReport(t *testing.T) { func TestUpsertMapReportInsertsAndUpdatesSameNode(t *testing.T) {
st := openTestStore(t) st := openTestStore(t)
defer st.Close() defer st.Close()
if err := st.UpsertNodeInfoMap(nodeInfoRecord("node name")); err != nil { first := mapReportTestRecord("first map")
t.Fatalf("nodeinfo UpsertNodeInfoMap() error = %v", err) if err := st.UpsertMapReport(first); err != nil {
t.Fatalf("first UpsertMapReport() error = %v", err)
} }
if err := st.UpsertNodeInfoMap(mapReportRecord("map name")); err != nil {
t.Fatalf("map_report UpsertNodeInfoMap() error = %v", err) second := mapReportTestRecord("second map")
second["latitude"] = 43.5
if err := st.UpsertMapReport(second); err != nil {
t.Fatalf("second UpsertMapReport() error = %v", err)
} }
var count int var count int
if err := rawTestDB(t, st).QueryRow("SELECT COUNT(*) FROM nodeinfo_map WHERE node_id = ?", "!12345678").Scan(&count); err != nil { if err := rawTestDB(t, st).QueryRow("SELECT COUNT(*) FROM map_report WHERE node_id = ?", "!12345678").Scan(&count); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if count != 1 { if count != 1 {
t.Fatalf("node row count = %d, want 1", count) t.Fatalf("map_report row count = %d, want 1", count)
} }
var latestType, userID, publicKey, longName, firmware, content string var longName string
var latitude float64 var latitude float64
var opted sql.NullBool var opted sql.NullBool
if err := rawTestDB(t, st).QueryRow("SELECT latest_type, user_id, public_key, long_name, firmware_version, latitude, has_opted_report_location, content_json FROM nodeinfo_map WHERE node_id = ?", "!12345678").Scan(&latestType, &userID, &publicKey, &longName, &firmware, &latitude, &opted, &content); err != nil { if err := rawTestDB(t, st).QueryRow("SELECT long_name, latitude, has_opted_report_location FROM map_report WHERE node_id = ?", "!12345678").Scan(&longName, &latitude, &opted); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if latestType != "map_report" { if longName != "second map" || latitude != 43.5 {
t.Fatalf("latest_type = %q, want map_report", latestType) t.Fatalf("map_report row = %q/%v, want second map/43.5", longName, latitude)
}
if userID != "!12345678" || publicKey != "abcd" {
t.Fatalf("nodeinfo fields not preserved: user_id=%q public_key=%q", userID, publicKey)
}
if longName != "map name" {
t.Fatalf("long_name = %q, want map name", longName)
}
if firmware != "1.2.3" {
t.Fatalf("firmware = %q, want 1.2.3", firmware)
}
if latitude != 42.5 {
t.Fatalf("latitude = %v, want 42.5", latitude)
} }
if !opted.Valid || opted.Bool { if !opted.Valid || opted.Bool {
t.Fatalf("has_opted_report_location = %+v, want valid false", opted) t.Fatalf("has_opted_report_location = %+v, want valid false", opted)
} }
if !strings.Contains(content, "map_report") {
t.Fatalf("content_json = %q, want latest map_report content", content)
}
} }
func TestUpsertNodeInfoMapMergesMapReportThenNodeInfo(t *testing.T) { func TestNodeInfoAndMapReportAreStoredSeparately(t *testing.T) {
st := openTestStore(t) st := openTestStore(t)
defer st.Close() defer st.Close()
if err := st.UpsertNodeInfoMap(mapReportRecord("map name")); err != nil { if err := st.UpsertNodeInfo(nodeInfoTestRecord("node name")); err != nil {
t.Fatalf("map_report UpsertNodeInfoMap() error = %v", err) t.Fatalf("UpsertNodeInfo() error = %v", err)
} }
if err := st.UpsertNodeInfoMap(nodeInfoRecord("node name")); err != nil { if err := st.UpsertMapReport(mapReportTestRecord("map name")); err != nil {
t.Fatalf("nodeinfo UpsertNodeInfoMap() error = %v", err) t.Fatalf("UpsertMapReport() error = %v", err)
} }
var latestType, userID, longName, firmware string var nodeLongName, userID, publicKey string
var latitude float64 if err := rawTestDB(t, st).QueryRow("SELECT long_name, user_id, public_key FROM nodeinfo WHERE node_id = ?", "!12345678").Scan(&nodeLongName, &userID, &publicKey); err != nil {
if err := rawTestDB(t, st).QueryRow("SELECT latest_type, user_id, long_name, firmware_version, latitude FROM nodeinfo_map WHERE node_id = ?", "!12345678").Scan(&latestType, &userID, &longName, &firmware, &latitude); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if latestType != "nodeinfo" { if nodeLongName != "node name" || userID != "!12345678" || publicKey != "abcd" {
t.Fatalf("latest_type = %q, want nodeinfo", latestType) t.Fatalf("nodeinfo row = %q/%q/%q, want node fields", nodeLongName, userID, publicKey)
} }
if userID != "!12345678" {
t.Fatalf("user_id = %q, want !12345678", userID) var mapLongName, firmware string
var latitude float64
if err := rawTestDB(t, st).QueryRow("SELECT long_name, firmware_version, latitude FROM map_report WHERE node_id = ?", "!12345678").Scan(&mapLongName, &firmware, &latitude); err != nil {
t.Fatal(err)
} }
if longName != "node name" { if mapLongName != "map name" || firmware != "1.2.3" || latitude != 42.5 {
t.Fatalf("long_name = %q, want node name", longName) t.Fatalf("map_report row = %q/%q/%v, want map fields", mapLongName, firmware, latitude)
}
if firmware != "1.2.3" || latitude != 42.5 {
t.Fatalf("map fields not preserved: firmware=%q latitude=%v", firmware, latitude)
} }
} }
func TestUpsertNodeInfoMapRequiresNodeFields(t *testing.T) { func TestUpsertNodeInfoRequiresNodeFields(t *testing.T) {
st := openTestStore(t) st := openTestStore(t)
defer st.Close() defer st.Close()
if err := st.UpsertNodeInfoMap(map[string]any{"type": "nodeinfo", "from_num": 1}); err == nil || !strings.Contains(err.Error(), "from") { if err := st.UpsertNodeInfo(map[string]any{"type": "nodeinfo", "from_num": 1}); err == nil || !strings.Contains(err.Error(), "from") {
t.Fatalf("missing from error = %v, want from error", err) t.Fatalf("missing from error = %v, want from error", err)
} }
if err := st.UpsertNodeInfoMap(map[string]any{"type": "nodeinfo", "from": "!00000001"}); err == nil || !strings.Contains(err.Error(), "from_num") { if err := st.UpsertNodeInfo(map[string]any{"type": "nodeinfo", "from": "!00000001"}); err == nil || !strings.Contains(err.Error(), "from_num") {
t.Fatalf("missing from_num error = %v, want from_num error", err) t.Fatalf("missing from_num error = %v, want from_num error", err)
} }
} }
func TestNodeInfoMapFromRecordRejectsWrongType(t *testing.T) { func TestUpsertMapReportRequiresNodeFields(t *testing.T) {
_, err := nodeInfoMapFromRecord(map[string]any{"type": "text_message"}) st := openTestStore(t)
if err == nil { defer st.Close()
t.Fatalf("nodeInfoMapFromRecord() error = nil, want error")
if err := st.UpsertMapReport(map[string]any{"type": "map_report", "from_num": 1}); err == nil || !strings.Contains(err.Error(), "from") {
t.Fatalf("missing from error = %v, want from error", err)
}
if err := st.UpsertMapReport(map[string]any{"type": "map_report", "from": "!00000001"}); err == nil || !strings.Contains(err.Error(), "from_num") {
t.Fatalf("missing from_num error = %v, want from_num error", err)
} }
} }
func TestNodeInfoMapNullablePublicKey(t *testing.T) { func TestNodeInfoFromRecordRejectsWrongType(t *testing.T) {
_, err := nodeInfoFromRecord(map[string]any{"type": "map_report"})
if err == nil {
t.Fatalf("nodeInfoFromRecord() error = nil, want error")
}
}
func TestMapReportFromRecordRejectsWrongType(t *testing.T) {
_, err := mapReportFromRecord(map[string]any{"type": "nodeinfo"})
if err == nil {
t.Fatalf("mapReportFromRecord() error = nil, want error")
}
}
func TestNodeInfoNullablePublicKey(t *testing.T) {
st := openTestStore(t) st := openTestStore(t)
defer st.Close() defer st.Close()
record := map[string]any{"type": "nodeinfo", "from": "!00000001", "from_num": 1, "public_key": nil} record := map[string]any{"type": "nodeinfo", "from": "!00000001", "from_num": 1, "public_key": nil}
if err := st.UpsertNodeInfoMap(record); err != nil { if err := st.UpsertNodeInfo(record); err != nil {
t.Fatalf("UpsertNodeInfoMap() error = %v", err) t.Fatalf("UpsertNodeInfo() error = %v", err)
} }
var publicKey sql.NullString var publicKey sql.NullString
if err := rawTestDB(t, st).QueryRow("SELECT public_key FROM nodeinfo_map WHERE node_id = ?", "!00000001").Scan(&publicKey); err != nil { if err := rawTestDB(t, st).QueryRow("SELECT public_key FROM nodeinfo WHERE node_id = ?", "!00000001").Scan(&publicKey); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if publicKey.Valid { if publicKey.Valid {
@@ -437,7 +441,7 @@ func rawTestDB(t *testing.T, st *store) *sql.DB {
return db return db
} }
func nodeInfoRecord(longName string) map[string]any { func nodeInfoTestRecord(longName string) map[string]any {
return map[string]any{ return map[string]any{
"type": "nodeinfo", "type": "nodeinfo",
"from": "!12345678", "from": "!12345678",
@@ -452,7 +456,7 @@ func nodeInfoRecord(longName string) map[string]any {
} }
} }
func mapReportRecord(longName string) map[string]any { func mapReportTestRecord(longName string) map[string]any {
return map[string]any{ return map[string]any{
"type": "map_report", "type": "map_report",
"from": "!12345678", "from": "!12345678",
+8 -2
View File
@@ -56,9 +56,15 @@ func (h *meshtasticFilterHook) OnPublish(cl *mqtt.Client, pk packets.Packet) (pa
} }
switch record["type"] { switch record["type"] {
case "nodeinfo", "map_report": case "nodeinfo":
if h.store != nil { if h.store != nil {
if err := h.store.UpsertNodeInfoMap(record); err != nil { if err := h.store.UpsertNodeInfo(record); err != nil {
printJSON(map[string]any{"event": "db_error", "type": record["type"], "from": record["from"], "error": err.Error()})
}
}
case "map_report":
if h.store != nil {
if err := h.store.UpsertMapReport(record); err != nil {
printJSON(map[string]any{"event": "db_error", "type": record["type"], "from": record["from"], "error": err.Error()}) printJSON(map[string]any{"event": "db_error", "type": record["type"], "from": record["from"], "error": err.Error()})
} }
} }
+93 -29
View File
@@ -1,60 +1,118 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { getHealth, getNodes, getPositions, getTextMessages } from './api' import { getHealth, getMapReports, getNodeInfo, getPositions, getTextMessages } from './api'
import ChatPanel from './components/ChatPanel.vue' import ChatPanel from './components/ChatPanel.vue'
import MeshMap from './components/MeshMap.vue' import MeshMap from './components/MeshMap.vue'
import NodeListPanel from './components/NodeListPanel.vue' import NodeListPanel from './components/NodeListPanel.vue'
import type { HealthStatus, MapNode, NodeInfoById, NodeInfoMap, PositionRecord, TextMessage } from './types' import type { HealthStatus, MapNode, MapReport, NodeInfo, NodeInfoById, PositionRecord, TextMessage } from './types'
const loading = ref(true) const loading = ref(true)
const nodePageLoading = ref(false) const nodePageLoading = ref(false)
const error = ref('') const error = ref('')
const selectedNodeId = ref<string | null>(null) const selectedNodeId = ref<string | null>(null)
const health = ref<HealthStatus | null>(null) const health = ref<HealthStatus | null>(null)
const mapNodeSource = ref<NodeInfoMap[]>([]) const nodeInfoSource = ref<NodeInfo[]>([])
const pagedNodes = ref<NodeInfoMap[]>([]) const mapReportSource = ref<MapReport[]>([])
const pagedNodeInfo = ref<NodeInfo[]>([])
const nodePage = ref(1) const nodePage = ref(1)
const nodePageSize = 25 const nodePageSize = 25
const nodeTotal = ref(0) const nodeTotal = ref(0)
const messages = ref<TextMessage[]>([]) const messages = ref<TextMessage[]>([])
const chatPageSize = 20
const chatLoadingOlder = ref(false)
const chatHasMore = ref(true)
const chatInitialized = ref(false)
const positions = ref<PositionRecord[]>([]) const positions = ref<PositionRecord[]>([])
let refreshTimer: number | undefined let refreshTimer: number | undefined
const nodesById = computed<NodeInfoById>(() => { const nodesById = computed<NodeInfoById>(() => {
const map = new Map<string, NodeInfoMap>() const map = new Map<string, NodeInfo>()
for (const node of mapNodeSource.value) { for (const node of nodeInfoSource.value) {
map.set(node.node_id, node) map.set(node.node_id, node)
} }
for (const node of pagedNodes.value) { for (const node of pagedNodeInfo.value) {
map.set(node.node_id, node) map.set(node.node_id, node)
} }
return Object.fromEntries(map) return Object.fromEntries(map)
}) })
const mapNodes = computed<MapNode[]>(() => { const mapNodes = computed<MapNode[]>(() => {
return mapNodeSource.value return mapReportSource.value
.filter((node) => node.latitude != null && node.longitude != null) .filter((report) => report.latitude != null && report.longitude != null)
.map((node) => ({ .map((report) => {
node_id: node.node_id, const nodeinfo = nodesById.value[report.node_id] ?? null
label: node.short_name || node.node_id, return {
latitude: node.latitude as number, node_id: report.node_id,
longitude: node.longitude as number, label: report.short_name || report.long_name || nodeinfo?.short_name || nodeinfo?.long_name || report.node_id,
altitude: node.altitude, latitude: report.latitude as number,
source: 'node', longitude: report.longitude as number,
updated_at: node.updated_at, altitude: report.altitude,
node, source: 'map_report',
updated_at: report.updated_at,
nodeinfo,
map_report: report,
latest_position: null, latest_position: null,
})) }
})
}) })
function toChronological(items: TextMessage[]): TextMessage[] {
return [...items].reverse()
}
function compareMessages(a: TextMessage, b: TextMessage): number {
const timeDiff = Date.parse(a.created_at) - Date.parse(b.created_at)
return timeDiff !== 0 ? timeDiff : a.id - b.id
}
function mergeMessages(existing: TextMessage[], incoming: TextMessage[]): TextMessage[] {
const byId = new Map<number, TextMessage>()
for (const message of existing) {
byId.set(message.id, message)
}
for (const message of incoming) {
byId.set(message.id, message)
}
return Array.from(byId.values()).sort(compareMessages)
}
async function loadInitialChatMessages() {
const response = await getTextMessages(chatPageSize, 0)
messages.value = toChronological(response.items)
chatHasMore.value = response.items.length === chatPageSize
chatInitialized.value = true
}
async function loadOlderMessages() {
if (chatLoadingOlder.value || !chatHasMore.value) {
return
}
chatLoadingOlder.value = true
try {
const response = await getTextMessages(chatPageSize, messages.value.length)
messages.value = mergeMessages(messages.value, toChronological(response.items))
chatHasMore.value = response.items.length === chatPageSize
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
} finally {
chatLoadingOlder.value = false
}
}
async function pollLatestMessages() {
const response = await getTextMessages(chatPageSize, 0)
messages.value = mergeMessages(messages.value, toChronological(response.items))
}
async function loadNodePage(page: number, showLoading = true) { async function loadNodePage(page: number, showLoading = true) {
if (showLoading) { if (showLoading) {
nodePageLoading.value = true nodePageLoading.value = true
} }
try { try {
const safePage = Math.max(1, page) const safePage = Math.max(1, page)
const response = await getNodes(nodePageSize, (safePage - 1) * nodePageSize) const response = await getNodeInfo(nodePageSize, (safePage - 1) * nodePageSize)
pagedNodes.value = response.items pagedNodeInfo.value = response.items
nodeTotal.value = response.total ?? response.offset + response.items.length nodeTotal.value = response.total ?? response.offset + response.items.length
nodePage.value = safePage nodePage.value = safePage
} catch (err) { } catch (err) {
@@ -72,17 +130,20 @@ async function refresh(showLoading = true) {
} }
error.value = '' error.value = ''
try { try {
const [healthData, mapNodeData, messageData, positionData] = await Promise.all([ const [healthData, nodeInfoData, mapReportData, positionData] = await Promise.all([
getHealth(), getHealth(),
getNodes(500, 0), getNodeInfo(500, 0),
getTextMessages(100), getMapReports(500, 0),
getPositions(500), getPositions(500),
]) ])
health.value = healthData health.value = healthData
mapNodeSource.value = mapNodeData.items nodeInfoSource.value = nodeInfoData.items
messages.value = messageData.items mapReportSource.value = mapReportData.items
positions.value = positionData.items positions.value = positionData.items
await loadNodePage(nodePage.value, showLoading) await Promise.all([
loadNodePage(nodePage.value, showLoading),
chatInitialized.value ? pollLatestMessages() : loadInitialChatMessages(),
])
} catch (err) { } catch (err) {
error.value = err instanceof Error ? err.message : String(err) error.value = err instanceof Error ? err.message : String(err)
} finally { } finally {
@@ -115,7 +176,7 @@ onBeforeUnmount(() => {
<span class="status-pill" :class="{ ok: health?.status === 'ok' }"> <span class="status-pill" :class="{ ok: health?.status === 'ok' }">
{{ health?.status ?? 'unknown' }} / db {{ health?.database ?? 'unknown' }} {{ health?.status ?? 'unknown' }} / db {{ health?.database ?? 'unknown' }}
</span> </span>
<span class="counter">节点 {{ nodeTotal }} · 消息 {{ messages.length }} · 坐标 {{ mapNodes.length }}</span> <span class="counter">节点 {{ nodeTotal }} · 已加载消息 {{ messages.length }} · 坐标 {{ mapNodes.length }}</span>
<button @click="() => refresh()" :disabled="loading">{{ loading ? '刷新中...' : '刷新' }}</button> <button @click="() => refresh()" :disabled="loading">{{ loading ? '刷新中...' : '刷新' }}</button>
</div> </div>
</header> </header>
@@ -127,7 +188,10 @@ onBeforeUnmount(() => {
:messages="messages" :messages="messages"
:nodes-by-id="nodesById" :nodes-by-id="nodesById"
:selected-node-id="selectedNodeId" :selected-node-id="selectedNodeId"
:loading-older="chatLoadingOlder"
:has-more-messages="chatHasMore"
@select-node="selectedNodeId = $event" @select-node="selectedNodeId = $event"
@load-older="loadOlderMessages"
/> />
<MeshMap <MeshMap
:nodes="mapNodes" :nodes="mapNodes"
@@ -138,7 +202,7 @@ onBeforeUnmount(() => {
</section> </section>
<NodeListPanel <NodeListPanel
:nodes="pagedNodes" :nodes="pagedNodeInfo"
:selected-node-id="selectedNodeId" :selected-node-id="selectedNodeId"
:page="nodePage" :page="nodePage"
:page-size="nodePageSize" :page-size="nodePageSize"
+9 -5
View File
@@ -1,4 +1,4 @@
import type { HealthStatus, ListResponse, NodeInfoMap, PositionRecord, TextMessage } from './types' import type { HealthStatus, ListResponse, MapReport, NodeInfo, PositionRecord, TextMessage } from './types'
async function getJSON<T>(path: string): Promise<T> { async function getJSON<T>(path: string): Promise<T> {
const response = await fetch(path) const response = await fetch(path)
@@ -12,12 +12,16 @@ export function getHealth(): Promise<HealthStatus> {
return getJSON<HealthStatus>('/api/health') return getJSON<HealthStatus>('/api/health')
} }
export function getNodes(limit = 500, offset = 0): Promise<ListResponse<NodeInfoMap>> { export function getNodeInfo(limit = 500, offset = 0): Promise<ListResponse<NodeInfo>> {
return getJSON<ListResponse<NodeInfoMap>>(`/api/nodes?limit=${limit}&offset=${offset}`) return getJSON<ListResponse<NodeInfo>>(`/api/nodeinfo?limit=${limit}&offset=${offset}`)
} }
export function getTextMessages(limit = 100): Promise<ListResponse<TextMessage>> { export function getMapReports(limit = 500, offset = 0): Promise<ListResponse<MapReport>> {
return getJSON<ListResponse<TextMessage>>(`/api/text-messages?limit=${limit}`) return getJSON<ListResponse<MapReport>>(`/api/map-reports?limit=${limit}&offset=${offset}`)
}
export function getTextMessages(limit = 100, offset = 0): Promise<ListResponse<TextMessage>> {
return getJSON<ListResponse<TextMessage>>(`/api/text-messages?limit=${limit}&offset=${offset}`)
} }
export function getPositions(limit = 500): Promise<ListResponse<PositionRecord>> { export function getPositions(limit = 500): Promise<ListResponse<PositionRecord>> {
+86 -1
View File
@@ -1,16 +1,30 @@
<script setup lang="ts"> <script setup lang="ts">
import { nextTick, onBeforeUpdate, onMounted, onUpdated, ref } from 'vue'
import type { NodeInfoById, TextMessage } from '../types' import type { NodeInfoById, TextMessage } from '../types'
const props = defineProps<{ const props = defineProps<{
messages: TextMessage[] messages: TextMessage[]
nodesById: NodeInfoById nodesById: NodeInfoById
selectedNodeId: string | null selectedNodeId: string | null
loadingOlder: boolean
hasMoreMessages: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
'select-node': [nodeId: string] 'select-node': [nodeId: string]
'load-older': []
}>() }>()
const panelRef = ref<HTMLElement | null>(null)
const topThreshold = 8
const bottomThreshold = 40
let didInitialScroll = false
let shouldStickToBottom = true
let restoreScrollHeight: number | null = null
let restoreScrollTop = 0
let restoreMessageCount = 0
function senderName(message: TextMessage): string { function senderName(message: TextMessage): string {
const node = props.nodesById[message.from_id] const node = props.nodesById[message.from_id]
return node?.long_name || node?.short_name || message.from_id return node?.long_name || node?.short_name || message.from_id
@@ -19,10 +33,79 @@ function senderName(message: TextMessage): string {
function formatTime(value: string): string { function formatTime(value: string): string {
return new Date(value).toLocaleString() return new Date(value).toLocaleString()
} }
function isNearBottom(el: HTMLElement): boolean {
return el.scrollHeight - el.scrollTop - el.clientHeight <= bottomThreshold
}
function clearRestoreState() {
restoreScrollHeight = null
restoreScrollTop = 0
restoreMessageCount = 0
}
function handleScroll() {
const el = panelRef.value
if (
!el ||
props.loadingOlder ||
!props.hasMoreMessages ||
props.messages.length === 0 ||
restoreScrollHeight != null
) {
return
}
if (el.scrollTop <= topThreshold) {
restoreScrollHeight = el.scrollHeight
restoreScrollTop = el.scrollTop
restoreMessageCount = props.messages.length
emit('load-older')
}
}
onBeforeUpdate(() => {
const el = panelRef.value
if (el) {
shouldStickToBottom = isNearBottom(el)
}
})
onMounted(async () => {
await nextTick()
const el = panelRef.value
if (el) {
el.scrollTop = el.scrollHeight
didInitialScroll = true
}
})
onUpdated(() => {
const el = panelRef.value
if (!el) {
return
}
if (restoreScrollHeight != null) {
if (props.messages.length > restoreMessageCount) {
el.scrollTop = el.scrollHeight - restoreScrollHeight + restoreScrollTop
clearRestoreState()
return
}
if (!props.loadingOlder) {
clearRestoreState()
}
}
if (!didInitialScroll || shouldStickToBottom) {
el.scrollTop = el.scrollHeight
didInitialScroll = true
}
})
</script> </script>
<template> <template>
<aside class="chat-panel panel"> <aside ref="panelRef" class="chat-panel panel" @scroll.passive="handleScroll">
<div class="panel-header"> <div class="panel-header">
<div> <div>
<p class="eyebrow">Chat</p> <p class="eyebrow">Chat</p>
@@ -31,6 +114,8 @@ function formatTime(value: string): string {
<span class="badge">{{ messages.length }}</span> <span class="badge">{{ messages.length }}</span>
</div> </div>
<div v-if="loadingOlder" class="chat-loading">正在加载更早消息...</div>
<div v-else-if="!hasMoreMessages && messages.length > 0" class="chat-end">没有更多历史消息</div>
<div v-if="messages.length === 0" class="empty">暂无聊天消息</div> <div v-if="messages.length === 0" class="empty">暂无聊天消息</div>
<button <button
v-for="message in messages" v-for="message in messages"
+11 -8
View File
@@ -91,21 +91,24 @@ function renderMarkers(forceFit: boolean) {
} }
function buildNodePopupHTML(node: MapNode): string { function buildNodePopupHTML(node: MapNode): string {
const info = node.node const info = node.nodeinfo
const report = node.map_report
return ` return `
<div class="node-popup"> <div class="node-popup">
<strong>${escapeHTML(node.node_id)}</strong> <strong>${escapeHTML(node.node_id)}</strong>
<dl> <dl>
<div><dt>长名称</dt><dd>${escapeHTML(info?.long_name || '-')}</dd></div> <div><dt>长名称</dt><dd>${escapeHTML(report?.long_name || info?.long_name || '-')}</dd></div>
<div><dt>短名称</dt><dd>${escapeHTML(info?.short_name || '-')}</dd></div> <div><dt>短名称</dt><dd>${escapeHTML(report?.short_name || info?.short_name || '-')}</dd></div>
<div><dt>硬件型号</dt><dd>${escapeHTML(info?.hw_model || '-')}</dd></div> <div><dt>硬件型号</dt><dd>${escapeHTML(report?.hw_model || info?.hw_model || '-')}</dd></div>
<div><dt>角色</dt><dd>${escapeHTML(info?.role || '-')}</dd></div> <div><dt>角色</dt><dd>${escapeHTML(report?.role || info?.role || '-')}</dd></div>
<div><dt>固件版本</dt><dd>${escapeHTML(info?.firmware_version || '-')}</dd></div> <div><dt>固件版本</dt><dd>${escapeHTML(report?.firmware_version || '-')}</dd></div>
<div><dt>区域</dt><dd>${escapeHTML(report?.region || '-')}</dd></div>
<div><dt>调制预设</dt><dd>${escapeHTML(report?.modem_preset || '-')}</dd></div>
<div><dt>海拔</dt><dd>${node.altitude ?? '-'}</dd></div> <div><dt>海拔</dt><dd>${node.altitude ?? '-'}</dd></div>
<div><dt>经度</dt><dd>${node.longitude.toFixed(5)}</dd></div> <div><dt>经度</dt><dd>${node.longitude.toFixed(5)}</dd></div>
<div><dt>纬度</dt><dd>${node.latitude.toFixed(5)}</dd></div> <div><dt>纬度</dt><dd>${node.latitude.toFixed(5)}</dd></div>
<div><dt>位置精度</dt><dd>${info?.position_precision ?? '-'}</dd></div> <div><dt>位置精度</dt><dd>${report?.position_precision ?? '-'}</dd></div>
<div><dt>在线节点</dt><dd>${info?.num_online_local_nodes ?? '-'}</dd></div> <div><dt>在线节点</dt><dd>${report?.num_online_local_nodes ?? '-'}</dd></div>
</dl> </dl>
</div> </div>
` `
@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { MapNode, NodeInfoMap, PositionRecord, TextMessage } from '../types' import type { MapNode, NodeInfo, PositionRecord, TextMessage } from '../types'
const props = defineProps<{ const props = defineProps<{
node: NodeInfoMap | null node: NodeInfo | null
mapNode: MapNode | null mapNode: MapNode | null
messages: TextMessage[] messages: TextMessage[]
positions: PositionRecord[] positions: PositionRecord[]
@@ -35,11 +35,12 @@ function formatTime(value: string | null | undefined): string {
<div class="detail-main"> <div class="detail-main">
<dl> <dl>
<div><dt>Node ID</dt><dd>{{ node?.node_id || mapNode?.node_id }}</dd></div> <div><dt>Node ID</dt><dd>{{ node?.node_id || mapNode?.node_id }}</dd></div>
<div><dt>Role</dt><dd>{{ node?.role || '-' }}</dd></div> <div><dt>User ID</dt><dd>{{ node?.user_id || '-' }}</dd></div>
<div><dt>Hardware</dt><dd>{{ node?.hw_model || '-' }}</dd></div> <div><dt>Role</dt><dd>{{ node?.role || mapNode?.map_report?.role || '-' }}</dd></div>
<div><dt>Latitude</dt><dd>{{ mapNode?.latitude ?? node?.latitude ?? '-' }}</dd></div> <div><dt>Hardware</dt><dd>{{ node?.hw_model || mapNode?.map_report?.hw_model || '-' }}</dd></div>
<div><dt>Longitude</dt><dd>{{ mapNode?.longitude ?? node?.longitude ?? '-' }}</dd></div> <div><dt>Latitude</dt><dd>{{ mapNode?.latitude ?? '-' }}</dd></div>
<div><dt>Altitude</dt><dd>{{ mapNode?.altitude ?? node?.altitude ?? '-' }}</dd></div> <div><dt>Longitude</dt><dd>{{ mapNode?.longitude ?? '-' }}</dd></div>
<div><dt>Altitude</dt><dd>{{ mapNode?.altitude ?? '-' }}</dd></div>
<div><dt>Updated</dt><dd>{{ formatTime(node?.updated_at || mapNode?.updated_at) }}</dd></div> <div><dt>Updated</dt><dd>{{ formatTime(node?.updated_at || mapNode?.updated_at) }}</dd></div>
</dl> </dl>
</div> </div>
@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import type { NodeInfoMap } from '../types' import type { NodeInfo } from '../types'
const props = defineProps<{ const props = defineProps<{
nodes: NodeInfoMap[] nodes: NodeInfo[]
selectedNodeId: string | null selectedNodeId: string | null
page: number page: number
pageSize: number pageSize: number
@@ -20,7 +20,7 @@ const totalPages = computed(() => Math.max(1, Math.ceil(props.total / props.page
const canPrev = computed(() => props.page > 1) const canPrev = computed(() => props.page > 1)
const canNext = computed(() => props.page < totalPages.value) const canNext = computed(() => props.page < totalPages.value)
function nodeName(node: NodeInfoMap): string { function nodeName(node: NodeInfo): string {
return node.long_name || node.short_name || node.node_id return node.long_name || node.short_name || node.node_id
} }
@@ -33,7 +33,7 @@ function formatTime(value: string): string {
<section class="node-list-panel panel"> <section class="node-list-panel panel">
<div class="panel-header"> <div class="panel-header">
<div> <div>
<p class="eyebrow">NodeInfo Map</p> <p class="eyebrow">NodeInfo</p>
<h2>节点列表</h2> <h2>节点列表</h2>
</div> </div>
<span class="badge"> {{ total }} </span> <span class="badge"> {{ total }} </span>
@@ -45,10 +45,9 @@ function formatTime(value: string): string {
<tr> <tr>
<th>节点</th> <th>节点</th>
<th>Node ID</th> <th>Node ID</th>
<th>类型</th> <th>User ID</th>
<th>角色</th> <th>角色</th>
<th>硬件</th> <th>硬件</th>
<th>坐标</th>
<th>更新时间</th> <th>更新时间</th>
</tr> </tr>
</thead> </thead>
@@ -62,10 +61,9 @@ function formatTime(value: string): string {
> >
<td>{{ nodeName(node) }}</td> <td>{{ nodeName(node) }}</td>
<td>{{ node.node_id }}</td> <td>{{ node.node_id }}</td>
<td>{{ node.latest_type }}</td> <td>{{ node.user_id || '-' }}</td>
<td>{{ node.role || '-' }}</td> <td>{{ node.role || '-' }}</td>
<td>{{ node.hw_model || '-' }}</td> <td>{{ node.hw_model || '-' }}</td>
<td>{{ node.latitude ?? '-' }}, {{ node.longitude ?? '-' }}</td>
<td>{{ formatTime(node.updated_at) }}</td> <td>{{ formatTime(node.updated_at) }}</td>
</tr> </tr>
</tbody> </tbody>
+10
View File
@@ -163,6 +163,16 @@ h3 {
background: #fff; background: #fff;
} }
.chat-loading,
.chat-end {
padding: 10px 16px;
border-bottom: 1px solid #e2e8f0;
color: #64748b;
font-size: 13px;
text-align: center;
background: #f8fafc;
}
.chat-item { .chat-item {
display: grid; display: grid;
gap: 6px; gap: 6px;
+22 -5
View File
@@ -10,20 +10,36 @@ export interface HealthStatus {
database: string database: string
} }
export interface NodeInfoMap { export interface NodeInfo {
node_id: string
node_num: number
user_id: string | null
long_name: string | null
short_name: string | null
hw_model: string | null
role: string | null
is_licensed: boolean | null
public_key: string | null
updated_at: string
content_json: string
}
export interface MapReport {
node_id: string node_id: string
node_num: number node_num: number
latest_type: string
long_name: string | null long_name: string | null
short_name: string | null short_name: string | null
hw_model: string | null hw_model: string | null
role: string | null role: string | null
firmware_version: string | null firmware_version: string | null
region: string | null
modem_preset: string | null
latitude: number | null latitude: number | null
longitude: number | null longitude: number | null
altitude: number | null altitude: number | null
position_precision: number | null position_precision: number | null
num_online_local_nodes: number | null num_online_local_nodes: number | null
has_opted_report_location: boolean | null
updated_at: string updated_at: string
content_json: string content_json: string
} }
@@ -56,10 +72,11 @@ export interface MapNode {
latitude: number latitude: number
longitude: number longitude: number
altitude: number | null altitude: number | null
source: 'node' | 'position' source: 'map_report' | 'position'
updated_at: string updated_at: string
node: NodeInfoMap | null nodeinfo: NodeInfo | null
map_report: MapReport | null
latest_position: PositionRecord | null latest_position: PositionRecord | null
} }
export type NodeInfoById = Record<string, NodeInfoMap> export type NodeInfoById = Record<string, NodeInfo>
+37 -13
View File
@@ -35,22 +35,54 @@ func normalizeListOptions(opts listOptions) listOptions {
return opts return opts
} }
func (s *store) ListNodes(opts listOptions) ([]nodeInfoMapRecord, error) { func (s *store) ListNodeInfo(opts listOptions) ([]nodeInfoRecord, error) {
opts = normalizeListOptions(opts) opts = normalizeListOptions(opts)
var rows []nodeInfoMapRecord var rows []nodeInfoRecord
q := applyNodeFilters(s.db.Model(&nodeInfoMapRecord{}), opts). q := applyNodeFilters(s.db.Model(&nodeInfoRecord{}), opts).
Order("updated_at DESC"). Order("updated_at DESC").
Limit(opts.Limit). Limit(opts.Limit).
Offset(opts.Offset) Offset(opts.Offset)
return rows, q.Find(&rows).Error return rows, q.Find(&rows).Error
} }
func (s *store) CountNodes(opts listOptions) (int64, error) { func (s *store) CountNodeInfo(opts listOptions) (int64, error) {
var total int64 var total int64
q := applyNodeFilters(s.db.Model(&nodeInfoMapRecord{}), opts) q := applyNodeFilters(s.db.Model(&nodeInfoRecord{}), opts)
return total, q.Count(&total).Error return total, q.Count(&total).Error
} }
func (s *store) GetNodeInfo(nodeID string) (*nodeInfoRecord, error) {
var row nodeInfoRecord
if err := s.db.Where("node_id = ?", nodeID).Take(&row).Error; err != nil {
return nil, err
}
return &row, nil
}
func (s *store) ListMapReports(opts listOptions) ([]mapReportRecord, error) {
opts = normalizeListOptions(opts)
var rows []mapReportRecord
q := applyNodeFilters(s.db.Model(&mapReportRecord{}), opts).
Order("updated_at DESC").
Limit(opts.Limit).
Offset(opts.Offset)
return rows, q.Find(&rows).Error
}
func (s *store) CountMapReports(opts listOptions) (int64, error) {
var total int64
q := applyNodeFilters(s.db.Model(&mapReportRecord{}), opts)
return total, q.Count(&total).Error
}
func (s *store) GetMapReport(nodeID string) (*mapReportRecord, error) {
var row mapReportRecord
if err := s.db.Where("node_id = ?", nodeID).Take(&row).Error; err != nil {
return nil, err
}
return &row, nil
}
func applyNodeFilters(q *gorm.DB, opts listOptions) *gorm.DB { func applyNodeFilters(q *gorm.DB, opts listOptions) *gorm.DB {
if opts.NodeID != "" { if opts.NodeID != "" {
q = q.Where("node_id = ?", opts.NodeID) q = q.Where("node_id = ?", opts.NodeID)
@@ -64,14 +96,6 @@ func applyNodeFilters(q *gorm.DB, opts listOptions) *gorm.DB {
return q return q
} }
func (s *store) GetNode(nodeID string) (*nodeInfoMapRecord, error) {
var row nodeInfoMapRecord
if err := s.db.Where("node_id = ?", nodeID).Take(&row).Error; err != nil {
return nil, err
}
return &row, nil
}
func (s *store) ListTextMessages(opts listOptions) ([]textMessageRecord, error) { func (s *store) ListTextMessages(opts listOptions) ([]textMessageRecord, error) {
var rows []textMessageRecord var rows []textMessageRecord
return rows, s.listAppendRows(opts, &rows).Error return rows, s.listAppendRows(opts, &rows).Error
+72 -27
View File
@@ -41,31 +41,9 @@ func registerAPIRoutes(r gin.IRouter, store *store) {
c.JSON(http.StatusOK, status) c.JSON(http.StatusOK, status)
}) })
r.GET("/nodes", func(c *gin.Context) { registerNodeInfoRoutes(r, store, "/nodeinfo")
opts, ok := parseListOptions(c) registerNodeInfoRoutes(r, store, "/nodes")
if !ok { registerMapReportRoutes(r, store)
return
}
rows, err := store.ListNodes(opts)
if err != nil {
writeListResponse(c, rows, opts, err, nodeDTO)
return
}
total, err := store.CountNodes(opts)
writeListResponseWithTotal(c, rows, opts, total, err, nodeDTO)
})
r.GET("/nodes/:id", func(c *gin.Context) {
row, err := store.GetNode(c.Param("id"))
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "node not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, nodeDTO(*row))
})
r.GET("/text-messages", func(c *gin.Context) { r.GET("/text-messages", func(c *gin.Context) {
opts, ok := parseListOptions(c) opts, ok := parseListOptions(c)
if !ok { if !ok {
@@ -108,6 +86,62 @@ func registerAPIRoutes(r gin.IRouter, store *store) {
}) })
} }
func registerNodeInfoRoutes(r gin.IRouter, store *store, path string) {
r.GET(path, func(c *gin.Context) {
opts, ok := parseListOptions(c)
if !ok {
return
}
rows, err := store.ListNodeInfo(opts)
if err != nil {
writeListResponse(c, rows, opts, err, nodeInfoDTO)
return
}
total, err := store.CountNodeInfo(opts)
writeListResponseWithTotal(c, rows, opts, total, err, nodeInfoDTO)
})
r.GET(path+"/:id", func(c *gin.Context) {
row, err := store.GetNodeInfo(c.Param("id"))
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "nodeinfo not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, nodeInfoDTO(*row))
})
}
func registerMapReportRoutes(r gin.IRouter, store *store) {
r.GET("/map-reports", func(c *gin.Context) {
opts, ok := parseListOptions(c)
if !ok {
return
}
rows, err := store.ListMapReports(opts)
if err != nil {
writeListResponse(c, rows, opts, err, mapReportDTO)
return
}
total, err := store.CountMapReports(opts)
writeListResponseWithTotal(c, rows, opts, total, err, mapReportDTO)
})
r.GET("/map-reports/:id", func(c *gin.Context) {
row, err := store.GetMapReport(c.Param("id"))
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "map report not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, mapReportDTO(*row))
})
}
func registerStaticRoutes(r *gin.Engine, staticDir string) { func registerStaticRoutes(r *gin.Engine, staticDir string) {
assetsDir := filepath.Join(staticDir, "assets") assetsDir := filepath.Join(staticDir, "assets")
if info, err := os.Stat(assetsDir); err == nil && info.IsDir() { if info, err := os.Stat(assetsDir); err == nil && info.IsDir() {
@@ -208,8 +242,12 @@ func writeListResponseWithTotal[T any](c *gin.Context, rows []T, opts listOption
c.JSON(http.StatusOK, gin.H{"items": items, "limit": opts.Limit, "offset": opts.Offset, "total": total}) c.JSON(http.StatusOK, gin.H{"items": items, "limit": opts.Limit, "offset": opts.Offset, "total": total})
} }
func nodeDTO(row nodeInfoMapRecord) gin.H { func nodeInfoDTO(row nodeInfoRecord) gin.H {
return gin.H{"node_id": row.NodeID, "node_num": row.NodeNum, "latest_type": row.LatestType, "long_name": ptrString(row.LongName), "short_name": ptrString(row.ShortName), "hw_model": ptrString(row.HWModel), "role": ptrString(row.Role), "firmware_version": ptrString(row.FirmwareVersion), "latitude": ptrFloat64(row.Latitude), "longitude": ptrFloat64(row.Longitude), "altitude": ptrInt64(row.Altitude), "position_precision": ptrInt64(row.PositionPrecision), "num_online_local_nodes": ptrInt64(row.NumOnlineLocalNodes), "updated_at": row.UpdatedAt, "content_json": row.ContentJSON} return gin.H{"node_id": row.NodeID, "node_num": row.NodeNum, "user_id": ptrString(row.UserID), "long_name": ptrString(row.LongName), "short_name": ptrString(row.ShortName), "hw_model": ptrString(row.HWModel), "role": ptrString(row.Role), "is_licensed": ptrBool(row.IsLicensed), "public_key": ptrString(row.PublicKey), "updated_at": row.UpdatedAt, "content_json": row.ContentJSON}
}
func mapReportDTO(row mapReportRecord) gin.H {
return gin.H{"node_id": row.NodeID, "node_num": row.NodeNum, "long_name": ptrString(row.LongName), "short_name": ptrString(row.ShortName), "hw_model": ptrString(row.HWModel), "role": ptrString(row.Role), "firmware_version": ptrString(row.FirmwareVersion), "region": ptrString(row.Region), "modem_preset": ptrString(row.ModemPreset), "latitude": ptrFloat64(row.Latitude), "longitude": ptrFloat64(row.Longitude), "altitude": ptrInt64(row.Altitude), "position_precision": ptrInt64(row.PositionPrecision), "num_online_local_nodes": ptrInt64(row.NumOnlineLocalNodes), "has_opted_report_location": ptrBool(row.HasOptedReportLocation), "updated_at": row.UpdatedAt, "content_json": row.ContentJSON}
} }
func textMessageDTO(row textMessageRecord) gin.H { func textMessageDTO(row textMessageRecord) gin.H {
@@ -256,3 +294,10 @@ func ptrFloat64(value *float64) any {
} }
return *value return *value
} }
func ptrBool(value *bool) any {
if value == nil {
return nil
}
return *value
}