分表
This commit is contained in:
@@ -116,8 +116,12 @@ go run .
|
||||
|
||||
```text
|
||||
GET /api/health
|
||||
GET /api/nodes
|
||||
GET /api/nodes/:id
|
||||
GET /api/nodeinfo
|
||||
GET /api/nodeinfo/:id
|
||||
GET /api/map-reports
|
||||
GET /api/map-reports/:id
|
||||
GET /api/nodes # /api/nodeinfo 的兼容别名
|
||||
GET /api/nodes/:id # /api/nodeinfo/:id 的兼容别名
|
||||
GET /api/text-messages
|
||||
GET /api/positions
|
||||
GET /api/telemetry
|
||||
@@ -145,21 +149,23 @@ meshtastic:
|
||||
|
||||
程序默认启用 SQLite,数据库表迁移和操作由 GORM 执行,并持久化以下数据:
|
||||
|
||||
- `nodeinfo_map`:融合 `type == "nodeinfo"` 和 `type == "map_report"` 的节点信息
|
||||
- `nodeinfo`:保存 `type == "nodeinfo"` 的节点身份和设备信息
|
||||
- `map_report`:保存 `type == "map_report"` 的地图报告信息,前端地图从该表读取
|
||||
- `text_message`:追加保存 `type == "text_message"` 的聊天消息
|
||||
- `position`:追加保存 `type == "position"` 的位置包
|
||||
- `telemetry`:追加保存 `type == "telemetry"` 的遥测包
|
||||
- `routing`:追加保存 `type == "routing"` 的路由控制包
|
||||
- `traceroute`:追加保存 `type == "traceroute"` 的路径追踪包
|
||||
|
||||
`nodeinfo_map` 规则:
|
||||
`nodeinfo` / `map_report` 规则:
|
||||
|
||||
- `nodeinfo` 表不再使用;如果旧数据库中已经存在该表,程序不会自动删除它
|
||||
- 同一节点以 `node_id`(即解析结果中的 `from`,例如 `!a8dfd867`)作为主键
|
||||
- 重复收到同一节点时不会插入重复行,只更新 `updated_at`、`content_json`、`latest_type` 和本次记录中有值的字段
|
||||
- `nodeinfo` 独有字段和 `map_report` 独有字段会互相保留;例如后续 `map_report` 不会清空已有的 `public_key`
|
||||
- 两张表都以 `node_id`(即解析结果中的 `from`,例如 `!a8dfd867`)作为主键
|
||||
- `nodeinfo` 只保存节点身份和设备字段,例如 `user_id`、名称、硬件型号、角色、授权状态和公钥
|
||||
- `map_report` 只保存地图报告字段,例如名称、硬件型号、角色、固件版本、区域、调制预设、经纬度、海拔、位置精度和在线节点数
|
||||
- 重复收到同一节点时不会插入重复行,只更新 `updated_at`、`content_json` 和本次记录中有值的字段
|
||||
- `first_seen_at` 保留第一次写入时间
|
||||
- `content_json` 保存最新一次 `nodeinfo` 或 `map_report` 的完整解析结果 JSON
|
||||
- `content_json` 分别保存最新一次 `nodeinfo` 或 `map_report` 的完整解析结果 JSON
|
||||
- 旧版本创建的 `nodeinfo_map` 融合表不会被自动删除,新版本不再写入该表;新表会从新收到的数据开始填充
|
||||
|
||||
`text_message` 规则:
|
||||
|
||||
|
||||
@@ -62,33 +62,48 @@ type MQTTClientRecordFields struct {
|
||||
MQTTRemotePort *string `gorm:"column:mqtt_remote_port"`
|
||||
}
|
||||
|
||||
type nodeInfoMapRecord struct {
|
||||
type nodeInfoRecord struct {
|
||||
NodeID string `gorm:"column:node_id;primaryKey;not null"`
|
||||
NodeNum int64 `gorm:"column:node_num;not null;index"`
|
||||
UserID *string `gorm:"column:user_id"`
|
||||
LongName *string `gorm:"column:long_name"`
|
||||
ShortName *string `gorm:"column:short_name"`
|
||||
HWModel *string `gorm:"column:hw_model"`
|
||||
Role *string `gorm:"column:role"`
|
||||
IsLicensed *bool `gorm:"column:is_licensed"`
|
||||
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"`
|
||||
LatestType string `gorm:"column:latest_type;not null"`
|
||||
UserID *string `gorm:"column:user_id"`
|
||||
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"`
|
||||
IsLicensed *bool `gorm:"column:is_licensed"`
|
||||
PublicKey *string `gorm:"column:public_key"`
|
||||
FirmwareVersion *string `gorm:"column:firmware_version"`
|
||||
Region *string `gorm:"column:region"`
|
||||
ModemPreset *string `gorm:"column:modem_preset"`
|
||||
Latitude *float64 `gorm:"column:latitude"`
|
||||
Longitude *float64 `gorm:"column:longitude"`
|
||||
Latitude *float64 `gorm:"column:latitude;index"`
|
||||
Longitude *float64 `gorm:"column:longitude;index"`
|
||||
Altitude *int64 `gorm:"column:altitude"`
|
||||
PositionPrecision *int64 `gorm:"column:position_precision"`
|
||||
NumOnlineLocalNodes *int64 `gorm:"column:num_online_local_nodes"`
|
||||
HasOptedReportLocation *bool `gorm:"column:has_opted_report_location"`
|
||||
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"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;index"`
|
||||
}
|
||||
|
||||
func (nodeInfoMapRecord) TableName() string {
|
||||
return "nodeinfo_map"
|
||||
func (mapReportRecord) TableName() string {
|
||||
return "map_report"
|
||||
}
|
||||
|
||||
type textMessageRecord struct {
|
||||
@@ -239,7 +254,8 @@ func (s *store) migrate() error {
|
||||
label string
|
||||
model any
|
||||
}{
|
||||
{label: "nodeinfo_map", model: &nodeInfoMapRecord{}},
|
||||
{label: "nodeinfo", model: &nodeInfoRecord{}},
|
||||
{label: "map_report", model: &mapReportRecord{}},
|
||||
{label: "text_message", model: &textMessageRecord{}},
|
||||
{label: "position", model: &positionRecord{}},
|
||||
{label: "telemetry", model: &telemetryRecord{}},
|
||||
@@ -278,43 +294,75 @@ func createMissingIndexes(migrator gorm.Migrator, model any, label string, index
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *store) UpsertNodeInfoMap(record map[string]any) error {
|
||||
node, err := nodeInfoMapFromRecord(record)
|
||||
func (s *store) UpsertNodeInfo(record map[string]any) error {
|
||||
node, err := nodeInfoFromRecord(record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.upsertNodeInfoMapRecord(node); err != nil {
|
||||
return fmt.Errorf("upsert nodeinfo_map %s: %w", node.NodeID, err)
|
||||
if err := s.upsertNodeInfoRecord(node); err != nil {
|
||||
return fmt.Errorf("upsert nodeinfo %s: %w", node.NodeID, err)
|
||||
}
|
||||
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 {
|
||||
var existing nodeInfoMapRecord
|
||||
var existing nodeInfoRecord
|
||||
err := tx.Where("node_id = ?", node.NodeID).Take(&existing).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
if err := tx.Create(node).Error; err != nil {
|
||||
return s.updateNodeInfoMapRecord(tx, node)
|
||||
return s.updateNodeInfoRecord(tx, node)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.updateNodeInfoMapRecord(tx, node)
|
||||
return s.updateNodeInfoRecord(tx, node)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *store) updateNodeInfoMapRecord(tx *gorm.DB, node *nodeInfoMapRecord) error {
|
||||
updates := nodeInfoMapUpdates(node)
|
||||
return tx.Model(&nodeInfoMapRecord{}).Where("node_id = ?", node.NodeID).Updates(updates).Error
|
||||
func (s *store) upsertMapReportRecord(report *mapReportRecord) error {
|
||||
return s.db.Transaction(func(tx *gorm.DB) 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{
|
||||
"node_num": node.NodeNum,
|
||||
"latest_type": node.LatestType,
|
||||
"content_json": node.ContentJSON,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
@@ -325,15 +373,28 @@ func nodeInfoMapUpdates(node *nodeInfoMapRecord) map[string]any {
|
||||
addStringUpdate(updates, "role", node.Role)
|
||||
addBoolUpdate(updates, "is_licensed", node.IsLicensed)
|
||||
addStringUpdate(updates, "public_key", node.PublicKey)
|
||||
addStringUpdate(updates, "firmware_version", node.FirmwareVersion)
|
||||
addStringUpdate(updates, "region", node.Region)
|
||||
addStringUpdate(updates, "modem_preset", node.ModemPreset)
|
||||
addFloat64Update(updates, "latitude", node.Latitude)
|
||||
addFloat64Update(updates, "longitude", node.Longitude)
|
||||
addInt64Update(updates, "altitude", node.Altitude)
|
||||
addInt64Update(updates, "position_precision", node.PositionPrecision)
|
||||
addInt64Update(updates, "num_online_local_nodes", node.NumOnlineLocalNodes)
|
||||
addBoolUpdate(updates, "has_opted_report_location", node.HasOptedReportLocation)
|
||||
return updates
|
||||
}
|
||||
|
||||
func mapReportUpdates(report *mapReportRecord) map[string]any {
|
||||
updates := map[string]any{
|
||||
"node_num": report.NodeNum,
|
||||
"content_json": report.ContentJSON,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -392,35 +453,47 @@ func (s *store) InsertTraceroute(record map[string]any, clientInfo mqttClientInf
|
||||
return nil
|
||||
}
|
||||
|
||||
func nodeInfoMapFromRecord(record map[string]any) (*nodeInfoMapRecord, error) {
|
||||
latestType, ok := record["type"].(string)
|
||||
if !ok || (latestType != "nodeinfo" && latestType != "map_report") {
|
||||
return nil, fmt.Errorf("record type %v is not nodeinfo or map_report", record["type"])
|
||||
func nodeInfoFromRecord(record map[string]any) (*nodeInfoRecord, error) {
|
||||
recordType, ok := record["type"].(string)
|
||||
if !ok || recordType != "nodeinfo" {
|
||||
return nil, fmt.Errorf("record type %v is not nodeinfo", record["type"])
|
||||
}
|
||||
nodeID, ok := record["from"].(string)
|
||||
if !ok || nodeID == "" {
|
||||
return nil, fmt.Errorf("nodeinfo_map missing from")
|
||||
}
|
||||
nodeNum, err := int64FromAny(record["from_num"])
|
||||
nodeID, nodeNum, contentJSON, err := nodeRecordBase(record, "nodeinfo")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("nodeinfo_map from_num: %w", err)
|
||||
}
|
||||
contentJSON, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode nodeinfo_map content_json: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &nodeInfoMapRecord{
|
||||
return &nodeInfoRecord{
|
||||
NodeID: nodeID,
|
||||
NodeNum: nodeNum,
|
||||
UserID: nullableString(record["user_id"]),
|
||||
LongName: nullableString(record["long_name"]),
|
||||
ShortName: nullableString(record["short_name"]),
|
||||
HWModel: nullableString(record["hw_model"]),
|
||||
Role: nullableString(record["role"]),
|
||||
IsLicensed: nullableBool(record["is_licensed"]),
|
||||
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,
|
||||
LatestType: latestType,
|
||||
UserID: nullableString(record["user_id"]),
|
||||
LongName: nullableString(record["long_name"]),
|
||||
ShortName: nullableString(record["short_name"]),
|
||||
HWModel: nullableString(record["hw_model"]),
|
||||
Role: nullableString(record["role"]),
|
||||
IsLicensed: nullableBool(record["is_licensed"]),
|
||||
PublicKey: nullableString(record["public_key"]),
|
||||
FirmwareVersion: nullableString(record["firmware_version"]),
|
||||
Region: nullableString(record["region"]),
|
||||
ModemPreset: nullableString(record["modem_preset"]),
|
||||
@@ -430,10 +503,26 @@ func nodeInfoMapFromRecord(record map[string]any) (*nodeInfoMapRecord, error) {
|
||||
PositionPrecision: nullableInt64(record["position_precision"]),
|
||||
NumOnlineLocalNodes: nullableInt64(record["num_online_local_nodes"]),
|
||||
HasOptedReportLocation: nullableBool(record["has_opted_report_location"]),
|
||||
ContentJSON: string(contentJSON),
|
||||
ContentJSON: contentJSON,
|
||||
}, 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) {
|
||||
recordType, ok := record["type"].(string)
|
||||
if !ok || recordType != "text_message" {
|
||||
|
||||
+79
-75
@@ -11,7 +11,7 @@ func TestOpenStoreCreatesTables(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
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
|
||||
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)
|
||||
@@ -22,160 +22,164 @@ func TestOpenStoreCreatesTables(t *testing.T) {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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)
|
||||
defer st.Close()
|
||||
|
||||
first := nodeInfoRecord("first name")
|
||||
if err := st.UpsertNodeInfoMap(first); err != nil {
|
||||
t.Fatalf("first UpsertNodeInfoMap() error = %v", err)
|
||||
first := nodeInfoTestRecord("first name")
|
||||
if err := st.UpsertNodeInfo(first); err != nil {
|
||||
t.Fatalf("first UpsertNodeInfo() error = %v", err)
|
||||
}
|
||||
|
||||
second := nodeInfoRecord("second name")
|
||||
second := nodeInfoTestRecord("second name")
|
||||
second["short_name"] = "snd"
|
||||
if err := st.UpsertNodeInfoMap(second); err != nil {
|
||||
t.Fatalf("second UpsertNodeInfoMap() error = %v", err)
|
||||
if err := st.UpsertNodeInfo(second); err != nil {
|
||||
t.Fatalf("second UpsertNodeInfo() error = %v", err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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
|
||||
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 {
|
||||
var longName, shortName, content string
|
||||
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)
|
||||
}
|
||||
if latestType != "nodeinfo" {
|
||||
t.Fatalf("latest_type = %q, want nodeinfo", latestType)
|
||||
}
|
||||
if longName != "second name" {
|
||||
t.Fatalf("long_name = %q, want second name", longName)
|
||||
if longName != "second name" || shortName != "snd" {
|
||||
t.Fatalf("nodeinfo names = %q/%q, want second name/snd", longName, shortName)
|
||||
}
|
||||
if !strings.Contains(content, "second name") {
|
||||
t.Fatalf("content_json = %q, want updated content", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertNodeInfoMapMergesNodeInfoThenMapReport(t *testing.T) {
|
||||
func TestUpsertMapReportInsertsAndUpdatesSameNode(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
if err := st.UpsertNodeInfoMap(nodeInfoRecord("node name")); err != nil {
|
||||
t.Fatalf("nodeinfo UpsertNodeInfoMap() error = %v", err)
|
||||
first := mapReportTestRecord("first map")
|
||||
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
|
||||
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)
|
||||
}
|
||||
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 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)
|
||||
}
|
||||
if latestType != "map_report" {
|
||||
t.Fatalf("latest_type = %q, want map_report", latestType)
|
||||
}
|
||||
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 longName != "second map" || latitude != 43.5 {
|
||||
t.Fatalf("map_report row = %q/%v, want second map/43.5", longName, latitude)
|
||||
}
|
||||
if !opted.Valid || opted.Bool {
|
||||
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)
|
||||
defer st.Close()
|
||||
|
||||
if err := st.UpsertNodeInfoMap(mapReportRecord("map name")); err != nil {
|
||||
t.Fatalf("map_report UpsertNodeInfoMap() error = %v", err)
|
||||
if err := st.UpsertNodeInfo(nodeInfoTestRecord("node name")); err != nil {
|
||||
t.Fatalf("UpsertNodeInfo() error = %v", err)
|
||||
}
|
||||
if err := st.UpsertNodeInfoMap(nodeInfoRecord("node name")); err != nil {
|
||||
t.Fatalf("nodeinfo UpsertNodeInfoMap() error = %v", err)
|
||||
if err := st.UpsertMapReport(mapReportTestRecord("map name")); err != nil {
|
||||
t.Fatalf("UpsertMapReport() error = %v", err)
|
||||
}
|
||||
|
||||
var latestType, userID, longName, firmware string
|
||||
var latitude float64
|
||||
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 {
|
||||
var nodeLongName, userID, publicKey string
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if latestType != "nodeinfo" {
|
||||
t.Fatalf("latest_type = %q, want nodeinfo", latestType)
|
||||
if nodeLongName != "node name" || userID != "!12345678" || publicKey != "abcd" {
|
||||
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" {
|
||||
t.Fatalf("long_name = %q, want node name", longName)
|
||||
}
|
||||
if firmware != "1.2.3" || latitude != 42.5 {
|
||||
t.Fatalf("map fields not preserved: firmware=%q latitude=%v", firmware, latitude)
|
||||
if mapLongName != "map name" || firmware != "1.2.3" || latitude != 42.5 {
|
||||
t.Fatalf("map_report row = %q/%q/%v, want map fields", mapLongName, firmware, latitude)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertNodeInfoMapRequiresNodeFields(t *testing.T) {
|
||||
func TestUpsertNodeInfoRequiresNodeFields(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeInfoMapFromRecordRejectsWrongType(t *testing.T) {
|
||||
_, err := nodeInfoMapFromRecord(map[string]any{"type": "text_message"})
|
||||
if err == nil {
|
||||
t.Fatalf("nodeInfoMapFromRecord() error = nil, want error")
|
||||
func TestUpsertMapReportRequiresNodeFields(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
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)
|
||||
defer st.Close()
|
||||
|
||||
record := map[string]any{"type": "nodeinfo", "from": "!00000001", "from_num": 1, "public_key": nil}
|
||||
if err := st.UpsertNodeInfoMap(record); err != nil {
|
||||
t.Fatalf("UpsertNodeInfoMap() error = %v", err)
|
||||
if err := st.UpsertNodeInfo(record); err != nil {
|
||||
t.Fatalf("UpsertNodeInfo() error = %v", err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if publicKey.Valid {
|
||||
@@ -437,7 +441,7 @@ func rawTestDB(t *testing.T, st *store) *sql.DB {
|
||||
return db
|
||||
}
|
||||
|
||||
func nodeInfoRecord(longName string) map[string]any {
|
||||
func nodeInfoTestRecord(longName string) map[string]any {
|
||||
return map[string]any{
|
||||
"type": "nodeinfo",
|
||||
"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{
|
||||
"type": "map_report",
|
||||
"from": "!12345678",
|
||||
|
||||
@@ -56,9 +56,15 @@ func (h *meshtasticFilterHook) OnPublish(cl *mqtt.Client, pk packets.Packet) (pa
|
||||
}
|
||||
|
||||
switch record["type"] {
|
||||
case "nodeinfo", "map_report":
|
||||
case "nodeinfo":
|
||||
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()})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,60 +1,118 @@
|
||||
<script setup lang="ts">
|
||||
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 MeshMap from './components/MeshMap.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 nodePageLoading = ref(false)
|
||||
const error = ref('')
|
||||
const selectedNodeId = ref<string | null>(null)
|
||||
const health = ref<HealthStatus | null>(null)
|
||||
const mapNodeSource = ref<NodeInfoMap[]>([])
|
||||
const pagedNodes = ref<NodeInfoMap[]>([])
|
||||
const nodeInfoSource = ref<NodeInfo[]>([])
|
||||
const mapReportSource = ref<MapReport[]>([])
|
||||
const pagedNodeInfo = ref<NodeInfo[]>([])
|
||||
const nodePage = ref(1)
|
||||
const nodePageSize = 25
|
||||
const nodeTotal = ref(0)
|
||||
const messages = ref<TextMessage[]>([])
|
||||
const chatPageSize = 20
|
||||
const chatLoadingOlder = ref(false)
|
||||
const chatHasMore = ref(true)
|
||||
const chatInitialized = ref(false)
|
||||
const positions = ref<PositionRecord[]>([])
|
||||
let refreshTimer: number | undefined
|
||||
|
||||
const nodesById = computed<NodeInfoById>(() => {
|
||||
const map = new Map<string, NodeInfoMap>()
|
||||
for (const node of mapNodeSource.value) {
|
||||
const map = new Map<string, NodeInfo>()
|
||||
for (const node of nodeInfoSource.value) {
|
||||
map.set(node.node_id, node)
|
||||
}
|
||||
for (const node of pagedNodes.value) {
|
||||
for (const node of pagedNodeInfo.value) {
|
||||
map.set(node.node_id, node)
|
||||
}
|
||||
return Object.fromEntries(map)
|
||||
})
|
||||
|
||||
const mapNodes = computed<MapNode[]>(() => {
|
||||
return mapNodeSource.value
|
||||
.filter((node) => node.latitude != null && node.longitude != null)
|
||||
.map((node) => ({
|
||||
node_id: node.node_id,
|
||||
label: node.short_name || node.node_id,
|
||||
latitude: node.latitude as number,
|
||||
longitude: node.longitude as number,
|
||||
altitude: node.altitude,
|
||||
source: 'node',
|
||||
updated_at: node.updated_at,
|
||||
node,
|
||||
latest_position: null,
|
||||
}))
|
||||
return mapReportSource.value
|
||||
.filter((report) => report.latitude != null && report.longitude != null)
|
||||
.map((report) => {
|
||||
const nodeinfo = nodesById.value[report.node_id] ?? null
|
||||
return {
|
||||
node_id: report.node_id,
|
||||
label: report.short_name || report.long_name || nodeinfo?.short_name || nodeinfo?.long_name || report.node_id,
|
||||
latitude: report.latitude as number,
|
||||
longitude: report.longitude as number,
|
||||
altitude: report.altitude,
|
||||
source: 'map_report',
|
||||
updated_at: report.updated_at,
|
||||
nodeinfo,
|
||||
map_report: report,
|
||||
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) {
|
||||
if (showLoading) {
|
||||
nodePageLoading.value = true
|
||||
}
|
||||
try {
|
||||
const safePage = Math.max(1, page)
|
||||
const response = await getNodes(nodePageSize, (safePage - 1) * nodePageSize)
|
||||
pagedNodes.value = response.items
|
||||
const response = await getNodeInfo(nodePageSize, (safePage - 1) * nodePageSize)
|
||||
pagedNodeInfo.value = response.items
|
||||
nodeTotal.value = response.total ?? response.offset + response.items.length
|
||||
nodePage.value = safePage
|
||||
} catch (err) {
|
||||
@@ -72,17 +130,20 @@ async function refresh(showLoading = true) {
|
||||
}
|
||||
error.value = ''
|
||||
try {
|
||||
const [healthData, mapNodeData, messageData, positionData] = await Promise.all([
|
||||
const [healthData, nodeInfoData, mapReportData, positionData] = await Promise.all([
|
||||
getHealth(),
|
||||
getNodes(500, 0),
|
||||
getTextMessages(100),
|
||||
getNodeInfo(500, 0),
|
||||
getMapReports(500, 0),
|
||||
getPositions(500),
|
||||
])
|
||||
health.value = healthData
|
||||
mapNodeSource.value = mapNodeData.items
|
||||
messages.value = messageData.items
|
||||
nodeInfoSource.value = nodeInfoData.items
|
||||
mapReportSource.value = mapReportData.items
|
||||
positions.value = positionData.items
|
||||
await loadNodePage(nodePage.value, showLoading)
|
||||
await Promise.all([
|
||||
loadNodePage(nodePage.value, showLoading),
|
||||
chatInitialized.value ? pollLatestMessages() : loadInitialChatMessages(),
|
||||
])
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err)
|
||||
} finally {
|
||||
@@ -115,7 +176,7 @@ onBeforeUnmount(() => {
|
||||
<span class="status-pill" :class="{ ok: health?.status === 'ok' }">
|
||||
{{ 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>
|
||||
<button @click="() => refresh()" :disabled="loading">{{ loading ? '刷新中...' : '刷新' }}</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -127,7 +188,10 @@ onBeforeUnmount(() => {
|
||||
:messages="messages"
|
||||
:nodes-by-id="nodesById"
|
||||
:selected-node-id="selectedNodeId"
|
||||
:loading-older="chatLoadingOlder"
|
||||
:has-more-messages="chatHasMore"
|
||||
@select-node="selectedNodeId = $event"
|
||||
@load-older="loadOlderMessages"
|
||||
/>
|
||||
<MeshMap
|
||||
:nodes="mapNodes"
|
||||
@@ -138,7 +202,7 @@ onBeforeUnmount(() => {
|
||||
</section>
|
||||
|
||||
<NodeListPanel
|
||||
:nodes="pagedNodes"
|
||||
:nodes="pagedNodeInfo"
|
||||
:selected-node-id="selectedNodeId"
|
||||
:page="nodePage"
|
||||
:page-size="nodePageSize"
|
||||
|
||||
@@ -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> {
|
||||
const response = await fetch(path)
|
||||
@@ -12,12 +12,16 @@ export function getHealth(): Promise<HealthStatus> {
|
||||
return getJSON<HealthStatus>('/api/health')
|
||||
}
|
||||
|
||||
export function getNodes(limit = 500, offset = 0): Promise<ListResponse<NodeInfoMap>> {
|
||||
return getJSON<ListResponse<NodeInfoMap>>(`/api/nodes?limit=${limit}&offset=${offset}`)
|
||||
export function getNodeInfo(limit = 500, offset = 0): Promise<ListResponse<NodeInfo>> {
|
||||
return getJSON<ListResponse<NodeInfo>>(`/api/nodeinfo?limit=${limit}&offset=${offset}`)
|
||||
}
|
||||
|
||||
export function getTextMessages(limit = 100): Promise<ListResponse<TextMessage>> {
|
||||
return getJSON<ListResponse<TextMessage>>(`/api/text-messages?limit=${limit}`)
|
||||
export function getMapReports(limit = 500, offset = 0): Promise<ListResponse<MapReport>> {
|
||||
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>> {
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUpdate, onMounted, onUpdated, ref } from 'vue'
|
||||
import type { NodeInfoById, TextMessage } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
messages: TextMessage[]
|
||||
nodesById: NodeInfoById
|
||||
selectedNodeId: string | null
|
||||
loadingOlder: boolean
|
||||
hasMoreMessages: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'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 {
|
||||
const node = props.nodesById[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 {
|
||||
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>
|
||||
|
||||
<template>
|
||||
<aside class="chat-panel panel">
|
||||
<aside ref="panelRef" class="chat-panel panel" @scroll.passive="handleScroll">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="eyebrow">Chat</p>
|
||||
@@ -31,6 +114,8 @@ function formatTime(value: string): string {
|
||||
<span class="badge">{{ messages.length }}</span>
|
||||
</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>
|
||||
<button
|
||||
v-for="message in messages"
|
||||
|
||||
@@ -91,21 +91,24 @@ function renderMarkers(forceFit: boolean) {
|
||||
}
|
||||
|
||||
function buildNodePopupHTML(node: MapNode): string {
|
||||
const info = node.node
|
||||
const info = node.nodeinfo
|
||||
const report = node.map_report
|
||||
return `
|
||||
<div class="node-popup">
|
||||
<strong>${escapeHTML(node.node_id)}</strong>
|
||||
<dl>
|
||||
<div><dt>长名称</dt><dd>${escapeHTML(info?.long_name || '-')}</dd></div>
|
||||
<div><dt>短名称</dt><dd>${escapeHTML(info?.short_name || '-')}</dd></div>
|
||||
<div><dt>硬件型号</dt><dd>${escapeHTML(info?.hw_model || '-')}</dd></div>
|
||||
<div><dt>角色</dt><dd>${escapeHTML(info?.role || '-')}</dd></div>
|
||||
<div><dt>固件版本</dt><dd>${escapeHTML(info?.firmware_version || '-')}</dd></div>
|
||||
<div><dt>长名称</dt><dd>${escapeHTML(report?.long_name || info?.long_name || '-')}</dd></div>
|
||||
<div><dt>短名称</dt><dd>${escapeHTML(report?.short_name || info?.short_name || '-')}</dd></div>
|
||||
<div><dt>硬件型号</dt><dd>${escapeHTML(report?.hw_model || info?.hw_model || '-')}</dd></div>
|
||||
<div><dt>角色</dt><dd>${escapeHTML(report?.role || info?.role || '-')}</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.longitude.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>${info?.num_online_local_nodes ?? '-'}</dd></div>
|
||||
<div><dt>位置精度</dt><dd>${report?.position_precision ?? '-'}</dd></div>
|
||||
<div><dt>在线节点</dt><dd>${report?.num_online_local_nodes ?? '-'}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
`
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { MapNode, NodeInfoMap, PositionRecord, TextMessage } from '../types'
|
||||
import type { MapNode, NodeInfo, PositionRecord, TextMessage } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
node: NodeInfoMap | null
|
||||
node: NodeInfo | null
|
||||
mapNode: MapNode | null
|
||||
messages: TextMessage[]
|
||||
positions: PositionRecord[]
|
||||
@@ -35,11 +35,12 @@ function formatTime(value: string | null | undefined): string {
|
||||
<div class="detail-main">
|
||||
<dl>
|
||||
<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>Hardware</dt><dd>{{ node?.hw_model || '-' }}</dd></div>
|
||||
<div><dt>Latitude</dt><dd>{{ mapNode?.latitude ?? node?.latitude ?? '-' }}</dd></div>
|
||||
<div><dt>Longitude</dt><dd>{{ mapNode?.longitude ?? node?.longitude ?? '-' }}</dd></div>
|
||||
<div><dt>Altitude</dt><dd>{{ mapNode?.altitude ?? node?.altitude ?? '-' }}</dd></div>
|
||||
<div><dt>User ID</dt><dd>{{ node?.user_id || '-' }}</dd></div>
|
||||
<div><dt>Role</dt><dd>{{ node?.role || mapNode?.map_report?.role || '-' }}</dd></div>
|
||||
<div><dt>Hardware</dt><dd>{{ node?.hw_model || mapNode?.map_report?.hw_model || '-' }}</dd></div>
|
||||
<div><dt>Latitude</dt><dd>{{ mapNode?.latitude ?? '-' }}</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>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { NodeInfoMap } from '../types'
|
||||
import type { NodeInfo } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
nodes: NodeInfoMap[]
|
||||
nodes: NodeInfo[]
|
||||
selectedNodeId: string | null
|
||||
page: 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 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
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ function formatTime(value: string): string {
|
||||
<section class="node-list-panel panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="eyebrow">NodeInfo Map</p>
|
||||
<p class="eyebrow">NodeInfo</p>
|
||||
<h2>节点列表</h2>
|
||||
</div>
|
||||
<span class="badge">共 {{ total }} 条</span>
|
||||
@@ -45,10 +45,9 @@ function formatTime(value: string): string {
|
||||
<tr>
|
||||
<th>节点</th>
|
||||
<th>Node ID</th>
|
||||
<th>类型</th>
|
||||
<th>User ID</th>
|
||||
<th>角色</th>
|
||||
<th>硬件</th>
|
||||
<th>坐标</th>
|
||||
<th>更新时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -62,10 +61,9 @@ function formatTime(value: string): string {
|
||||
>
|
||||
<td>{{ nodeName(node) }}</td>
|
||||
<td>{{ node.node_id }}</td>
|
||||
<td>{{ node.latest_type }}</td>
|
||||
<td>{{ node.user_id || '-' }}</td>
|
||||
<td>{{ node.role || '-' }}</td>
|
||||
<td>{{ node.hw_model || '-' }}</td>
|
||||
<td>{{ node.latitude ?? '-' }}, {{ node.longitude ?? '-' }}</td>
|
||||
<td>{{ formatTime(node.updated_at) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@@ -163,6 +163,16 @@ h3 {
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
|
||||
@@ -10,20 +10,36 @@ export interface HealthStatus {
|
||||
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_num: number
|
||||
latest_type: string
|
||||
long_name: string | null
|
||||
short_name: string | null
|
||||
hw_model: string | null
|
||||
role: string | null
|
||||
firmware_version: string | null
|
||||
region: string | null
|
||||
modem_preset: string | null
|
||||
latitude: number | null
|
||||
longitude: number | null
|
||||
altitude: number | null
|
||||
position_precision: number | null
|
||||
num_online_local_nodes: number | null
|
||||
has_opted_report_location: boolean | null
|
||||
updated_at: string
|
||||
content_json: string
|
||||
}
|
||||
@@ -56,10 +72,11 @@ export interface MapNode {
|
||||
latitude: number
|
||||
longitude: number
|
||||
altitude: number | null
|
||||
source: 'node' | 'position'
|
||||
source: 'map_report' | 'position'
|
||||
updated_at: string
|
||||
node: NodeInfoMap | null
|
||||
nodeinfo: NodeInfo | null
|
||||
map_report: MapReport | null
|
||||
latest_position: PositionRecord | null
|
||||
}
|
||||
|
||||
export type NodeInfoById = Record<string, NodeInfoMap>
|
||||
export type NodeInfoById = Record<string, NodeInfo>
|
||||
|
||||
+37
-13
@@ -35,22 +35,54 @@ func normalizeListOptions(opts listOptions) listOptions {
|
||||
return opts
|
||||
}
|
||||
|
||||
func (s *store) ListNodes(opts listOptions) ([]nodeInfoMapRecord, error) {
|
||||
func (s *store) ListNodeInfo(opts listOptions) ([]nodeInfoRecord, error) {
|
||||
opts = normalizeListOptions(opts)
|
||||
var rows []nodeInfoMapRecord
|
||||
q := applyNodeFilters(s.db.Model(&nodeInfoMapRecord{}), opts).
|
||||
var rows []nodeInfoRecord
|
||||
q := applyNodeFilters(s.db.Model(&nodeInfoRecord{}), opts).
|
||||
Order("updated_at DESC").
|
||||
Limit(opts.Limit).
|
||||
Offset(opts.Offset)
|
||||
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
|
||||
q := applyNodeFilters(s.db.Model(&nodeInfoMapRecord{}), opts)
|
||||
q := applyNodeFilters(s.db.Model(&nodeInfoRecord{}), opts)
|
||||
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 {
|
||||
if opts.NodeID != "" {
|
||||
q = q.Where("node_id = ?", opts.NodeID)
|
||||
@@ -64,14 +96,6 @@ func applyNodeFilters(q *gorm.DB, opts listOptions) *gorm.DB {
|
||||
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) {
|
||||
var rows []textMessageRecord
|
||||
return rows, s.listAppendRows(opts, &rows).Error
|
||||
|
||||
@@ -41,31 +41,9 @@ func registerAPIRoutes(r gin.IRouter, store *store) {
|
||||
c.JSON(http.StatusOK, status)
|
||||
})
|
||||
|
||||
r.GET("/nodes", func(c *gin.Context) {
|
||||
opts, ok := parseListOptions(c)
|
||||
if !ok {
|
||||
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))
|
||||
})
|
||||
registerNodeInfoRoutes(r, store, "/nodeinfo")
|
||||
registerNodeInfoRoutes(r, store, "/nodes")
|
||||
registerMapReportRoutes(r, store)
|
||||
r.GET("/text-messages", func(c *gin.Context) {
|
||||
opts, ok := parseListOptions(c)
|
||||
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) {
|
||||
assetsDir := filepath.Join(staticDir, "assets")
|
||||
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})
|
||||
}
|
||||
|
||||
func nodeDTO(row nodeInfoMapRecord) 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}
|
||||
func nodeInfoDTO(row nodeInfoRecord) gin.H {
|
||||
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 {
|
||||
@@ -256,3 +294,10 @@ func ptrFloat64(value *float64) any {
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func ptrBool(value *bool) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user