Add realtime gateway and simulator bridge
This commit is contained in:
149
realtime-gateway/README.md
Normal file
149
realtime-gateway/README.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# Realtime Gateway
|
||||
|
||||
`realtime-gateway` 是一个独立于现有模拟器的 Go 实时设备数据网关工程。
|
||||
|
||||
当前目标:
|
||||
|
||||
- 以实时中转为核心
|
||||
- 纯内存运行
|
||||
- 单二进制部署
|
||||
- 支持 WebSocket 接入
|
||||
- 支持 channel 模型
|
||||
- 支持内置管理台、实时窗口、流量统计
|
||||
- 为后续规则、通知、归档、回放预留插件总线
|
||||
|
||||
## 文档入口
|
||||
|
||||
优先看这几份:
|
||||
|
||||
- 运行手册:[realtime-gateway-runbook.md](D:/dev/cmr-mini/doc/realtime-gateway-runbook.md)
|
||||
- 架构方案:[realtime-device-gateway-architecture.md](D:/dev/cmr-mini/doc/realtime-device-gateway-architecture.md)
|
||||
- 协议说明:[gateway-protocol-spec.md](D:/dev/cmr-mini/doc/gateway-protocol-spec.md)
|
||||
- Tunnel 联调:[cloudflare-tunnel-dev-guide.md](D:/dev/cmr-mini/doc/cloudflare-tunnel-dev-guide.md)
|
||||
- 老模拟器桥接说明:[README.md](D:/dev/cmr-mini/tools/mock-gps-sim/README.md)
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 启动网关
|
||||
|
||||
```powershell
|
||||
cd D:\dev\cmr-mini\realtime-gateway
|
||||
go run .\cmd\gateway -config .\config\tunnel-dev.json
|
||||
```
|
||||
|
||||
### 2. 打开管理台
|
||||
|
||||
```text
|
||||
http://127.0.0.1:18080/
|
||||
```
|
||||
|
||||
### 3. 启动老模拟器
|
||||
|
||||
```powershell
|
||||
cd D:\dev\cmr-mini
|
||||
npm run mock-gps-sim
|
||||
```
|
||||
|
||||
### 4. 打开模拟器页面
|
||||
|
||||
```text
|
||||
http://127.0.0.1:17865/
|
||||
```
|
||||
|
||||
### 5. 用老模拟器桥接新网关
|
||||
|
||||
在“新网关桥接”区域填写:
|
||||
|
||||
- 网关地址:`ws://127.0.0.1:18080/ws`
|
||||
- `Producer Token / Channel Token`
|
||||
- `Channel ID` 可选
|
||||
- `Device ID`
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
realtime-gateway/
|
||||
├── cmd/gateway
|
||||
├── cmd/mock-consumer
|
||||
├── cmd/mock-producer
|
||||
├── config
|
||||
├── deploy
|
||||
├── internal/channel
|
||||
├── internal/config
|
||||
├── internal/gateway
|
||||
├── internal/model
|
||||
├── internal/plugin
|
||||
├── internal/router
|
||||
└── internal/session
|
||||
```
|
||||
|
||||
## 构建
|
||||
|
||||
```powershell
|
||||
cd D:\dev\cmr-mini\realtime-gateway
|
||||
go build -o .\bin\gateway.exe .\cmd\gateway
|
||||
go build -o .\bin\mock-producer.exe .\cmd\mock-producer
|
||||
go build -o .\bin\mock-consumer.exe .\cmd\mock-consumer
|
||||
```
|
||||
|
||||
或直接做一次完整编译检查:
|
||||
|
||||
```powershell
|
||||
cd D:\dev\cmr-mini\realtime-gateway
|
||||
go build ./...
|
||||
```
|
||||
|
||||
## 默认地址
|
||||
|
||||
推荐开发配置:
|
||||
|
||||
- 网关 HTTP / Admin:`http://127.0.0.1:18080/`
|
||||
- 网关 WebSocket:`ws://127.0.0.1:18080/ws`
|
||||
- 模拟器页面:`http://127.0.0.1:17865/`
|
||||
- 模拟器 mock WS:`ws://127.0.0.1:17865/mock-gps`
|
||||
|
||||
## CLI 调试
|
||||
|
||||
### producer
|
||||
|
||||
```powershell
|
||||
cd D:\dev\cmr-mini\realtime-gateway
|
||||
go run .\cmd\mock-producer -device-id child-001 -topic telemetry.location -count 5
|
||||
go run .\cmd\mock-producer -device-id child-001 -topic telemetry.heart_rate -bpm 148 -count 5
|
||||
```
|
||||
|
||||
### consumer
|
||||
|
||||
```powershell
|
||||
cd D:\dev\cmr-mini\realtime-gateway
|
||||
go run .\cmd\mock-consumer -config .\config\consumer-gps-heart.example.json
|
||||
```
|
||||
|
||||
### channel 模式
|
||||
|
||||
```powershell
|
||||
cd D:\dev\cmr-mini\realtime-gateway
|
||||
go run .\cmd\mock-producer -channel-id ch-xxxx -token <producer-token> -topic telemetry.location -count 5
|
||||
go run .\cmd\mock-consumer -channel-id ch-xxxx -token <consumer-token> -topics telemetry.location,telemetry.heart_rate
|
||||
```
|
||||
|
||||
## 当前已落地能力
|
||||
|
||||
- WebSocket 接入
|
||||
- `authenticate`
|
||||
- `join_channel`
|
||||
- `subscribe`
|
||||
- `publish`
|
||||
- `snapshot`
|
||||
- latest state 缓存
|
||||
- channel 管理
|
||||
- `cache_latest`
|
||||
- `drop_if_no_consumer`
|
||||
- 管理台
|
||||
- 实时数据窗口
|
||||
- GPS 轨迹预览
|
||||
- 流量统计
|
||||
|
||||
更多运行和排障细节,直接看:
|
||||
|
||||
- [realtime-gateway-runbook.md](D:/dev/cmr-mini/doc/realtime-gateway-runbook.md)
|
||||
48
realtime-gateway/cmd/gateway/main.go
Normal file
48
realtime-gateway/cmd/gateway/main.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"realtime-gateway/internal/config"
|
||||
"realtime-gateway/internal/gateway"
|
||||
"realtime-gateway/internal/logging"
|
||||
)
|
||||
|
||||
func main() {
|
||||
configPath := flag.String("config", "./config/dev.json", "path to config file")
|
||||
flag.Parse()
|
||||
|
||||
cfg, err := config.Load(*configPath)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
logger := logging.New()
|
||||
app, err := gateway.NewServer(cfg, logger)
|
||||
if err != nil {
|
||||
logger.Error("failed to create server", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- app.Run(ctx)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
logger.Info("shutdown signal received")
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
logger.Error("server stopped with error", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
354
realtime-gateway/cmd/mock-consumer/main.go
Normal file
354
realtime-gateway/cmd/mock-consumer/main.go
Normal file
@@ -0,0 +1,354 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/coder/websocket/wsjson"
|
||||
|
||||
"realtime-gateway/internal/model"
|
||||
)
|
||||
|
||||
type consumerConfig struct {
|
||||
URL string `json:"url"`
|
||||
Token string `json:"token"`
|
||||
ChannelID string `json:"channelId"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
GroupID string `json:"groupId"`
|
||||
Topic string `json:"topic"`
|
||||
Topics []string `json:"topics"`
|
||||
Subscriptions []model.Subscription `json:"subscriptions"`
|
||||
Snapshot *bool `json:"snapshot"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
configPath := findConfigPath(os.Args[1:])
|
||||
fileConfig, err := loadConsumerConfig(configPath)
|
||||
if err != nil {
|
||||
log.Fatalf("load config: %v", err)
|
||||
}
|
||||
|
||||
flag.StringVar(&configPath, "config", configPath, "path to consumer config file")
|
||||
url := flag.String("url", valueOr(fileConfig.URL, "ws://127.0.0.1:8080/ws"), "gateway websocket url")
|
||||
token := flag.String("token", fileConfig.Token, "consumer token, leave empty if anonymous consumers are allowed")
|
||||
channelID := flag.String("channel-id", fileConfig.ChannelID, "channel id to join before subscribe")
|
||||
deviceID := flag.String("device-id", valueOr(fileConfig.DeviceID, "child-001"), "device id to subscribe")
|
||||
groupID := flag.String("group-id", fileConfig.GroupID, "group id to subscribe")
|
||||
topic := flag.String("topic", valueOr(fileConfig.Topic, "telemetry.location"), "single topic to subscribe")
|
||||
topics := flag.String("topics", strings.Join(fileConfig.Topics, ","), "comma-separated topics to subscribe, overrides -topic when set")
|
||||
snapshot := flag.Bool("snapshot", boolValue(fileConfig.Snapshot, true), "request latest snapshot after subscribe")
|
||||
flag.Parse()
|
||||
|
||||
subscriptions := resolveSubscriptions(fileConfig, *deviceID, *groupID, *topic, *topics)
|
||||
if len(subscriptions) == 0 {
|
||||
log.Fatalf("no subscriptions configured")
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
conn, _, err := websocket.Dial(ctx, *url, nil)
|
||||
if err != nil {
|
||||
log.Fatalf("dial gateway: %v", err)
|
||||
}
|
||||
defer conn.Close(websocket.StatusNormalClosure, "consumer closed")
|
||||
|
||||
var welcome model.ServerMessage
|
||||
if err := wsjson.Read(ctx, conn, &welcome); err != nil {
|
||||
log.Fatalf("read welcome: %v", err)
|
||||
}
|
||||
log.Printf("connected: session=%s", welcome.SessionID)
|
||||
|
||||
if *token != "" || *channelID != "" {
|
||||
authReq := model.ClientMessage{
|
||||
Type: "authenticate",
|
||||
Role: model.RoleConsumer,
|
||||
Token: *token,
|
||||
}
|
||||
if *channelID != "" {
|
||||
authReq.Type = "join_channel"
|
||||
authReq.ChannelID = *channelID
|
||||
}
|
||||
if err := wsjson.Write(ctx, conn, authReq); err != nil {
|
||||
log.Fatalf("send authenticate: %v", err)
|
||||
}
|
||||
var authResp model.ServerMessage
|
||||
if err := wsjson.Read(ctx, conn, &authResp); err != nil {
|
||||
log.Fatalf("read authenticate response: %v", err)
|
||||
}
|
||||
if authResp.Type == "error" {
|
||||
log.Fatalf("authenticate failed: %s", authResp.Error)
|
||||
}
|
||||
log.Printf("authenticated: session=%s", authResp.SessionID)
|
||||
}
|
||||
|
||||
subReq := model.ClientMessage{
|
||||
Type: "subscribe",
|
||||
Subscriptions: subscriptions,
|
||||
}
|
||||
if err := wsjson.Write(ctx, conn, subReq); err != nil {
|
||||
log.Fatalf("send subscribe: %v", err)
|
||||
}
|
||||
|
||||
var subResp model.ServerMessage
|
||||
if err := wsjson.Read(ctx, conn, &subResp); err != nil {
|
||||
log.Fatalf("read subscribe response: %v", err)
|
||||
}
|
||||
if subResp.Type == "error" {
|
||||
log.Fatalf("subscribe failed: %s", subResp.Error)
|
||||
}
|
||||
log.Printf("subscribed: %s", describeSubscriptions(subscriptions))
|
||||
|
||||
if *snapshot {
|
||||
req := model.ClientMessage{
|
||||
Type: "snapshot",
|
||||
Subscriptions: []model.Subscription{
|
||||
{DeviceID: firstSnapshotDeviceID(subscriptions, *deviceID)},
|
||||
},
|
||||
}
|
||||
if err := wsjson.Write(ctx, conn, req); err != nil {
|
||||
log.Fatalf("send snapshot: %v", err)
|
||||
}
|
||||
var resp model.ServerMessage
|
||||
if err := wsjson.Read(ctx, conn, &resp); err != nil {
|
||||
log.Fatalf("read snapshot response: %v", err)
|
||||
}
|
||||
if resp.Type == "snapshot" {
|
||||
log.Printf("snapshot: %s", compactJSON(resp.State))
|
||||
} else if resp.Type == "error" {
|
||||
log.Printf("snapshot unavailable: %s", resp.Error)
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
var message model.ServerMessage
|
||||
if err := wsjson.Read(ctx, conn, &message); err != nil {
|
||||
log.Fatalf("read event: %v", err)
|
||||
}
|
||||
switch message.Type {
|
||||
case "event":
|
||||
log.Printf("event: %s", describeEnvelope(message.Envelope))
|
||||
case "error":
|
||||
log.Printf("server error: %s", message.Error)
|
||||
default:
|
||||
log.Printf("message: type=%s", message.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadConsumerConfig(path string) (consumerConfig, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return consumerConfig{}, nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return consumerConfig{}, fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
|
||||
var cfg consumerConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return consumerConfig{}, fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func findConfigPath(args []string) string {
|
||||
for index := 0; index < len(args); index++ {
|
||||
arg := args[index]
|
||||
if strings.HasPrefix(arg, "-config=") {
|
||||
return strings.TrimSpace(strings.TrimPrefix(arg, "-config="))
|
||||
}
|
||||
if arg == "-config" && index+1 < len(args) {
|
||||
return strings.TrimSpace(args[index+1])
|
||||
}
|
||||
if strings.HasPrefix(arg, "--config=") {
|
||||
return strings.TrimSpace(strings.TrimPrefix(arg, "--config="))
|
||||
}
|
||||
if arg == "--config" && index+1 < len(args) {
|
||||
return strings.TrimSpace(args[index+1])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func valueOr(value string, fallback string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func boolValue(value *bool, fallback bool) bool {
|
||||
if value == nil {
|
||||
return fallback
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func resolveSubscriptions(fileConfig consumerConfig, deviceID string, groupID string, topic string, topicsCSV string) []model.Subscription {
|
||||
if len(fileConfig.Subscriptions) > 0 && strings.TrimSpace(topicsCSV) == "" && strings.TrimSpace(topic) == valueOr(fileConfig.Topic, "telemetry.location") {
|
||||
return filterValidSubscriptions(fileConfig.Subscriptions)
|
||||
}
|
||||
|
||||
topics := parseTopics(topicsCSV)
|
||||
if len(topics) == 0 {
|
||||
topics = []string{topic}
|
||||
}
|
||||
|
||||
subscriptions := make([]model.Subscription, 0, len(topics))
|
||||
for _, entry := range topics {
|
||||
subscriptions = append(subscriptions, model.Subscription{
|
||||
DeviceID: deviceID,
|
||||
GroupID: groupID,
|
||||
Topic: entry,
|
||||
})
|
||||
}
|
||||
return filterValidSubscriptions(subscriptions)
|
||||
}
|
||||
|
||||
func parseTopics(value string) []string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(value, ",")
|
||||
topics := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
topic := strings.TrimSpace(part)
|
||||
if topic == "" {
|
||||
continue
|
||||
}
|
||||
topics = append(topics, topic)
|
||||
}
|
||||
return topics
|
||||
}
|
||||
|
||||
func filterValidSubscriptions(items []model.Subscription) []model.Subscription {
|
||||
subscriptions := make([]model.Subscription, 0, len(items))
|
||||
for _, item := range items {
|
||||
if strings.TrimSpace(item.ChannelID) == "" && strings.TrimSpace(item.DeviceID) == "" && strings.TrimSpace(item.GroupID) == "" {
|
||||
continue
|
||||
}
|
||||
subscriptions = append(subscriptions, model.Subscription{
|
||||
ChannelID: strings.TrimSpace(item.ChannelID),
|
||||
DeviceID: strings.TrimSpace(item.DeviceID),
|
||||
GroupID: strings.TrimSpace(item.GroupID),
|
||||
Topic: strings.TrimSpace(item.Topic),
|
||||
})
|
||||
}
|
||||
return subscriptions
|
||||
}
|
||||
|
||||
func describeSubscriptions(items []model.Subscription) string {
|
||||
parts := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
scope := item.DeviceID
|
||||
if scope == "" {
|
||||
if item.GroupID != "" {
|
||||
scope = "group:" + item.GroupID
|
||||
} else {
|
||||
scope = "all"
|
||||
}
|
||||
}
|
||||
if item.ChannelID != "" {
|
||||
scope = "channel:" + item.ChannelID + " / " + scope
|
||||
}
|
||||
if item.Topic != "" {
|
||||
scope += "/" + item.Topic
|
||||
}
|
||||
parts = append(parts, scope)
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func firstSnapshotDeviceID(items []model.Subscription, fallback string) string {
|
||||
for _, item := range items {
|
||||
if strings.TrimSpace(item.DeviceID) != "" {
|
||||
return item.DeviceID
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func describeEnvelope(envelope *model.Envelope) string {
|
||||
if envelope == nil {
|
||||
return "{}"
|
||||
}
|
||||
switch envelope.Topic {
|
||||
case "telemetry.location":
|
||||
return describeLocationEnvelope(envelope)
|
||||
case "telemetry.heart_rate":
|
||||
return describeHeartRateEnvelope(envelope)
|
||||
default:
|
||||
return compactEnvelope(envelope)
|
||||
}
|
||||
}
|
||||
|
||||
func describeLocationEnvelope(envelope *model.Envelope) string {
|
||||
var payload struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
Speed float64 `json:"speed"`
|
||||
Bearing float64 `json:"bearing"`
|
||||
Accuracy float64 `json:"accuracy"`
|
||||
CoordSystem string `json:"coordSystem"`
|
||||
}
|
||||
if err := json.Unmarshal(envelope.Payload, &payload); err != nil {
|
||||
return compactEnvelope(envelope)
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"gps device=%s lat=%.6f lng=%.6f speed=%.2f bearing=%.1f accuracy=%.1f coord=%s",
|
||||
envelope.Target.DeviceID,
|
||||
payload.Lat,
|
||||
payload.Lng,
|
||||
payload.Speed,
|
||||
payload.Bearing,
|
||||
payload.Accuracy,
|
||||
payload.CoordSystem,
|
||||
)
|
||||
}
|
||||
|
||||
func describeHeartRateEnvelope(envelope *model.Envelope) string {
|
||||
var payload struct {
|
||||
BPM int `json:"bpm"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
}
|
||||
if err := json.Unmarshal(envelope.Payload, &payload); err != nil {
|
||||
return compactEnvelope(envelope)
|
||||
}
|
||||
if payload.Confidence > 0 {
|
||||
return fmt.Sprintf("heart_rate device=%s bpm=%d confidence=%.2f", envelope.Target.DeviceID, payload.BPM, payload.Confidence)
|
||||
}
|
||||
return fmt.Sprintf("heart_rate device=%s bpm=%d", envelope.Target.DeviceID, payload.BPM)
|
||||
}
|
||||
|
||||
func compactEnvelope(envelope *model.Envelope) string {
|
||||
if envelope == nil {
|
||||
return "{}"
|
||||
}
|
||||
data, err := json.Marshal(envelope)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return compactJSON(data)
|
||||
}
|
||||
|
||||
func compactJSON(data []byte) string {
|
||||
var value any
|
||||
if err := json.Unmarshal(data, &value); err != nil {
|
||||
return string(data)
|
||||
}
|
||||
compact, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return string(data)
|
||||
}
|
||||
return string(compact)
|
||||
}
|
||||
190
realtime-gateway/cmd/mock-producer/main.go
Normal file
190
realtime-gateway/cmd/mock-producer/main.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/coder/websocket/wsjson"
|
||||
|
||||
"realtime-gateway/internal/model"
|
||||
)
|
||||
|
||||
func main() {
|
||||
url := flag.String("url", "ws://127.0.0.1:8080/ws", "gateway websocket url")
|
||||
token := flag.String("token", "dev-producer-token", "producer token")
|
||||
channelID := flag.String("channel-id", "", "channel id to join before publish")
|
||||
deviceID := flag.String("device-id", "child-001", "target device id")
|
||||
groupID := flag.String("group-id", "", "target group id")
|
||||
sourceID := flag.String("source-id", "mock-producer-001", "source id")
|
||||
mode := flag.String("mode", "mock", "source mode")
|
||||
topic := flag.String("topic", "telemetry.location", "publish topic")
|
||||
lat := flag.Float64("lat", 31.2304, "start latitude")
|
||||
lng := flag.Float64("lng", 121.4737, "start longitude")
|
||||
speed := flag.Float64("speed", 1.2, "speed value")
|
||||
accuracy := flag.Float64("accuracy", 6, "accuracy value")
|
||||
bpm := flag.Int("bpm", 120, "heart rate value when topic is telemetry.heart_rate")
|
||||
confidence := flag.Float64("confidence", 0, "heart rate confidence when topic is telemetry.heart_rate")
|
||||
interval := flag.Duration("interval", time.Second, "publish interval")
|
||||
count := flag.Int("count", 0, "number of messages to send, 0 means unlimited")
|
||||
flag.Parse()
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
conn, _, err := websocket.Dial(ctx, *url, nil)
|
||||
if err != nil {
|
||||
log.Fatalf("dial gateway: %v", err)
|
||||
}
|
||||
defer conn.Close(websocket.StatusNormalClosure, "producer closed")
|
||||
|
||||
var welcome model.ServerMessage
|
||||
if err := wsjson.Read(ctx, conn, &welcome); err != nil {
|
||||
log.Fatalf("read welcome: %v", err)
|
||||
}
|
||||
log.Printf("connected: session=%s", welcome.SessionID)
|
||||
|
||||
authReq := model.ClientMessage{
|
||||
Type: "authenticate",
|
||||
Role: model.RoleProducer,
|
||||
Token: *token,
|
||||
}
|
||||
if *channelID != "" {
|
||||
authReq.Type = "join_channel"
|
||||
authReq.ChannelID = *channelID
|
||||
}
|
||||
if err := wsjson.Write(ctx, conn, authReq); err != nil {
|
||||
log.Fatalf("send authenticate: %v", err)
|
||||
}
|
||||
|
||||
var authResp model.ServerMessage
|
||||
if err := wsjson.Read(ctx, conn, &authResp); err != nil {
|
||||
log.Fatalf("read authenticate response: %v", err)
|
||||
}
|
||||
if authResp.Type == "error" {
|
||||
log.Fatalf("authenticate failed: %s", authResp.Error)
|
||||
}
|
||||
log.Printf("authenticated: session=%s", authResp.SessionID)
|
||||
|
||||
ticker := time.NewTicker(*interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
sent := 0
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Println("producer stopping")
|
||||
return
|
||||
case <-ticker.C:
|
||||
envelope := model.Envelope{
|
||||
SchemaVersion: 1,
|
||||
MessageID: messageID(sent + 1),
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
Topic: *topic,
|
||||
Source: model.Source{
|
||||
Kind: model.RoleProducer,
|
||||
ID: *sourceID,
|
||||
Mode: *mode,
|
||||
},
|
||||
Target: model.Target{
|
||||
DeviceID: *deviceID,
|
||||
GroupID: *groupID,
|
||||
},
|
||||
Payload: payloadForTopic(*topic, *lat, *lng, *speed, *accuracy, *bpm, *confidence, sent),
|
||||
}
|
||||
|
||||
req := model.ClientMessage{
|
||||
Type: "publish",
|
||||
Envelope: &envelope,
|
||||
}
|
||||
if err := wsjson.Write(ctx, conn, req); err != nil {
|
||||
log.Fatalf("publish failed: %v", err)
|
||||
}
|
||||
|
||||
var resp model.ServerMessage
|
||||
if err := wsjson.Read(ctx, conn, &resp); err != nil {
|
||||
log.Fatalf("read publish response: %v", err)
|
||||
}
|
||||
if resp.Type == "error" {
|
||||
log.Fatalf("publish rejected: %s", resp.Error)
|
||||
}
|
||||
|
||||
sent++
|
||||
log.Printf("published #%d %s", sent, describePublished(*topic, *deviceID, *lat, *lng, *speed, *accuracy, *bpm, *confidence, sent))
|
||||
if *count > 0 && sent >= *count {
|
||||
log.Println("producer completed")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func payloadForTopic(topic string, baseLat, baseLng, speed, accuracy float64, bpm int, confidence float64, step int) []byte {
|
||||
if topic == "telemetry.heart_rate" {
|
||||
return heartRatePayload(bpm, confidence)
|
||||
}
|
||||
return locationPayload(baseLat, baseLng, speed, accuracy, step)
|
||||
}
|
||||
|
||||
func locationPayload(baseLat, baseLng, speed, accuracy float64, step int) []byte {
|
||||
lat := currentLat(baseLat, step)
|
||||
lng := currentLng(baseLng, step)
|
||||
return []byte(
|
||||
`{"lat":` + formatFloat(lat) +
|
||||
`,"lng":` + formatFloat(lng) +
|
||||
`,"speed":` + formatFloat(speed) +
|
||||
`,"bearing":90` +
|
||||
`,"accuracy":` + formatFloat(accuracy) +
|
||||
`,"coordSystem":"GCJ02"}`,
|
||||
)
|
||||
}
|
||||
|
||||
func heartRatePayload(bpm int, confidence float64) []byte {
|
||||
if confidence > 0 {
|
||||
return []byte(
|
||||
`{"bpm":` + strconv.Itoa(maxInt(1, bpm)) +
|
||||
`,"confidence":` + formatFloat(confidence) +
|
||||
`}`,
|
||||
)
|
||||
}
|
||||
return []byte(`{"bpm":` + strconv.Itoa(maxInt(1, bpm)) + `}`)
|
||||
}
|
||||
|
||||
func currentLat(base float64, step int) float64 {
|
||||
return base + float64(step)*0.00002
|
||||
}
|
||||
|
||||
func currentLng(base float64, step int) float64 {
|
||||
return base + float64(step)*0.00003
|
||||
}
|
||||
|
||||
func messageID(step int) string {
|
||||
return "msg-" + strconv.Itoa(step)
|
||||
}
|
||||
|
||||
func formatFloat(v float64) string {
|
||||
return strconv.FormatFloat(v, 'f', 6, 64)
|
||||
}
|
||||
|
||||
func describePublished(topic string, deviceID string, lat, lng, speed, accuracy float64, bpm int, confidence float64, step int) string {
|
||||
if topic == "telemetry.heart_rate" {
|
||||
if confidence > 0 {
|
||||
return "device=" + deviceID + " topic=" + topic + " bpm=" + strconv.Itoa(maxInt(1, bpm)) + " confidence=" + formatFloat(confidence)
|
||||
}
|
||||
return "device=" + deviceID + " topic=" + topic + " bpm=" + strconv.Itoa(maxInt(1, bpm))
|
||||
}
|
||||
return "device=" + deviceID + " topic=" + topic + " lat=" + formatFloat(currentLat(lat, step)) + " lng=" + formatFloat(currentLng(lng, step)) + " speed=" + formatFloat(speed) + " accuracy=" + formatFloat(accuracy)
|
||||
}
|
||||
|
||||
func maxInt(left int, right int) int {
|
||||
if left > right {
|
||||
return left
|
||||
}
|
||||
return right
|
||||
}
|
||||
15
realtime-gateway/config/consumer-gps-heart.example.json
Normal file
15
realtime-gateway/config/consumer-gps-heart.example.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"url": "ws://127.0.0.1:18080/ws",
|
||||
"token": "replace-with-dev-consumer-token",
|
||||
"snapshot": true,
|
||||
"subscriptions": [
|
||||
{
|
||||
"deviceId": "child-001",
|
||||
"topic": "telemetry.location"
|
||||
},
|
||||
{
|
||||
"deviceId": "child-001",
|
||||
"topic": "telemetry.heart_rate"
|
||||
}
|
||||
]
|
||||
}
|
||||
10
realtime-gateway/config/consumer-tunnel.example.json
Normal file
10
realtime-gateway/config/consumer-tunnel.example.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"url": "wss://your-tunnel-host.example.com/ws",
|
||||
"token": "replace-with-dev-consumer-token",
|
||||
"topics": [
|
||||
"telemetry.location",
|
||||
"telemetry.heart_rate"
|
||||
],
|
||||
"deviceId": "child-001",
|
||||
"snapshot": true
|
||||
}
|
||||
28
realtime-gateway/config/dev.json
Normal file
28
realtime-gateway/config/dev.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"server": {
|
||||
"httpListen": ":8080",
|
||||
"readTimeoutSeconds": 15,
|
||||
"writeTimeoutSeconds": 15,
|
||||
"idleTimeoutSeconds": 60,
|
||||
"shutdownTimeoutSeconds": 10
|
||||
},
|
||||
"gateway": {
|
||||
"maxPayloadBytes": 65536,
|
||||
"writeWaitSeconds": 10,
|
||||
"pongWaitSeconds": 60,
|
||||
"pingIntervalSeconds": 25,
|
||||
"maxLatestStateEntries": 10000
|
||||
},
|
||||
"auth": {
|
||||
"producerTokens": [
|
||||
"dev-producer-token"
|
||||
],
|
||||
"consumerTokens": [
|
||||
"dev-consumer-token"
|
||||
],
|
||||
"controllerTokens": [
|
||||
"dev-controller-token"
|
||||
],
|
||||
"allowAnonymousConsumers": true
|
||||
}
|
||||
}
|
||||
28
realtime-gateway/config/tunnel-dev.json
Normal file
28
realtime-gateway/config/tunnel-dev.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"server": {
|
||||
"httpListen": ":18080",
|
||||
"readTimeoutSeconds": 15,
|
||||
"writeTimeoutSeconds": 15,
|
||||
"idleTimeoutSeconds": 60,
|
||||
"shutdownTimeoutSeconds": 10
|
||||
},
|
||||
"gateway": {
|
||||
"maxPayloadBytes": 65536,
|
||||
"writeWaitSeconds": 10,
|
||||
"pongWaitSeconds": 60,
|
||||
"pingIntervalSeconds": 25,
|
||||
"maxLatestStateEntries": 10000
|
||||
},
|
||||
"auth": {
|
||||
"producerTokens": [
|
||||
"replace-with-dev-producer-token"
|
||||
],
|
||||
"consumerTokens": [
|
||||
"replace-with-dev-consumer-token"
|
||||
],
|
||||
"controllerTokens": [
|
||||
"replace-with-dev-controller-token"
|
||||
],
|
||||
"allowAnonymousConsumers": false
|
||||
}
|
||||
}
|
||||
7
realtime-gateway/deploy/cloudflared/config.example.yml
Normal file
7
realtime-gateway/deploy/cloudflared/config.example.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
tunnel: YOUR_TUNNEL_ID
|
||||
credentials-file: C:\Users\YOUR_USER\.cloudflared\YOUR_TUNNEL_ID.json
|
||||
|
||||
ingress:
|
||||
- hostname: gateway-dev.example.com
|
||||
service: http://localhost:18080
|
||||
- service: http_status:404
|
||||
5
realtime-gateway/go.mod
Normal file
5
realtime-gateway/go.mod
Normal file
@@ -0,0 +1,5 @@
|
||||
module realtime-gateway
|
||||
|
||||
go 1.25.1
|
||||
|
||||
require github.com/coder/websocket v1.8.14
|
||||
2
realtime-gateway/go.sum
Normal file
2
realtime-gateway/go.sum
Normal file
@@ -0,0 +1,2 @@
|
||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
262
realtime-gateway/internal/channel/manager.go
Normal file
262
realtime-gateway/internal/channel/manager.go
Normal file
@@ -0,0 +1,262 @@
|
||||
package channel
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"realtime-gateway/internal/model"
|
||||
)
|
||||
|
||||
const (
|
||||
DeliveryModeCacheLatest = "cache_latest"
|
||||
DeliveryModeDropIfNoConsumer = "drop_if_no_consumer"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrChannelNotFound = errors.New("channel not found")
|
||||
ErrChannelExpired = errors.New("channel expired")
|
||||
ErrChannelUnauthorized = errors.New("channel token invalid")
|
||||
ErrInvalidRole = errors.New("invalid channel role")
|
||||
)
|
||||
|
||||
type CreateRequest struct {
|
||||
Label string
|
||||
DeliveryMode string
|
||||
TTLSeconds int
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label,omitempty"`
|
||||
DeliveryMode string `json:"deliveryMode"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
ActiveProducers int `json:"activeProducers"`
|
||||
ActiveConsumers int `json:"activeConsumers"`
|
||||
ActiveControllers int `json:"activeControllers"`
|
||||
}
|
||||
|
||||
type CreateResponse struct {
|
||||
Snapshot Snapshot `json:"snapshot"`
|
||||
ProducerToken string `json:"producerToken"`
|
||||
ConsumerToken string `json:"consumerToken"`
|
||||
ControllerToken string `json:"controllerToken"`
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
defaultTTL time.Duration
|
||||
channels map[string]*channelState
|
||||
}
|
||||
|
||||
type channelState struct {
|
||||
id string
|
||||
label string
|
||||
deliveryMode string
|
||||
createdAt time.Time
|
||||
expiresAt time.Time
|
||||
producerToken string
|
||||
consumerToken string
|
||||
controllerToken string
|
||||
activeProducers int
|
||||
activeConsumers int
|
||||
activeControllers int
|
||||
}
|
||||
|
||||
func NewManager(defaultTTL time.Duration) *Manager {
|
||||
if defaultTTL <= 0 {
|
||||
defaultTTL = 8 * time.Hour
|
||||
}
|
||||
return &Manager{
|
||||
defaultTTL: defaultTTL,
|
||||
channels: make(map[string]*channelState),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Create(request CreateRequest) (CreateResponse, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
deliveryMode := normalizeDeliveryMode(request.DeliveryMode)
|
||||
ttl := m.defaultTTL
|
||||
if request.TTLSeconds > 0 {
|
||||
ttl = time.Duration(request.TTLSeconds) * time.Second
|
||||
}
|
||||
|
||||
state := &channelState{
|
||||
id: "ch-" + randomHex(6),
|
||||
label: request.Label,
|
||||
deliveryMode: deliveryMode,
|
||||
createdAt: now,
|
||||
expiresAt: now.Add(ttl),
|
||||
producerToken: randomHex(16),
|
||||
consumerToken: randomHex(16),
|
||||
controllerToken: randomHex(16),
|
||||
}
|
||||
m.channels[state.id] = state
|
||||
|
||||
return CreateResponse{
|
||||
Snapshot: snapshotOf(state),
|
||||
ProducerToken: state.producerToken,
|
||||
ConsumerToken: state.consumerToken,
|
||||
ControllerToken: state.controllerToken,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Join(channelID string, token string, role model.Role) (Snapshot, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
state, ok := m.channels[channelID]
|
||||
if !ok {
|
||||
return Snapshot{}, ErrChannelNotFound
|
||||
}
|
||||
if state.expiresAt.Before(time.Now()) {
|
||||
return Snapshot{}, ErrChannelExpired
|
||||
}
|
||||
if !authorizeToken(state, role, token) {
|
||||
return Snapshot{}, ErrChannelUnauthorized
|
||||
}
|
||||
return snapshotOf(state), nil
|
||||
}
|
||||
|
||||
func (m *Manager) Bind(channelID string, role model.Role) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
state, ok := m.channels[channelID]
|
||||
if !ok {
|
||||
return ErrChannelNotFound
|
||||
}
|
||||
if state.expiresAt.Before(time.Now()) {
|
||||
return ErrChannelExpired
|
||||
}
|
||||
|
||||
switch role {
|
||||
case model.RoleProducer:
|
||||
state.activeProducers++
|
||||
case model.RoleConsumer:
|
||||
state.activeConsumers++
|
||||
case model.RoleController:
|
||||
state.activeControllers++
|
||||
default:
|
||||
return ErrInvalidRole
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Unbind(channelID string, role model.Role) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
state, ok := m.channels[channelID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
switch role {
|
||||
case model.RoleProducer:
|
||||
if state.activeProducers > 0 {
|
||||
state.activeProducers--
|
||||
}
|
||||
case model.RoleConsumer:
|
||||
if state.activeConsumers > 0 {
|
||||
state.activeConsumers--
|
||||
}
|
||||
case model.RoleController:
|
||||
if state.activeControllers > 0 {
|
||||
state.activeControllers--
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) DeliveryMode(channelID string) string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
state, ok := m.channels[channelID]
|
||||
if !ok {
|
||||
return DeliveryModeCacheLatest
|
||||
}
|
||||
return state.deliveryMode
|
||||
}
|
||||
|
||||
func (m *Manager) HasConsumers(channelID string) bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
state, ok := m.channels[channelID]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return state.activeConsumers > 0
|
||||
}
|
||||
|
||||
func (m *Manager) List() []Snapshot {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
now := time.Now()
|
||||
items := make([]Snapshot, 0, len(m.channels))
|
||||
for _, state := range m.channels {
|
||||
if state.expiresAt.Before(now) {
|
||||
continue
|
||||
}
|
||||
items = append(items, snapshotOf(state))
|
||||
}
|
||||
sort.Slice(items, func(i int, j int) bool {
|
||||
return items[i].CreatedAt.After(items[j].CreatedAt)
|
||||
})
|
||||
return items
|
||||
}
|
||||
|
||||
func normalizeDeliveryMode(value string) string {
|
||||
switch value {
|
||||
case DeliveryModeDropIfNoConsumer:
|
||||
return DeliveryModeDropIfNoConsumer
|
||||
default:
|
||||
return DeliveryModeCacheLatest
|
||||
}
|
||||
}
|
||||
|
||||
func authorizeToken(state *channelState, role model.Role, token string) bool {
|
||||
switch role {
|
||||
case model.RoleProducer:
|
||||
return state.producerToken == token
|
||||
case model.RoleConsumer:
|
||||
return state.consumerToken == token
|
||||
case model.RoleController:
|
||||
return state.controllerToken == token
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func snapshotOf(state *channelState) Snapshot {
|
||||
return Snapshot{
|
||||
ID: state.id,
|
||||
Label: state.label,
|
||||
DeliveryMode: state.deliveryMode,
|
||||
CreatedAt: state.createdAt,
|
||||
ExpiresAt: state.expiresAt,
|
||||
ActiveProducers: state.activeProducers,
|
||||
ActiveConsumers: state.activeConsumers,
|
||||
ActiveControllers: state.activeControllers,
|
||||
}
|
||||
}
|
||||
|
||||
func randomHex(size int) string {
|
||||
if size <= 0 {
|
||||
size = 8
|
||||
}
|
||||
buf := make([]byte, size)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return strconv.FormatInt(time.Now().UnixNano(), 16)
|
||||
}
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
123
realtime-gateway/internal/config/config.go
Normal file
123
realtime-gateway/internal/config/config.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig `json:"server"`
|
||||
Gateway GatewayConfig `json:"gateway"`
|
||||
Auth AuthConfig `json:"auth"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
HTTPListen string `json:"httpListen"`
|
||||
ReadTimeoutSeconds int `json:"readTimeoutSeconds"`
|
||||
WriteTimeoutSeconds int `json:"writeTimeoutSeconds"`
|
||||
IdleTimeoutSeconds int `json:"idleTimeoutSeconds"`
|
||||
ShutdownTimeoutSeconds int `json:"shutdownTimeoutSeconds"`
|
||||
}
|
||||
|
||||
type GatewayConfig struct {
|
||||
MaxPayloadBytes int `json:"maxPayloadBytes"`
|
||||
WriteWaitSeconds int `json:"writeWaitSeconds"`
|
||||
PongWaitSeconds int `json:"pongWaitSeconds"`
|
||||
PingIntervalSeconds int `json:"pingIntervalSeconds"`
|
||||
MaxLatestStateEntries int `json:"maxLatestStateEntries"`
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
ProducerTokens []string `json:"producerTokens"`
|
||||
ConsumerTokens []string `json:"consumerTokens"`
|
||||
ControllerTokens []string `json:"controllerTokens"`
|
||||
AllowAnonymousConsumers bool `json:"allowAnonymousConsumers"`
|
||||
}
|
||||
|
||||
func Load(path string) (Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return Config{}, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
|
||||
cfg.applyDefaults()
|
||||
if err := cfg.validate(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (c *Config) applyDefaults() {
|
||||
if c.Server.HTTPListen == "" {
|
||||
c.Server.HTTPListen = ":8080"
|
||||
}
|
||||
if c.Server.ReadTimeoutSeconds <= 0 {
|
||||
c.Server.ReadTimeoutSeconds = 15
|
||||
}
|
||||
if c.Server.WriteTimeoutSeconds <= 0 {
|
||||
c.Server.WriteTimeoutSeconds = 15
|
||||
}
|
||||
if c.Server.IdleTimeoutSeconds <= 0 {
|
||||
c.Server.IdleTimeoutSeconds = 60
|
||||
}
|
||||
if c.Server.ShutdownTimeoutSeconds <= 0 {
|
||||
c.Server.ShutdownTimeoutSeconds = 10
|
||||
}
|
||||
if c.Gateway.MaxPayloadBytes <= 0 {
|
||||
c.Gateway.MaxPayloadBytes = 64 * 1024
|
||||
}
|
||||
if c.Gateway.WriteWaitSeconds <= 0 {
|
||||
c.Gateway.WriteWaitSeconds = 10
|
||||
}
|
||||
if c.Gateway.PongWaitSeconds <= 0 {
|
||||
c.Gateway.PongWaitSeconds = 60
|
||||
}
|
||||
if c.Gateway.PingIntervalSeconds <= 0 {
|
||||
c.Gateway.PingIntervalSeconds = 25
|
||||
}
|
||||
if c.Gateway.MaxLatestStateEntries <= 0 {
|
||||
c.Gateway.MaxLatestStateEntries = 10000
|
||||
}
|
||||
}
|
||||
|
||||
func (c Config) validate() error {
|
||||
if c.Gateway.PingInterval() >= c.Gateway.PongWait() {
|
||||
return fmt.Errorf("gateway.pingIntervalSeconds must be smaller than gateway.pongWaitSeconds")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c ServerConfig) ReadTimeout() time.Duration {
|
||||
return time.Duration(c.ReadTimeoutSeconds) * time.Second
|
||||
}
|
||||
|
||||
func (c ServerConfig) WriteTimeout() time.Duration {
|
||||
return time.Duration(c.WriteTimeoutSeconds) * time.Second
|
||||
}
|
||||
|
||||
func (c ServerConfig) IdleTimeout() time.Duration {
|
||||
return time.Duration(c.IdleTimeoutSeconds) * time.Second
|
||||
}
|
||||
|
||||
func (c ServerConfig) ShutdownTimeout() time.Duration {
|
||||
return time.Duration(c.ShutdownTimeoutSeconds) * time.Second
|
||||
}
|
||||
|
||||
func (c GatewayConfig) WriteWait() time.Duration {
|
||||
return time.Duration(c.WriteWaitSeconds) * time.Second
|
||||
}
|
||||
|
||||
func (c GatewayConfig) PongWait() time.Duration {
|
||||
return time.Duration(c.PongWaitSeconds) * time.Second
|
||||
}
|
||||
|
||||
func (c GatewayConfig) PingInterval() time.Duration {
|
||||
return time.Duration(c.PingIntervalSeconds) * time.Second
|
||||
}
|
||||
228
realtime-gateway/internal/gateway/admin_ui.go
Normal file
228
realtime-gateway/internal/gateway/admin_ui.go
Normal file
@@ -0,0 +1,228 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"realtime-gateway/internal/model"
|
||||
)
|
||||
|
||||
//go:embed adminui/*
|
||||
var adminUIFiles embed.FS
|
||||
|
||||
type adminOverview struct {
|
||||
Status string `json:"status"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
Now time.Time `json:"now"`
|
||||
UptimeSeconds int64 `json:"uptimeSeconds"`
|
||||
HTTPListen string `json:"httpListen"`
|
||||
Anonymous bool `json:"anonymousConsumers"`
|
||||
Metrics map[string]any `json:"metrics"`
|
||||
Auth map[string]any `json:"auth"`
|
||||
Endpoints map[string]any `json:"endpoints"`
|
||||
}
|
||||
|
||||
func (s *Server) registerAdminRoutes(mux *http.ServeMux) error {
|
||||
sub, err := fs.Sub(adminUIFiles, "adminui")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fileServer := http.FileServer(http.FS(sub))
|
||||
mux.Handle("/assets/", http.StripPrefix("/assets/", noStoreHandler(fileServer)))
|
||||
mux.HandleFunc("/", s.handleAdminIndex)
|
||||
mux.HandleFunc("/admin", s.handleAdminIndex)
|
||||
mux.HandleFunc("/api/admin/overview", s.handleAdminOverview)
|
||||
mux.HandleFunc("/api/admin/sessions", s.handleAdminSessions)
|
||||
mux.HandleFunc("/api/admin/latest", s.handleAdminLatest)
|
||||
mux.HandleFunc("/api/admin/traffic", s.handleAdminTraffic)
|
||||
mux.HandleFunc("/api/admin/live", s.handleAdminLive)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" && r.URL.Path != "/admin" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := adminUIFiles.ReadFile("adminui/index.html")
|
||||
if err != nil {
|
||||
http.Error(w, "admin ui unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
func noStoreHandler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminOverview(w http.ResponseWriter, _ *http.Request) {
|
||||
subscriberCount, latestStateCount := s.hub.Stats()
|
||||
traffic := s.hub.TrafficSnapshot()
|
||||
now := time.Now()
|
||||
writeJSON(w, http.StatusOK, adminOverview{
|
||||
Status: "ok",
|
||||
StartedAt: s.startedAt,
|
||||
Now: now,
|
||||
UptimeSeconds: int64(now.Sub(s.startedAt).Seconds()),
|
||||
HTTPListen: s.cfg.Server.HTTPListen,
|
||||
Anonymous: s.cfg.Auth.AllowAnonymousConsumers,
|
||||
Metrics: map[string]any{
|
||||
"sessions": s.sessions.Count(),
|
||||
"subscribers": subscriberCount,
|
||||
"latestState": latestStateCount,
|
||||
"channels": len(s.channels.List()),
|
||||
"pluginHandlers": s.plugins.HandlerCount(),
|
||||
"published": traffic.Published,
|
||||
"dropped": traffic.Dropped,
|
||||
"fanout": traffic.Fanout,
|
||||
},
|
||||
Auth: map[string]any{
|
||||
"producerTokens": len(s.cfg.Auth.ProducerTokens),
|
||||
"consumerTokens": len(s.cfg.Auth.ConsumerTokens),
|
||||
"controllerTokens": len(s.cfg.Auth.ControllerTokens),
|
||||
},
|
||||
Endpoints: map[string]any{
|
||||
"websocket": "/ws",
|
||||
"health": "/healthz",
|
||||
"metrics": "/metrics",
|
||||
"createChannel": "/api/channel/create",
|
||||
"channels": "/api/admin/channels",
|
||||
"traffic": "/api/admin/traffic",
|
||||
"admin": "/admin",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminSessions(w http.ResponseWriter, _ *http.Request) {
|
||||
snapshots := s.sessions.List()
|
||||
sort.Slice(snapshots, func(i int, j int) bool {
|
||||
return snapshots[i].CreatedAt.After(snapshots[j].CreatedAt)
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"items": snapshots,
|
||||
"count": len(snapshots),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminLatest(w http.ResponseWriter, r *http.Request) {
|
||||
envelopes := s.hub.LatestStates()
|
||||
sort.Slice(envelopes, func(i int, j int) bool {
|
||||
return envelopes[i].Timestamp > envelopes[j].Timestamp
|
||||
})
|
||||
|
||||
query := strings.TrimSpace(r.URL.Query().Get("topic"))
|
||||
if query != "" {
|
||||
filtered := make([]any, 0, len(envelopes))
|
||||
for _, envelope := range envelopes {
|
||||
if envelope.Topic != query {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, adminLatestItem(envelope))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"items": filtered,
|
||||
"count": len(filtered),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]any, 0, len(envelopes))
|
||||
for _, envelope := range envelopes {
|
||||
items = append(items, adminLatestItem(envelope))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"items": items,
|
||||
"count": len(items),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminLive(w http.ResponseWriter, r *http.Request) {
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
topic := strings.TrimSpace(r.URL.Query().Get("topic"))
|
||||
channelID := strings.TrimSpace(r.URL.Query().Get("channelId"))
|
||||
deviceID := strings.TrimSpace(r.URL.Query().Get("deviceId"))
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
|
||||
id, stream := s.hub.SubscribeLive(64)
|
||||
defer s.hub.UnsubscribeLive(id)
|
||||
|
||||
fmt.Fprint(w, ": live stream ready\n\n")
|
||||
flusher.Flush()
|
||||
|
||||
ping := time.NewTicker(15 * time.Second)
|
||||
defer ping.Stop()
|
||||
|
||||
ctx := r.Context()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ping.C:
|
||||
fmt.Fprint(w, ": ping\n\n")
|
||||
flusher.Flush()
|
||||
case envelope, ok := <-stream:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if topic != "" && envelope.Topic != topic {
|
||||
continue
|
||||
}
|
||||
if channelID != "" && envelope.Target.ChannelID != channelID {
|
||||
continue
|
||||
}
|
||||
if deviceID != "" && envelope.Target.DeviceID != deviceID {
|
||||
continue
|
||||
}
|
||||
|
||||
data, err := json.Marshal(adminLatestItem(envelope))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, "event: envelope\ndata: %s\n\n", data)
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminTraffic(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, s.hub.TrafficSnapshot())
|
||||
}
|
||||
|
||||
func adminLatestItem(envelope model.Envelope) map[string]any {
|
||||
payload := map[string]any{}
|
||||
_ = json.Unmarshal(envelope.Payload, &payload)
|
||||
|
||||
return map[string]any{
|
||||
"timestamp": envelope.Timestamp,
|
||||
"topic": envelope.Topic,
|
||||
"channelId": envelope.Target.ChannelID,
|
||||
"deviceId": envelope.Target.DeviceID,
|
||||
"groupId": envelope.Target.GroupID,
|
||||
"sourceId": envelope.Source.ID,
|
||||
"mode": envelope.Source.Mode,
|
||||
"payload": payload,
|
||||
}
|
||||
}
|
||||
604
realtime-gateway/internal/gateway/adminui/app.js
Normal file
604
realtime-gateway/internal/gateway/adminui/app.js
Normal file
@@ -0,0 +1,604 @@
|
||||
(function () {
|
||||
const elements = {
|
||||
serviceBadge: document.getElementById('serviceBadge'),
|
||||
listenText: document.getElementById('listenText'),
|
||||
uptimeText: document.getElementById('uptimeText'),
|
||||
anonymousText: document.getElementById('anonymousText'),
|
||||
heroText: document.getElementById('heroText'),
|
||||
sessionsCount: document.getElementById('sessionsCount'),
|
||||
subscribersCount: document.getElementById('subscribersCount'),
|
||||
latestCount: document.getElementById('latestCount'),
|
||||
channelsCount: document.getElementById('channelsCount'),
|
||||
publishedCount: document.getElementById('publishedCount'),
|
||||
droppedCount: document.getElementById('droppedCount'),
|
||||
fanoutCount: document.getElementById('fanoutCount'),
|
||||
pluginsCount: document.getElementById('pluginsCount'),
|
||||
channelsTable: document.getElementById('channelsTable'),
|
||||
channelLabelInput: document.getElementById('channelLabelInput'),
|
||||
channelModeSelect: document.getElementById('channelModeSelect'),
|
||||
channelTTLInput: document.getElementById('channelTTLInput'),
|
||||
createChannelBtn: document.getElementById('createChannelBtn'),
|
||||
createChannelResult: document.getElementById('createChannelResult'),
|
||||
sessionsTable: document.getElementById('sessionsTable'),
|
||||
latestTable: document.getElementById('latestTable'),
|
||||
topicTrafficTable: document.getElementById('topicTrafficTable'),
|
||||
channelTrafficTable: document.getElementById('channelTrafficTable'),
|
||||
topicFilter: document.getElementById('topicFilter'),
|
||||
liveTopicFilter: document.getElementById('liveTopicFilter'),
|
||||
liveChannelFilter: document.getElementById('liveChannelFilter'),
|
||||
liveDeviceFilter: document.getElementById('liveDeviceFilter'),
|
||||
liveReconnectBtn: document.getElementById('liveReconnectBtn'),
|
||||
liveClearBtn: document.getElementById('liveClearBtn'),
|
||||
liveStatus: document.getElementById('liveStatus'),
|
||||
liveSummary: document.getElementById('liveSummary'),
|
||||
liveLocationCount: document.getElementById('liveLocationCount'),
|
||||
liveHeartRateCount: document.getElementById('liveHeartRateCount'),
|
||||
liveLastDevice: document.getElementById('liveLastDevice'),
|
||||
liveLastTopic: document.getElementById('liveLastTopic'),
|
||||
liveTrack: document.getElementById('liveTrack'),
|
||||
liveTrackLegend: document.getElementById('liveTrackLegend'),
|
||||
liveFeed: document.getElementById('liveFeed'),
|
||||
refreshBtn: document.getElementById('refreshBtn'),
|
||||
autoRefreshInput: document.getElementById('autoRefreshInput'),
|
||||
}
|
||||
|
||||
let timer = 0
|
||||
let liveSource = null
|
||||
let liveCount = 0
|
||||
const maxLiveLines = 120
|
||||
const maxTrackPoints = 80
|
||||
const liveTrackSeries = new Map()
|
||||
const liveStats = {
|
||||
location: 0,
|
||||
heartRate: 0,
|
||||
lastDevice: '--',
|
||||
lastTopic: '--',
|
||||
}
|
||||
const liveTrackPalette = ['#0f7a68', '#d57a1f', '#2878c8', '#8a4bd6', '#b24f6a', '#2c9f5e']
|
||||
|
||||
function setBadge(status) {
|
||||
elements.serviceBadge.textContent = status === 'ok' ? 'Online' : 'Unavailable'
|
||||
elements.serviceBadge.className = status === 'ok' ? 'badge is-ok' : 'badge'
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${hours}h ${minutes}m ${secs}s`
|
||||
}
|
||||
|
||||
function formatTime(value) {
|
||||
if (!value) {
|
||||
return '--'
|
||||
}
|
||||
return new Date(value).toLocaleString()
|
||||
}
|
||||
|
||||
async function loadJSON(url) {
|
||||
const response = await fetch(url, { cache: 'no-store' })
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
function renderSessions(payload) {
|
||||
const items = Array.isArray(payload.items) ? payload.items : []
|
||||
if (!items.length) {
|
||||
elements.sessionsTable.innerHTML = '<div class="empty">当前没有活跃会话。</div>'
|
||||
return
|
||||
}
|
||||
|
||||
const rows = items.map((item) => {
|
||||
const subscriptions = Array.isArray(item.subscriptions) && item.subscriptions.length
|
||||
? item.subscriptions.map((entry) => {
|
||||
const scope = entry.deviceId || `group:${entry.groupId || '--'}`
|
||||
return `${scope}${entry.topic ? ` / ${entry.topic}` : ''}`
|
||||
}).join('<br>')
|
||||
: '--'
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td><code>${item.id || '--'}</code></td>
|
||||
<td><code>${item.channelId || '--'}</code></td>
|
||||
<td>${item.role || '--'}</td>
|
||||
<td>${item.authenticated ? 'yes' : 'no'}</td>
|
||||
<td>${formatTime(item.createdAt)}</td>
|
||||
<td><div class="json-chip">${subscriptions}</div></td>
|
||||
</tr>
|
||||
`
|
||||
}).join('')
|
||||
|
||||
elements.sessionsTable.innerHTML = `
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Session</th>
|
||||
<th>Channel</th>
|
||||
<th>Role</th>
|
||||
<th>Auth</th>
|
||||
<th>Created</th>
|
||||
<th>Subscriptions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
`
|
||||
}
|
||||
|
||||
function renderChannels(payload) {
|
||||
const items = Array.isArray(payload.items) ? payload.items : []
|
||||
if (!items.length) {
|
||||
elements.channelsTable.innerHTML = '<div class="empty">当前没有 channel。</div>'
|
||||
return
|
||||
}
|
||||
|
||||
const rows = items.map((item) => `
|
||||
<tr>
|
||||
<td><code>${item.id || '--'}</code></td>
|
||||
<td>${item.label || '--'}</td>
|
||||
<td>${item.deliveryMode || '--'}</td>
|
||||
<td>${item.activeProducers || 0} / ${item.activeConsumers || 0} / ${item.activeControllers || 0}</td>
|
||||
<td>${formatTime(item.expiresAt)}</td>
|
||||
</tr>
|
||||
`).join('')
|
||||
|
||||
elements.channelsTable.innerHTML = `
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Channel</th>
|
||||
<th>Label</th>
|
||||
<th>Mode</th>
|
||||
<th>P / C / Ctrl</th>
|
||||
<th>Expires</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
`
|
||||
}
|
||||
|
||||
function renderLatest(payload) {
|
||||
const items = Array.isArray(payload.items) ? payload.items : []
|
||||
if (!items.length) {
|
||||
elements.latestTable.innerHTML = '<div class="empty">当前没有 latest state。</div>'
|
||||
return
|
||||
}
|
||||
|
||||
const rows = items.map((item) => `
|
||||
<tr>
|
||||
<td>${item.deviceId || '--'}</td>
|
||||
<td>${item.channelId || '--'}</td>
|
||||
<td>${item.topic || '--'}</td>
|
||||
<td>${item.sourceId || '--'}${item.mode ? ` / ${item.mode}` : ''}</td>
|
||||
<td>${formatTime(item.timestamp)}</td>
|
||||
<td><div class="json-chip">${escapeHTML(JSON.stringify(item.payload || {}))}</div></td>
|
||||
</tr>
|
||||
`).join('')
|
||||
|
||||
elements.latestTable.innerHTML = `
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Device</th>
|
||||
<th>Channel</th>
|
||||
<th>Topic</th>
|
||||
<th>Source</th>
|
||||
<th>Timestamp</th>
|
||||
<th>Payload</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
`
|
||||
}
|
||||
|
||||
function renderTrafficTable(container, columns, rows, emptyText) {
|
||||
if (!rows.length) {
|
||||
container.innerHTML = `<div class="empty">${emptyText}</div>`
|
||||
return
|
||||
}
|
||||
|
||||
const header = columns.map((column) => `<th>${column.label}</th>`).join('')
|
||||
const body = rows.map((row) => `
|
||||
<tr>${columns.map((column) => `<td>${column.render(row)}</td>`).join('')}</tr>
|
||||
`).join('')
|
||||
|
||||
container.innerHTML = `
|
||||
<table>
|
||||
<thead>
|
||||
<tr>${header}</tr>
|
||||
</thead>
|
||||
<tbody>${body}</tbody>
|
||||
</table>
|
||||
`
|
||||
}
|
||||
|
||||
function renderTraffic(payload) {
|
||||
const topicItems = Array.isArray(payload.topics) ? payload.topics.slice() : []
|
||||
topicItems.sort((left, right) => Number(right.published || 0) - Number(left.published || 0))
|
||||
renderTrafficTable(
|
||||
elements.topicTrafficTable,
|
||||
[
|
||||
{ label: 'Topic', render: (row) => `<code>${escapeHTML(row.topic || '--')}</code>` },
|
||||
{ label: 'Published', render: (row) => String(row.published || 0) },
|
||||
{ label: 'Dropped', render: (row) => String(row.dropped || 0) },
|
||||
{ label: 'Fanout', render: (row) => String(row.fanout || 0) },
|
||||
],
|
||||
topicItems,
|
||||
'当前没有 topic 流量。',
|
||||
)
|
||||
|
||||
const channelItems = Array.isArray(payload.channels) ? payload.channels.slice() : []
|
||||
channelItems.sort((left, right) => Number(right.published || 0) - Number(left.published || 0))
|
||||
renderTrafficTable(
|
||||
elements.channelTrafficTable,
|
||||
[
|
||||
{ label: 'Channel', render: (row) => `<code>${escapeHTML(row.channelId || '--')}</code>` },
|
||||
{ label: 'Published', render: (row) => String(row.published || 0) },
|
||||
{ label: 'Dropped', render: (row) => String(row.dropped || 0) },
|
||||
{ label: 'Fanout', render: (row) => String(row.fanout || 0) },
|
||||
],
|
||||
channelItems,
|
||||
'当前没有 channel 流量。',
|
||||
)
|
||||
}
|
||||
|
||||
function escapeHTML(text) {
|
||||
return String(text)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
}
|
||||
|
||||
function setLiveStatus(status, summary) {
|
||||
elements.liveStatus.textContent = status
|
||||
elements.liveStatus.className = status === 'Online' ? 'badge is-ok' : 'badge'
|
||||
elements.liveSummary.textContent = summary
|
||||
}
|
||||
|
||||
function updateLiveStats() {
|
||||
elements.liveLocationCount.textContent = String(liveStats.location)
|
||||
elements.liveHeartRateCount.textContent = String(liveStats.heartRate)
|
||||
elements.liveLastDevice.textContent = liveStats.lastDevice
|
||||
elements.liveLastTopic.textContent = liveStats.lastTopic
|
||||
}
|
||||
|
||||
function formatNumber(value, digits) {
|
||||
const num = Number(value)
|
||||
if (!Number.isFinite(num)) {
|
||||
return '--'
|
||||
}
|
||||
return num.toFixed(digits)
|
||||
}
|
||||
|
||||
function formatLiveSummary(item) {
|
||||
if (item.topic === 'telemetry.location') {
|
||||
const payload = item.payload || {}
|
||||
return `定位 ${formatNumber(payload.lat, 6)}, ${formatNumber(payload.lng, 6)} | 速度 ${formatNumber(payload.speed, 1)} m/s | 航向 ${formatNumber(payload.bearing, 0)}° | 精度 ${formatNumber(payload.accuracy, 1)} m`
|
||||
}
|
||||
if (item.topic === 'telemetry.heart_rate') {
|
||||
const payload = item.payload || {}
|
||||
return `心率 ${formatNumber(payload.bpm, 0)} bpm`
|
||||
}
|
||||
return '原始数据'
|
||||
}
|
||||
|
||||
function trackKey(item) {
|
||||
return `${item.channelId || '--'} / ${item.deviceId || '--'}`
|
||||
}
|
||||
|
||||
function ensureTrackSeries(item) {
|
||||
const key = trackKey(item)
|
||||
if (!liveTrackSeries.has(key)) {
|
||||
liveTrackSeries.set(key, {
|
||||
key,
|
||||
color: liveTrackPalette[liveTrackSeries.size % liveTrackPalette.length],
|
||||
points: [],
|
||||
lastTopic: item.topic || '--',
|
||||
})
|
||||
}
|
||||
return liveTrackSeries.get(key)
|
||||
}
|
||||
|
||||
function updateTrack(item) {
|
||||
if (item.topic !== 'telemetry.location') {
|
||||
return
|
||||
}
|
||||
|
||||
const payload = item.payload || {}
|
||||
const lat = Number(payload.lat)
|
||||
const lng = Number(payload.lng)
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
|
||||
return
|
||||
}
|
||||
|
||||
const series = ensureTrackSeries(item)
|
||||
series.lastTopic = item.topic || '--'
|
||||
series.points.push({ lat, lng, timestamp: item.timestamp })
|
||||
if (series.points.length > maxTrackPoints) {
|
||||
series.points.shift()
|
||||
}
|
||||
renderLiveTrack()
|
||||
}
|
||||
|
||||
function renderLiveTrack() {
|
||||
const activeSeries = Array.from(liveTrackSeries.values()).filter((entry) => entry.points.length > 0)
|
||||
if (!activeSeries.length) {
|
||||
elements.liveTrack.innerHTML = '<div class="live-track__empty">等待 GPS 数据...</div>'
|
||||
elements.liveTrackLegend.innerHTML = '<div class="empty">暂无轨迹。</div>'
|
||||
return
|
||||
}
|
||||
|
||||
let minLat = Infinity
|
||||
let maxLat = -Infinity
|
||||
let minLng = Infinity
|
||||
let maxLng = -Infinity
|
||||
|
||||
activeSeries.forEach((series) => {
|
||||
series.points.forEach((point) => {
|
||||
minLat = Math.min(minLat, point.lat)
|
||||
maxLat = Math.max(maxLat, point.lat)
|
||||
minLng = Math.min(minLng, point.lng)
|
||||
maxLng = Math.max(maxLng, point.lng)
|
||||
})
|
||||
})
|
||||
|
||||
const width = 340
|
||||
const height = 320
|
||||
const padding = 18
|
||||
const lngSpan = Math.max(maxLng - minLng, 0.0001)
|
||||
const latSpan = Math.max(maxLat - minLat, 0.0001)
|
||||
|
||||
const polylines = activeSeries.map((series) => {
|
||||
const points = series.points.map((point) => {
|
||||
const x = padding + ((point.lng - minLng) / lngSpan) * (width - padding * 2)
|
||||
const y = height - padding - ((point.lat - minLat) / latSpan) * (height - padding * 2)
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`
|
||||
}).join(' ')
|
||||
|
||||
const last = series.points[series.points.length - 1]
|
||||
const lastX = padding + ((last.lng - minLng) / lngSpan) * (width - padding * 2)
|
||||
const lastY = height - padding - ((last.lat - minLat) / latSpan) * (height - padding * 2)
|
||||
|
||||
return `
|
||||
<polyline fill="none" stroke="${series.color}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" points="${points}" />
|
||||
<circle cx="${lastX.toFixed(1)}" cy="${lastY.toFixed(1)}" r="4.5" fill="${series.color}" stroke="rgba(255,255,255,0.95)" stroke-width="2" />
|
||||
`
|
||||
}).join('')
|
||||
|
||||
const grid = [25, 50, 75].map((ratio) => {
|
||||
const x = (width * ratio) / 100
|
||||
const y = (height * ratio) / 100
|
||||
return `
|
||||
<line x1="${x}" y1="0" x2="${x}" y2="${height}" stroke="rgba(21,38,31,0.08)" stroke-width="1" />
|
||||
<line x1="0" y1="${y}" x2="${width}" y2="${y}" stroke="rgba(21,38,31,0.08)" stroke-width="1" />
|
||||
`
|
||||
}).join('')
|
||||
|
||||
elements.liveTrack.innerHTML = `
|
||||
<svg viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" aria-label="live track preview">
|
||||
<rect x="0" y="0" width="${width}" height="${height}" fill="transparent" />
|
||||
${grid}
|
||||
${polylines}
|
||||
</svg>
|
||||
`
|
||||
|
||||
elements.liveTrackLegend.innerHTML = activeSeries.map((series) => {
|
||||
const last = series.points[series.points.length - 1]
|
||||
return `
|
||||
<div class="live-track-legend__item">
|
||||
<span class="live-track-legend__swatch" style="background:${series.color}"></span>
|
||||
<span>${escapeHTML(series.key)} | ${formatNumber(last.lat, 6)}, ${formatNumber(last.lng, 6)} | ${series.points.length} 点</span>
|
||||
</div>
|
||||
`
|
||||
}).join('')
|
||||
}
|
||||
|
||||
function renderLiveEntry(item) {
|
||||
const line = document.createElement('div')
|
||||
line.className = 'live-line'
|
||||
|
||||
const meta = document.createElement('div')
|
||||
meta.className = 'live-line__meta'
|
||||
meta.innerHTML = [
|
||||
`<span>${escapeHTML(formatTime(item.timestamp))}</span>`,
|
||||
`<span>${escapeHTML(item.topic || '--')}</span>`,
|
||||
`<span>ch=${escapeHTML(item.channelId || '--')}</span>`,
|
||||
`<span>device=${escapeHTML(item.deviceId || '--')}</span>`,
|
||||
`<span>source=${escapeHTML(item.sourceId || '--')}${item.mode ? ` / ${escapeHTML(item.mode)}` : ''}</span>`,
|
||||
].join('')
|
||||
|
||||
const summary = document.createElement('div')
|
||||
summary.className = 'live-line__summary'
|
||||
summary.textContent = formatLiveSummary(item)
|
||||
|
||||
const payload = document.createElement('div')
|
||||
payload.className = 'live-line__payload'
|
||||
payload.textContent = JSON.stringify(item.payload || {}, null, 2)
|
||||
|
||||
line.appendChild(meta)
|
||||
line.appendChild(summary)
|
||||
line.appendChild(payload)
|
||||
|
||||
if (elements.liveFeed.firstChild && elements.liveFeed.firstChild.classList && elements.liveFeed.firstChild.classList.contains('live-feed__empty')) {
|
||||
elements.liveFeed.innerHTML = ''
|
||||
}
|
||||
|
||||
elements.liveFeed.prepend(line)
|
||||
liveCount += 1
|
||||
liveStats.lastDevice = item.deviceId || '--'
|
||||
liveStats.lastTopic = item.topic || '--'
|
||||
if (item.topic === 'telemetry.location') {
|
||||
liveStats.location += 1
|
||||
} else if (item.topic === 'telemetry.heart_rate') {
|
||||
liveStats.heartRate += 1
|
||||
}
|
||||
updateLiveStats()
|
||||
updateTrack(item)
|
||||
while (elements.liveFeed.childElementCount > maxLiveLines) {
|
||||
elements.liveFeed.removeChild(elements.liveFeed.lastElementChild)
|
||||
}
|
||||
setLiveStatus('Online', `实时流已连接,已接收 ${liveCount} 条数据。`)
|
||||
}
|
||||
|
||||
function clearLiveFeed() {
|
||||
liveCount = 0
|
||||
liveStats.location = 0
|
||||
liveStats.heartRate = 0
|
||||
liveStats.lastDevice = '--'
|
||||
liveStats.lastTopic = '--'
|
||||
liveTrackSeries.clear()
|
||||
elements.liveFeed.innerHTML = '<div class="live-feed__empty">等待实时数据...</div>'
|
||||
elements.liveTrack.innerHTML = '<div class="live-track__empty">等待 GPS 数据...</div>'
|
||||
elements.liveTrackLegend.innerHTML = '<div class="empty">暂无轨迹。</div>'
|
||||
updateLiveStats()
|
||||
setLiveStatus(liveSource ? 'Online' : 'Connecting', liveSource ? '实时流已连接,等待数据...' : '正在连接实时流...')
|
||||
}
|
||||
|
||||
function closeLiveStream() {
|
||||
if (!liveSource) {
|
||||
return
|
||||
}
|
||||
liveSource.close()
|
||||
liveSource = null
|
||||
}
|
||||
|
||||
function connectLiveStream() {
|
||||
closeLiveStream()
|
||||
|
||||
const params = new URLSearchParams()
|
||||
if (elements.liveTopicFilter.value) {
|
||||
params.set('topic', elements.liveTopicFilter.value)
|
||||
}
|
||||
if (elements.liveChannelFilter.value.trim()) {
|
||||
params.set('channelId', elements.liveChannelFilter.value.trim())
|
||||
}
|
||||
if (elements.liveDeviceFilter.value.trim()) {
|
||||
params.set('deviceId', elements.liveDeviceFilter.value.trim())
|
||||
}
|
||||
|
||||
clearLiveFeed()
|
||||
setLiveStatus('Connecting', '正在连接实时流...')
|
||||
|
||||
const url = `/api/admin/live${params.toString() ? `?${params.toString()}` : ''}`
|
||||
liveSource = new EventSource(url)
|
||||
|
||||
liveSource.addEventListener('open', () => {
|
||||
setLiveStatus('Online', liveCount > 0 ? `实时流已连接,已接收 ${liveCount} 条数据。` : '实时流已连接,等待数据...')
|
||||
})
|
||||
|
||||
liveSource.addEventListener('envelope', (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data)
|
||||
renderLiveEntry(payload)
|
||||
} catch (_error) {
|
||||
setLiveStatus('Error', '实时流收到不可解析数据。')
|
||||
}
|
||||
})
|
||||
|
||||
liveSource.addEventListener('error', () => {
|
||||
setLiveStatus('Error', '实时流已断开,可手动重连。')
|
||||
})
|
||||
}
|
||||
|
||||
async function refreshDashboard() {
|
||||
try {
|
||||
const topic = elements.topicFilter.value
|
||||
const [overview, sessions, latest, channels, traffic] = await Promise.all([
|
||||
loadJSON('/api/admin/overview'),
|
||||
loadJSON('/api/admin/sessions'),
|
||||
loadJSON(`/api/admin/latest${topic ? `?topic=${encodeURIComponent(topic)}` : ''}`),
|
||||
loadJSON('/api/admin/channels'),
|
||||
loadJSON('/api/admin/traffic'),
|
||||
])
|
||||
|
||||
setBadge(overview.status)
|
||||
elements.listenText.textContent = overview.httpListen || '--'
|
||||
elements.uptimeText.textContent = formatDuration(overview.uptimeSeconds || 0)
|
||||
elements.anonymousText.textContent = overview.anonymousConsumers ? 'enabled' : 'disabled'
|
||||
elements.sessionsCount.textContent = String(overview.metrics.sessions || 0)
|
||||
elements.subscribersCount.textContent = String(overview.metrics.subscribers || 0)
|
||||
elements.latestCount.textContent = String(overview.metrics.latestState || 0)
|
||||
elements.channelsCount.textContent = String(overview.metrics.channels || 0)
|
||||
elements.publishedCount.textContent = String(overview.metrics.published || 0)
|
||||
elements.droppedCount.textContent = String(overview.metrics.dropped || 0)
|
||||
elements.fanoutCount.textContent = String(overview.metrics.fanout || 0)
|
||||
elements.pluginsCount.textContent = String(overview.metrics.pluginHandlers || 0)
|
||||
elements.heroText.textContent = `运行中,启动于 ${formatTime(overview.startedAt)},当前时间 ${formatTime(overview.now)}。`
|
||||
|
||||
renderSessions(sessions)
|
||||
renderLatest(latest)
|
||||
renderChannels(channels)
|
||||
renderTraffic(traffic)
|
||||
} catch (error) {
|
||||
setBadge('error')
|
||||
elements.heroText.textContent = error && error.message ? error.message : '加载失败'
|
||||
elements.channelsTable.innerHTML = '<div class="empty">无法加载 channel 信息。</div>'
|
||||
elements.sessionsTable.innerHTML = '<div class="empty">无法加载会话信息。</div>'
|
||||
elements.latestTable.innerHTML = '<div class="empty">无法加载 latest state。</div>'
|
||||
elements.topicTrafficTable.innerHTML = '<div class="empty">无法加载 topic 流量。</div>'
|
||||
elements.channelTrafficTable.innerHTML = '<div class="empty">无法加载 channel 流量。</div>'
|
||||
}
|
||||
}
|
||||
|
||||
async function createChannel() {
|
||||
const payload = {
|
||||
label: elements.channelLabelInput.value.trim(),
|
||||
deliveryMode: elements.channelModeSelect.value,
|
||||
ttlSeconds: Number(elements.channelTTLInput.value) || 28800,
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/channel/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
const data = await response.json()
|
||||
if (!response.ok) {
|
||||
throw new Error(data && data.error ? data.error : `HTTP ${response.status}`)
|
||||
}
|
||||
elements.createChannelResult.textContent = [
|
||||
`channelId: ${data.snapshot.id}`,
|
||||
`label: ${data.snapshot.label || '--'}`,
|
||||
`deliveryMode: ${data.snapshot.deliveryMode || '--'}`,
|
||||
`producerToken: ${data.producerToken}`,
|
||||
`consumerToken: ${data.consumerToken}`,
|
||||
`controllerToken: ${data.controllerToken}`,
|
||||
].join('\n')
|
||||
await refreshDashboard()
|
||||
} catch (error) {
|
||||
elements.createChannelResult.textContent = error && error.message ? error.message : '创建失败'
|
||||
}
|
||||
}
|
||||
|
||||
function resetAutoRefresh() {
|
||||
if (timer) {
|
||||
window.clearInterval(timer)
|
||||
timer = 0
|
||||
}
|
||||
if (elements.autoRefreshInput.checked) {
|
||||
timer = window.setInterval(refreshDashboard, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
elements.refreshBtn.addEventListener('click', refreshDashboard)
|
||||
elements.createChannelBtn.addEventListener('click', createChannel)
|
||||
elements.topicFilter.addEventListener('change', refreshDashboard)
|
||||
elements.liveReconnectBtn.addEventListener('click', connectLiveStream)
|
||||
elements.liveClearBtn.addEventListener('click', clearLiveFeed)
|
||||
elements.liveTopicFilter.addEventListener('change', connectLiveStream)
|
||||
elements.liveChannelFilter.addEventListener('change', connectLiveStream)
|
||||
elements.liveDeviceFilter.addEventListener('change', connectLiveStream)
|
||||
elements.autoRefreshInput.addEventListener('change', resetAutoRefresh)
|
||||
|
||||
clearLiveFeed()
|
||||
connectLiveStream()
|
||||
refreshDashboard()
|
||||
resetAutoRefresh()
|
||||
})()
|
||||
214
realtime-gateway/internal/gateway/adminui/index.html
Normal file
214
realtime-gateway/internal/gateway/adminui/index.html
Normal file
@@ -0,0 +1,214 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Realtime Gateway Console</title>
|
||||
<link rel="stylesheet" href="/assets/style.css?v=20260327e">
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<div class="brand__eyebrow">REALTIME GATEWAY</div>
|
||||
<h1>控制台</h1>
|
||||
<div id="serviceBadge" class="badge">Loading</div>
|
||||
</div>
|
||||
<div class="sidebar__section">
|
||||
<div class="sidebar__label">运行信息</div>
|
||||
<div class="meta-list">
|
||||
<div class="meta-row"><span>监听</span><strong id="listenText">--</strong></div>
|
||||
<div class="meta-row"><span>运行时长</span><strong id="uptimeText">--</strong></div>
|
||||
<div class="meta-row"><span>匿名订阅</span><strong id="anonymousText">--</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar__section">
|
||||
<div class="sidebar__label">接口</div>
|
||||
<div class="endpoint-list">
|
||||
<code>/ws</code>
|
||||
<code>/healthz</code>
|
||||
<code>/metrics</code>
|
||||
<code>/api/admin/overview</code>
|
||||
<code>/api/admin/sessions</code>
|
||||
<code>/api/admin/latest</code>
|
||||
<code>/api/admin/traffic</code>
|
||||
<code>/api/admin/live</code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar__section">
|
||||
<button id="refreshBtn" class="action-btn">立即刷新</button>
|
||||
<label class="toggle">
|
||||
<input id="autoRefreshInput" type="checkbox" checked>
|
||||
<span>自动刷新 (3s)</span>
|
||||
</label>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
<section class="hero">
|
||||
<div class="hero__title">
|
||||
<span class="hero__tag">Router Style</span>
|
||||
<h2>实时设备网关管理台</h2>
|
||||
</div>
|
||||
<p id="heroText" class="hero__text">正在加载运行状态...</p>
|
||||
</section>
|
||||
|
||||
<section class="grid stats-grid">
|
||||
<article class="card metric-card">
|
||||
<div class="card__label">Sessions</div>
|
||||
<div id="sessionsCount" class="metric-card__value">0</div>
|
||||
</article>
|
||||
<article class="card metric-card">
|
||||
<div class="card__label">Subscribers</div>
|
||||
<div id="subscribersCount" class="metric-card__value">0</div>
|
||||
</article>
|
||||
<article class="card metric-card">
|
||||
<div class="card__label">Latest State</div>
|
||||
<div id="latestCount" class="metric-card__value">0</div>
|
||||
</article>
|
||||
<article class="card metric-card">
|
||||
<div class="card__label">Channels</div>
|
||||
<div id="channelsCount" class="metric-card__value">0</div>
|
||||
</article>
|
||||
<article class="card metric-card">
|
||||
<div class="card__label">Published</div>
|
||||
<div id="publishedCount" class="metric-card__value">0</div>
|
||||
</article>
|
||||
<article class="card metric-card">
|
||||
<div class="card__label">Dropped</div>
|
||||
<div id="droppedCount" class="metric-card__value">0</div>
|
||||
</article>
|
||||
<article class="card metric-card">
|
||||
<div class="card__label">Fanout</div>
|
||||
<div id="fanoutCount" class="metric-card__value">0</div>
|
||||
</article>
|
||||
<article class="card metric-card">
|
||||
<div class="card__label">Plugins</div>
|
||||
<div id="pluginsCount" class="metric-card__value">0</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="grid detail-grid">
|
||||
<article class="card">
|
||||
<div class="card__header">
|
||||
<div>
|
||||
<div class="card__title">Channel 管理</div>
|
||||
<div class="card__hint">创建临时通道并查看当前在线角色</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="channel-form">
|
||||
<input id="channelLabelInput" class="filter-select" type="text" placeholder="可选标签,例如: debug-a">
|
||||
<select id="channelModeSelect" class="filter-select">
|
||||
<option value="cache_latest">cache_latest</option>
|
||||
<option value="drop_if_no_consumer">drop_if_no_consumer</option>
|
||||
</select>
|
||||
<input id="channelTTLInput" class="filter-select" type="number" min="60" value="28800" placeholder="TTL 秒">
|
||||
<button id="createChannelBtn" class="action-btn">创建 Channel</button>
|
||||
</div>
|
||||
<div class="card__hint">创建成功后,这里会显示 `channelId` 与三种 token。</div>
|
||||
<pre id="createChannelResult" class="result-box">创建结果会显示在这里。</pre>
|
||||
<div id="channelsTable" class="table-wrap"></div>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<div class="card__header">
|
||||
<div>
|
||||
<div class="card__title">会话列表</div>
|
||||
<div id="sessionsHint" class="card__hint">当前连接与订阅</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="sessionsTable" class="table-wrap"></div>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<div class="card__header">
|
||||
<div>
|
||||
<div class="card__title">Latest State</div>
|
||||
<div class="card__hint">每个设备最近一条消息</div>
|
||||
</div>
|
||||
<select id="topicFilter" class="filter-select">
|
||||
<option value="">全部 Topic</option>
|
||||
<option value="telemetry.location">telemetry.location</option>
|
||||
<option value="telemetry.heart_rate">telemetry.heart_rate</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="latestTable" class="table-wrap"></div>
|
||||
</article>
|
||||
|
||||
<article class="card traffic-card">
|
||||
<div class="card__header">
|
||||
<div>
|
||||
<div class="card__title">流量统计</div>
|
||||
<div class="card__hint">按 topic 和 channel 看累计发布、丢弃和扇出</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="traffic-grid">
|
||||
<section>
|
||||
<div class="card__hint">Topic 统计</div>
|
||||
<div id="topicTrafficTable" class="table-wrap"></div>
|
||||
</section>
|
||||
<section>
|
||||
<div class="card__hint">Channel 统计</div>
|
||||
<div id="channelTrafficTable" class="table-wrap"></div>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="card live-card">
|
||||
<div class="card__header">
|
||||
<div>
|
||||
<div class="card__title">实时数据窗口</div>
|
||||
<div class="card__hint">直接查看网关收到的实时 GPS / 心率数据</div>
|
||||
</div>
|
||||
<div class="live-controls">
|
||||
<select id="liveTopicFilter" class="filter-select">
|
||||
<option value="">全部 Topic</option>
|
||||
<option value="telemetry.location">telemetry.location</option>
|
||||
<option value="telemetry.heart_rate">telemetry.heart_rate</option>
|
||||
</select>
|
||||
<input id="liveChannelFilter" class="filter-select" type="text" placeholder="channelId 可选">
|
||||
<input id="liveDeviceFilter" class="filter-select" type="text" placeholder="deviceId 可选">
|
||||
<button id="liveReconnectBtn" class="action-btn action-btn--inline">重连实时流</button>
|
||||
<button id="liveClearBtn" class="action-btn action-btn--inline action-btn--muted">清空窗口</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="live-meta">
|
||||
<div id="liveStatus" class="badge">Connecting</div>
|
||||
<div id="liveSummary" class="card__hint">等待实时流...</div>
|
||||
</div>
|
||||
<div class="live-stats">
|
||||
<div class="live-stat">
|
||||
<span class="live-stat__label">定位消息</span>
|
||||
<strong id="liveLocationCount">0</strong>
|
||||
</div>
|
||||
<div class="live-stat">
|
||||
<span class="live-stat__label">心率消息</span>
|
||||
<strong id="liveHeartRateCount">0</strong>
|
||||
</div>
|
||||
<div class="live-stat">
|
||||
<span class="live-stat__label">最后设备</span>
|
||||
<strong id="liveLastDevice">--</strong>
|
||||
</div>
|
||||
<div class="live-stat">
|
||||
<span class="live-stat__label">最后主题</span>
|
||||
<strong id="liveLastTopic">--</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="live-panel-grid">
|
||||
<section class="live-track-panel">
|
||||
<div class="card__hint">轨迹预览,建议配合 `channelId / deviceId` 过滤使用</div>
|
||||
<div id="liveTrack" class="live-track"></div>
|
||||
<div id="liveTrackLegend" class="live-track-legend"></div>
|
||||
</section>
|
||||
<section>
|
||||
<div id="liveFeed" class="live-feed"></div>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/assets/app.js?v=20260327e"></script>
|
||||
</body>
|
||||
</html>
|
||||
508
realtime-gateway/internal/gateway/adminui/style.css
Normal file
508
realtime-gateway/internal/gateway/adminui/style.css
Normal file
@@ -0,0 +1,508 @@
|
||||
:root {
|
||||
--bg: #d9dfd3;
|
||||
--panel: rgba(247, 249, 243, 0.94);
|
||||
--card: rgba(255, 255, 255, 0.94);
|
||||
--line: rgba(28, 43, 34, 0.12);
|
||||
--text: #15261f;
|
||||
--muted: #5e6f66;
|
||||
--accent: #0f7a68;
|
||||
--accent-2: #d57a1f;
|
||||
--ok: #13754c;
|
||||
--warn: #9a5a11;
|
||||
--shadow: 0 18px 40px rgba(28, 43, 34, 0.12);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(213, 122, 31, 0.22), transparent 28%),
|
||||
radial-gradient(circle at bottom right, rgba(15, 122, 104, 0.22), transparent 24%),
|
||||
var(--bg);
|
||||
color: var(--text);
|
||||
font-family: "Bahnschrift", "Segoe UI Variable Text", "PingFang SC", sans-serif;
|
||||
}
|
||||
|
||||
.shell {
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
padding: 24px;
|
||||
background: rgba(22, 35, 29, 0.92);
|
||||
color: #eef4ed;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.brand__eyebrow {
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(238, 244, 237, 0.68);
|
||||
}
|
||||
|
||||
.brand h1 {
|
||||
margin: 10px 0 12px;
|
||||
font-size: 34px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
min-height: 30px;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(238, 244, 237, 0.82);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.badge.is-ok {
|
||||
background: rgba(19, 117, 76, 0.24);
|
||||
color: #9ef4c7;
|
||||
}
|
||||
|
||||
.sidebar__section {
|
||||
margin-top: 26px;
|
||||
}
|
||||
|
||||
.sidebar__label {
|
||||
margin-bottom: 12px;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(238, 244, 237, 0.58);
|
||||
}
|
||||
|
||||
.meta-list,
|
||||
.endpoint-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.meta-row span {
|
||||
color: rgba(238, 244, 237, 0.66);
|
||||
}
|
||||
|
||||
.endpoint-list code {
|
||||
display: block;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #dbe8e1;
|
||||
font-family: "Cascadia Code", "Consolas", monospace;
|
||||
}
|
||||
|
||||
.action-btn,
|
||||
.filter-select {
|
||||
min-height: 42px;
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 100%;
|
||||
font-weight: 800;
|
||||
color: #113128;
|
||||
background: linear-gradient(135deg, #f0d96b, #d57a1f);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action-btn--inline {
|
||||
width: auto;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.action-btn--muted {
|
||||
color: #eef4ed;
|
||||
background: linear-gradient(135deg, #466055, #2f473d);
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
color: rgba(238, 244, 237, 0.82);
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 24px 26px;
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.9), rgba(233, 239, 232, 0.9));
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.hero__tag {
|
||||
display: inline-block;
|
||||
margin-bottom: 10px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 122, 104, 0.12);
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.hero h2 {
|
||||
margin: 0;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.hero__text {
|
||||
margin: 10px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
grid-template-columns: 1.15fr 1fr;
|
||||
}
|
||||
|
||||
.traffic-card {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.live-card {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.channel-form {
|
||||
display: grid;
|
||||
grid-template-columns: 1.3fr 1fr 0.8fr 0.9fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 20px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 22px;
|
||||
background: var(--card);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.metric-card__value {
|
||||
margin-top: 10px;
|
||||
font-size: 42px;
|
||||
line-height: 1;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.card__label {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card__title {
|
||||
font-size: 20px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.card__hint {
|
||||
margin-top: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
min-width: 220px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--line);
|
||||
background: #f6f8f2;
|
||||
}
|
||||
|
||||
.live-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.live-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.live-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.live-stat {
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
background: rgba(244, 247, 240, 0.88);
|
||||
}
|
||||
|
||||
.live-stat__label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.live-stat strong {
|
||||
display: block;
|
||||
font-size: 22px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.live-panel-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 360px 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.traffic-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.live-track-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.live-track {
|
||||
min-height: 320px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(15, 122, 104, 0.15), transparent 30%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(237, 242, 234, 0.96));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.live-track svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 320px;
|
||||
}
|
||||
|
||||
.live-track__empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 320px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.live-track-legend {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.live-track-legend__item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
border-radius: 12px;
|
||||
background: rgba(244, 247, 240, 0.88);
|
||||
border: 1px solid var(--line);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.live-track-legend__swatch {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.live-feed {
|
||||
min-height: 320px;
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(19, 29, 24, 0.98), rgba(14, 24, 20, 0.98));
|
||||
color: #dceee7;
|
||||
font-family: "Cascadia Code", "Consolas", monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.live-feed__empty {
|
||||
color: rgba(220, 238, 231, 0.68);
|
||||
}
|
||||
|
||||
.live-line {
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.live-line + .live-line {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.live-line__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
color: #8dd9c7;
|
||||
}
|
||||
|
||||
.live-line__summary {
|
||||
margin-bottom: 8px;
|
||||
color: #f1d88e;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.live-line__payload {
|
||||
color: rgba(241, 246, 244, 0.8);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow: auto;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(244, 247, 240, 0.8);
|
||||
}
|
||||
|
||||
.result-box {
|
||||
min-height: 88px;
|
||||
margin: 10px 0 16px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--line);
|
||||
background: #f4f7ef;
|
||||
color: var(--text);
|
||||
font-family: "Cascadia Code", "Consolas", monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #edf2ea;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
td code,
|
||||
.json-chip {
|
||||
font-family: "Cascadia Code", "Consolas", monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.json-chip {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
padding: 8px 10px;
|
||||
border-radius: 12px;
|
||||
background: #f2f5ef;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 18px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stats-grid,
|
||||
.detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.channel-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.live-stats,
|
||||
.traffic-grid,
|
||||
.live-panel-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.live-controls {
|
||||
justify-content: stretch;
|
||||
}
|
||||
}
|
||||
24
realtime-gateway/internal/gateway/auth.go
Normal file
24
realtime-gateway/internal/gateway/auth.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"realtime-gateway/internal/config"
|
||||
"realtime-gateway/internal/model"
|
||||
)
|
||||
|
||||
func authorize(cfg config.AuthConfig, role model.Role, token string) bool {
|
||||
switch role {
|
||||
case model.RoleProducer:
|
||||
return slices.Contains(cfg.ProducerTokens, token)
|
||||
case model.RoleController:
|
||||
return slices.Contains(cfg.ControllerTokens, token)
|
||||
case model.RoleConsumer:
|
||||
if cfg.AllowAnonymousConsumers && token == "" {
|
||||
return true
|
||||
}
|
||||
return slices.Contains(cfg.ConsumerTokens, token)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
58
realtime-gateway/internal/gateway/channel_api.go
Normal file
58
realtime-gateway/internal/gateway/channel_api.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"realtime-gateway/internal/channel"
|
||||
)
|
||||
|
||||
type createChannelRequest struct {
|
||||
Label string `json:"label"`
|
||||
DeliveryMode string `json:"deliveryMode"`
|
||||
TTLSeconds int `json:"ttlSeconds"`
|
||||
}
|
||||
|
||||
func (s *Server) registerChannelRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/channel/create", s.handleCreateChannel)
|
||||
mux.HandleFunc("/api/admin/channels", s.handleAdminChannels)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateChannel(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]any{
|
||||
"error": "method not allowed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var request createChannelRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{
|
||||
"error": "invalid json body",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
created, err := s.channels.Create(channel.CreateRequest{
|
||||
Label: request.Label,
|
||||
DeliveryMode: request.DeliveryMode,
|
||||
TTLSeconds: request.TTLSeconds,
|
||||
})
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, created)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminChannels(w http.ResponseWriter, _ *http.Request) {
|
||||
items := s.channels.List()
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"items": items,
|
||||
"count": len(items),
|
||||
})
|
||||
}
|
||||
277
realtime-gateway/internal/gateway/client.go
Normal file
277
realtime-gateway/internal/gateway/client.go
Normal file
@@ -0,0 +1,277 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/coder/websocket/wsjson"
|
||||
|
||||
"realtime-gateway/internal/channel"
|
||||
"realtime-gateway/internal/config"
|
||||
"realtime-gateway/internal/model"
|
||||
"realtime-gateway/internal/plugin"
|
||||
"realtime-gateway/internal/router"
|
||||
"realtime-gateway/internal/session"
|
||||
)
|
||||
|
||||
type client struct {
|
||||
conn *websocket.Conn
|
||||
logger *slog.Logger
|
||||
cfg config.GatewayConfig
|
||||
hub *router.Hub
|
||||
channels *channel.Manager
|
||||
plugins *plugin.Bus
|
||||
session *session.Session
|
||||
auth config.AuthConfig
|
||||
|
||||
writeMu sync.Mutex
|
||||
}
|
||||
|
||||
func serveClient(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
logger *slog.Logger,
|
||||
cfg config.Config,
|
||||
hub *router.Hub,
|
||||
channels *channel.Manager,
|
||||
plugins *plugin.Bus,
|
||||
sessions *session.Manager,
|
||||
) {
|
||||
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
InsecureSkipVerify: true,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("websocket accept failed", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
sess := sessions.Create()
|
||||
c := &client{
|
||||
conn: conn,
|
||||
logger: logger.With("sessionId", sess.ID),
|
||||
cfg: cfg.Gateway,
|
||||
hub: hub,
|
||||
channels: channels,
|
||||
plugins: plugins,
|
||||
session: sess,
|
||||
auth: cfg.Auth,
|
||||
}
|
||||
|
||||
hub.Register(c, nil)
|
||||
defer func() {
|
||||
if sess.ChannelID != "" {
|
||||
channels.Unbind(sess.ChannelID, sess.Role)
|
||||
}
|
||||
hub.Unregister(sess.ID)
|
||||
sessions.Delete(sess.ID)
|
||||
_ = conn.Close(websocket.StatusNormalClosure, "session closed")
|
||||
}()
|
||||
|
||||
if err := c.run(r.Context()); err != nil && !errors.Is(err, context.Canceled) {
|
||||
c.logger.Warn("client closed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *client) ID() string {
|
||||
return c.session.ID
|
||||
}
|
||||
|
||||
func (c *client) Send(message model.ServerMessage) error {
|
||||
c.writeMu.Lock()
|
||||
defer c.writeMu.Unlock()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), c.cfg.WriteWait())
|
||||
defer cancel()
|
||||
return wsjson.Write(ctx, c.conn, message)
|
||||
}
|
||||
|
||||
func (c *client) run(ctx context.Context) error {
|
||||
if err := c.Send(model.ServerMessage{
|
||||
Type: "welcome",
|
||||
SessionID: c.session.ID,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pingCtx, cancelPing := context.WithCancel(ctx)
|
||||
defer cancelPing()
|
||||
go c.pingLoop(pingCtx)
|
||||
|
||||
for {
|
||||
readCtx, cancel := context.WithTimeout(ctx, c.cfg.PongWait())
|
||||
var message model.ClientMessage
|
||||
err := wsjson.Read(readCtx, c.conn, &message)
|
||||
cancel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.handleMessage(message); err != nil {
|
||||
_ = c.Send(model.ServerMessage{
|
||||
Type: "error",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *client) handleMessage(message model.ClientMessage) error {
|
||||
switch message.Type {
|
||||
case "authenticate":
|
||||
return c.handleAuthenticate(message)
|
||||
case "join_channel":
|
||||
return c.handleJoinChannel(message)
|
||||
case "subscribe":
|
||||
return c.handleSubscribe(message)
|
||||
case "publish":
|
||||
return c.handlePublish(message)
|
||||
case "snapshot":
|
||||
return c.handleSnapshot(message)
|
||||
default:
|
||||
return errors.New("unsupported message type")
|
||||
}
|
||||
}
|
||||
|
||||
func (c *client) handleJoinChannel(message model.ClientMessage) error {
|
||||
if strings.TrimSpace(message.ChannelID) == "" {
|
||||
return errors.New("channelId is required")
|
||||
}
|
||||
|
||||
snapshot, err := c.channels.Join(message.ChannelID, message.Token, message.Role)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.session.ChannelID != "" {
|
||||
c.channels.Unbind(c.session.ChannelID, c.session.Role)
|
||||
}
|
||||
if err := c.channels.Bind(snapshot.ID, message.Role); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.session.Role = message.Role
|
||||
c.session.Authenticated = true
|
||||
c.session.ChannelID = snapshot.ID
|
||||
c.session.Subscriptions = nil
|
||||
c.hub.UpdateSubscriptions(c.session.ID, nil)
|
||||
|
||||
return c.Send(model.ServerMessage{
|
||||
Type: "joined_channel",
|
||||
SessionID: c.session.ID,
|
||||
State: json.RawMessage([]byte(
|
||||
`{"channelId":"` + snapshot.ID + `","deliveryMode":"` + snapshot.DeliveryMode + `"}`,
|
||||
)),
|
||||
})
|
||||
}
|
||||
|
||||
func (c *client) handleAuthenticate(message model.ClientMessage) error {
|
||||
if !authorize(c.auth, message.Role, message.Token) {
|
||||
return errors.New("authentication failed")
|
||||
}
|
||||
|
||||
c.session.Role = message.Role
|
||||
c.session.Authenticated = true
|
||||
return c.Send(model.ServerMessage{
|
||||
Type: "authenticated",
|
||||
SessionID: c.session.ID,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *client) handleSubscribe(message model.ClientMessage) error {
|
||||
if !c.session.Authenticated && !c.auth.AllowAnonymousConsumers {
|
||||
return errors.New("consumer must authenticate before subscribe")
|
||||
}
|
||||
|
||||
subscriptions := normalizeSubscriptions(c.session.ChannelID, message.Subscriptions)
|
||||
c.session.Subscriptions = subscriptions
|
||||
c.hub.UpdateSubscriptions(c.session.ID, subscriptions)
|
||||
return c.Send(model.ServerMessage{
|
||||
Type: "subscribed",
|
||||
SessionID: c.session.ID,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *client) handlePublish(message model.ClientMessage) error {
|
||||
if !c.session.Authenticated {
|
||||
return errors.New("authentication required")
|
||||
}
|
||||
if c.session.Role != model.RoleProducer && c.session.Role != model.RoleController {
|
||||
return errors.New("publish is only allowed for producer or controller")
|
||||
}
|
||||
if message.Envelope == nil {
|
||||
return errors.New("envelope is required")
|
||||
}
|
||||
|
||||
envelope := *message.Envelope
|
||||
if envelope.Source.Kind == "" {
|
||||
envelope.Source.Kind = c.session.Role
|
||||
}
|
||||
|
||||
if c.session.ChannelID != "" {
|
||||
envelope.Target.ChannelID = c.session.ChannelID
|
||||
}
|
||||
deliveryMode := channel.DeliveryModeCacheLatest
|
||||
if envelope.Target.ChannelID != "" {
|
||||
deliveryMode = c.channels.DeliveryMode(envelope.Target.ChannelID)
|
||||
}
|
||||
result := c.hub.Publish(envelope, deliveryMode)
|
||||
if !result.Dropped {
|
||||
c.plugins.Publish(envelope)
|
||||
}
|
||||
return c.Send(model.ServerMessage{
|
||||
Type: "published",
|
||||
SessionID: c.session.ID,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *client) handleSnapshot(message model.ClientMessage) error {
|
||||
if len(message.Subscriptions) == 0 || message.Subscriptions[0].DeviceID == "" {
|
||||
return errors.New("snapshot requires deviceId in first subscription")
|
||||
}
|
||||
channelID := message.Subscriptions[0].ChannelID
|
||||
if channelID == "" {
|
||||
channelID = c.session.ChannelID
|
||||
}
|
||||
state, ok := c.hub.Snapshot(channelID, message.Subscriptions[0].DeviceID)
|
||||
if !ok {
|
||||
return errors.New("snapshot not found")
|
||||
}
|
||||
return c.Send(model.ServerMessage{
|
||||
Type: "snapshot",
|
||||
SessionID: c.session.ID,
|
||||
State: json.RawMessage(state),
|
||||
})
|
||||
}
|
||||
|
||||
func (c *client) pingLoop(ctx context.Context) {
|
||||
ticker := time.NewTicker(c.cfg.PingInterval())
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
pingCtx, cancel := context.WithTimeout(ctx, c.cfg.WriteWait())
|
||||
_ = c.conn.Ping(pingCtx)
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeSubscriptions(channelID string, subscriptions []model.Subscription) []model.Subscription {
|
||||
items := make([]model.Subscription, 0, len(subscriptions))
|
||||
for _, entry := range subscriptions {
|
||||
if channelID != "" && strings.TrimSpace(entry.ChannelID) == "" {
|
||||
entry.ChannelID = channelID
|
||||
}
|
||||
items = append(items, entry)
|
||||
}
|
||||
return items
|
||||
}
|
||||
109
realtime-gateway/internal/gateway/server.go
Normal file
109
realtime-gateway/internal/gateway/server.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"realtime-gateway/internal/channel"
|
||||
"realtime-gateway/internal/config"
|
||||
"realtime-gateway/internal/plugin"
|
||||
"realtime-gateway/internal/router"
|
||||
"realtime-gateway/internal/session"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
cfg config.Config
|
||||
logger *slog.Logger
|
||||
httpSrv *http.Server
|
||||
channels *channel.Manager
|
||||
hub *router.Hub
|
||||
plugins *plugin.Bus
|
||||
sessions *session.Manager
|
||||
startedAt time.Time
|
||||
}
|
||||
|
||||
func NewServer(cfg config.Config, logger *slog.Logger) (*Server, error) {
|
||||
channels := channel.NewManager(8 * time.Hour)
|
||||
hub := router.NewHub(cfg.Gateway.MaxLatestStateEntries)
|
||||
plugins := plugin.NewBus(logger.With("component", "plugin-bus"))
|
||||
sessions := session.NewManager()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
server := &Server{
|
||||
cfg: cfg,
|
||||
logger: logger,
|
||||
channels: channels,
|
||||
hub: hub,
|
||||
plugins: plugins,
|
||||
sessions: sessions,
|
||||
startedAt: time.Now(),
|
||||
httpSrv: &http.Server{
|
||||
Addr: cfg.Server.HTTPListen,
|
||||
ReadTimeout: cfg.Server.ReadTimeout(),
|
||||
WriteTimeout: cfg.Server.WriteTimeout(),
|
||||
IdleTimeout: cfg.Server.IdleTimeout(),
|
||||
},
|
||||
}
|
||||
|
||||
mux.HandleFunc("/healthz", server.handleHealth)
|
||||
mux.HandleFunc("/metrics", server.handleMetrics)
|
||||
mux.HandleFunc("/ws", server.handleWS)
|
||||
server.registerChannelRoutes(mux)
|
||||
if err := server.registerAdminRoutes(mux); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
server.httpSrv.Handler = mux
|
||||
return server, nil
|
||||
}
|
||||
|
||||
func (s *Server) Run(ctx context.Context) error {
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
s.logger.Info("gateway listening", "addr", s.cfg.Server.HTTPListen)
|
||||
errCh <- s.httpSrv.ListenAndServe()
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), s.cfg.Server.ShutdownTimeout())
|
||||
defer cancel()
|
||||
s.logger.Info("shutting down gateway")
|
||||
return s.httpSrv.Shutdown(shutdownCtx)
|
||||
case err := <-errCh:
|
||||
if err == http.ErrServerClosed {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"status": "ok",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleMetrics(w http.ResponseWriter, _ *http.Request) {
|
||||
subscriberCount, latestStateCount := s.hub.Stats()
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"sessions": s.sessions.Count(),
|
||||
"subscribers": subscriberCount,
|
||||
"latestState": latestStateCount,
|
||||
"pluginHandlers": s.plugins.HandlerCount(),
|
||||
"httpListen": s.cfg.Server.HTTPListen,
|
||||
"anonymousClient": s.cfg.Auth.AllowAnonymousConsumers,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
|
||||
serveClient(w, r, s.logger, s.cfg, s.hub, s.channels, s.plugins, s.sessions)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
13
realtime-gateway/internal/logging/logging.go
Normal file
13
realtime-gateway/internal/logging/logging.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
)
|
||||
|
||||
func New() *slog.Logger {
|
||||
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
})
|
||||
return slog.New(handler)
|
||||
}
|
||||
64
realtime-gateway/internal/model/message.go
Normal file
64
realtime-gateway/internal/model/message.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package model
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
type Role string
|
||||
|
||||
const (
|
||||
RoleProducer Role = "producer"
|
||||
RoleConsumer Role = "consumer"
|
||||
RoleController Role = "controller"
|
||||
)
|
||||
|
||||
type Envelope struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
MessageID string `json:"messageId,omitempty"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Topic string `json:"topic"`
|
||||
Source Source `json:"source"`
|
||||
Target Target `json:"target"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
|
||||
type Source struct {
|
||||
Kind Role `json:"kind"`
|
||||
ID string `json:"id"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
}
|
||||
|
||||
type Target struct {
|
||||
ChannelID string `json:"channelId,omitempty"`
|
||||
DeviceID string `json:"deviceId,omitempty"`
|
||||
GroupID string `json:"groupId,omitempty"`
|
||||
}
|
||||
|
||||
type ClientMessage struct {
|
||||
Type string `json:"type"`
|
||||
Role Role `json:"role,omitempty"`
|
||||
ChannelID string `json:"channelId,omitempty"`
|
||||
Token string `json:"token,omitempty"`
|
||||
Subscriptions []Subscription `json:"subscriptions,omitempty"`
|
||||
Envelope *Envelope `json:"envelope,omitempty"`
|
||||
}
|
||||
|
||||
type Subscription struct {
|
||||
ChannelID string `json:"channelId,omitempty"`
|
||||
DeviceID string `json:"deviceId,omitempty"`
|
||||
GroupID string `json:"groupId,omitempty"`
|
||||
Topic string `json:"topic,omitempty"`
|
||||
}
|
||||
|
||||
type ServerMessage struct {
|
||||
Type string `json:"type"`
|
||||
SessionID string `json:"sessionId,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Envelope *Envelope `json:"envelope,omitempty"`
|
||||
State json.RawMessage `json:"state,omitempty"`
|
||||
}
|
||||
|
||||
type LatestState struct {
|
||||
DeviceID string `json:"deviceId"`
|
||||
GroupID string `json:"groupId,omitempty"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
Topics []string `json:"topics"`
|
||||
}
|
||||
53
realtime-gateway/internal/plugin/bus.go
Normal file
53
realtime-gateway/internal/plugin/bus.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
"realtime-gateway/internal/model"
|
||||
)
|
||||
|
||||
type Handler interface {
|
||||
Name() string
|
||||
Handle(context.Context, model.Envelope) error
|
||||
}
|
||||
|
||||
type Bus struct {
|
||||
logger *slog.Logger
|
||||
mu sync.RWMutex
|
||||
handlers []Handler
|
||||
}
|
||||
|
||||
func NewBus(logger *slog.Logger) *Bus {
|
||||
return &Bus{
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bus) Register(handler Handler) {
|
||||
b.mu.Lock()
|
||||
b.handlers = append(b.handlers, handler)
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
func (b *Bus) Publish(envelope model.Envelope) {
|
||||
b.mu.RLock()
|
||||
handlers := append([]Handler(nil), b.handlers...)
|
||||
b.mu.RUnlock()
|
||||
|
||||
for _, handler := range handlers {
|
||||
handler := handler
|
||||
go func() {
|
||||
if err := handler.Handle(context.Background(), envelope); err != nil {
|
||||
b.logger.Warn("plugin handler failed", "handler", handler.Name(), "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bus) HandlerCount() int {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return len(b.handlers)
|
||||
}
|
||||
337
realtime-gateway/internal/router/hub.go
Normal file
337
realtime-gateway/internal/router/hub.go
Normal file
@@ -0,0 +1,337 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
|
||||
"realtime-gateway/internal/model"
|
||||
)
|
||||
|
||||
type Subscriber interface {
|
||||
ID() string
|
||||
Send(message model.ServerMessage) error
|
||||
}
|
||||
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
subscribers map[string]Subscriber
|
||||
filters map[string][]model.Subscription
|
||||
latestState map[string]model.Envelope
|
||||
liveFeeds map[uint64]chan model.Envelope
|
||||
nextLiveID uint64
|
||||
stats trafficStats
|
||||
maxLatest int
|
||||
}
|
||||
|
||||
type TrafficSnapshot struct {
|
||||
Published uint64 `json:"published"`
|
||||
Dropped uint64 `json:"dropped"`
|
||||
Fanout uint64 `json:"fanout"`
|
||||
Topics []TopicTrafficItem `json:"topics"`
|
||||
Channels []ChannelTrafficItem `json:"channels"`
|
||||
}
|
||||
|
||||
type TopicTrafficItem struct {
|
||||
Topic string `json:"topic"`
|
||||
Published uint64 `json:"published"`
|
||||
Dropped uint64 `json:"dropped"`
|
||||
Fanout uint64 `json:"fanout"`
|
||||
}
|
||||
|
||||
type ChannelTrafficItem struct {
|
||||
ChannelID string `json:"channelId"`
|
||||
Published uint64 `json:"published"`
|
||||
Dropped uint64 `json:"dropped"`
|
||||
Fanout uint64 `json:"fanout"`
|
||||
}
|
||||
|
||||
type trafficStats struct {
|
||||
Published uint64
|
||||
Dropped uint64
|
||||
Fanout uint64
|
||||
Topics map[string]*trafficCounter
|
||||
Channels map[string]*trafficCounter
|
||||
}
|
||||
|
||||
type trafficCounter struct {
|
||||
Published uint64
|
||||
Dropped uint64
|
||||
Fanout uint64
|
||||
}
|
||||
|
||||
type PublishResult struct {
|
||||
Matched int `json:"matched"`
|
||||
Stored bool `json:"stored"`
|
||||
Dropped bool `json:"dropped"`
|
||||
}
|
||||
|
||||
func NewHub(maxLatest int) *Hub {
|
||||
return &Hub{
|
||||
subscribers: make(map[string]Subscriber),
|
||||
filters: make(map[string][]model.Subscription),
|
||||
latestState: make(map[string]model.Envelope),
|
||||
liveFeeds: make(map[uint64]chan model.Envelope),
|
||||
stats: trafficStats{
|
||||
Topics: make(map[string]*trafficCounter),
|
||||
Channels: make(map[string]*trafficCounter),
|
||||
},
|
||||
maxLatest: maxLatest,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) Register(subscriber Subscriber, subscriptions []model.Subscription) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.subscribers[subscriber.ID()] = subscriber
|
||||
h.filters[subscriber.ID()] = subscriptions
|
||||
}
|
||||
|
||||
func (h *Hub) Unregister(subscriberID string) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
delete(h.subscribers, subscriberID)
|
||||
delete(h.filters, subscriberID)
|
||||
}
|
||||
|
||||
func (h *Hub) UpdateSubscriptions(subscriberID string, subscriptions []model.Subscription) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.filters[subscriberID] = subscriptions
|
||||
}
|
||||
|
||||
func (h *Hub) Publish(envelope model.Envelope, deliveryMode string) PublishResult {
|
||||
h.mu.RLock()
|
||||
matches := make([]Subscriber, 0, len(h.subscribers))
|
||||
for subscriberID, subscriber := range h.subscribers {
|
||||
subscriptions := h.filters[subscriberID]
|
||||
if !matchesAny(envelope, subscriptions) {
|
||||
continue
|
||||
}
|
||||
matches = append(matches, subscriber)
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
|
||||
if deliveryMode == "drop_if_no_consumer" && len(matches) == 0 {
|
||||
h.recordTraffic(envelope, 0, true)
|
||||
return PublishResult{
|
||||
Matched: 0,
|
||||
Stored: false,
|
||||
Dropped: true,
|
||||
}
|
||||
}
|
||||
|
||||
h.storeLatest(envelope)
|
||||
h.publishLive(envelope)
|
||||
h.recordTraffic(envelope, len(matches), false)
|
||||
for _, subscriber := range matches {
|
||||
_ = subscriber.Send(model.ServerMessage{
|
||||
Type: "event",
|
||||
Envelope: &envelope,
|
||||
})
|
||||
}
|
||||
return PublishResult{
|
||||
Matched: len(matches),
|
||||
Stored: true,
|
||||
Dropped: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) Snapshot(channelID string, deviceID string) (json.RawMessage, bool) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
envelope, ok := h.latestState[latestStateKey(channelID, deviceID)]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
data, err := json.Marshal(envelope)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return data, true
|
||||
}
|
||||
|
||||
func (h *Hub) Stats() (subscriberCount int, latestStateCount int) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return len(h.subscribers), len(h.latestState)
|
||||
}
|
||||
|
||||
func (h *Hub) LatestStates() []model.Envelope {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
items := make([]model.Envelope, 0, len(h.latestState))
|
||||
for _, envelope := range h.latestState {
|
||||
items = append(items, envelope)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func (h *Hub) TrafficSnapshot() TrafficSnapshot {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
topics := make([]TopicTrafficItem, 0, len(h.stats.Topics))
|
||||
for topic, counter := range h.stats.Topics {
|
||||
topics = append(topics, TopicTrafficItem{
|
||||
Topic: topic,
|
||||
Published: counter.Published,
|
||||
Dropped: counter.Dropped,
|
||||
Fanout: counter.Fanout,
|
||||
})
|
||||
}
|
||||
|
||||
channels := make([]ChannelTrafficItem, 0, len(h.stats.Channels))
|
||||
for channelID, counter := range h.stats.Channels {
|
||||
channels = append(channels, ChannelTrafficItem{
|
||||
ChannelID: channelID,
|
||||
Published: counter.Published,
|
||||
Dropped: counter.Dropped,
|
||||
Fanout: counter.Fanout,
|
||||
})
|
||||
}
|
||||
|
||||
return TrafficSnapshot{
|
||||
Published: h.stats.Published,
|
||||
Dropped: h.stats.Dropped,
|
||||
Fanout: h.stats.Fanout,
|
||||
Topics: topics,
|
||||
Channels: channels,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) SubscribeLive(buffer int) (uint64, <-chan model.Envelope) {
|
||||
if buffer <= 0 {
|
||||
buffer = 32
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
h.nextLiveID += 1
|
||||
id := h.nextLiveID
|
||||
ch := make(chan model.Envelope, buffer)
|
||||
h.liveFeeds[id] = ch
|
||||
return id, ch
|
||||
}
|
||||
|
||||
func (h *Hub) UnsubscribeLive(id uint64) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
ch, ok := h.liveFeeds[id]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
delete(h.liveFeeds, id)
|
||||
close(ch)
|
||||
}
|
||||
|
||||
func (h *Hub) storeLatest(envelope model.Envelope) {
|
||||
if envelope.Target.DeviceID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if len(h.latestState) >= h.maxLatest {
|
||||
for key := range h.latestState {
|
||||
delete(h.latestState, key)
|
||||
break
|
||||
}
|
||||
}
|
||||
h.latestState[latestStateKey(envelope.Target.ChannelID, envelope.Target.DeviceID)] = envelope
|
||||
}
|
||||
|
||||
func (h *Hub) publishLive(envelope model.Envelope) {
|
||||
h.mu.RLock()
|
||||
feeds := make([]chan model.Envelope, 0, len(h.liveFeeds))
|
||||
for _, ch := range h.liveFeeds {
|
||||
feeds = append(feeds, ch)
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
|
||||
for _, ch := range feeds {
|
||||
select {
|
||||
case ch <- envelope:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) recordTraffic(envelope model.Envelope, matched int, dropped bool) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
h.stats.Published += 1
|
||||
h.stats.Fanout += uint64(matched)
|
||||
if dropped {
|
||||
h.stats.Dropped += 1
|
||||
}
|
||||
|
||||
topicKey := envelope.Topic
|
||||
if topicKey == "" {
|
||||
topicKey = "--"
|
||||
}
|
||||
topicCounter := h.stats.Topics[topicKey]
|
||||
if topicCounter == nil {
|
||||
topicCounter = &trafficCounter{}
|
||||
h.stats.Topics[topicKey] = topicCounter
|
||||
}
|
||||
topicCounter.Published += 1
|
||||
topicCounter.Fanout += uint64(matched)
|
||||
if dropped {
|
||||
topicCounter.Dropped += 1
|
||||
}
|
||||
|
||||
channelKey := envelope.Target.ChannelID
|
||||
if channelKey == "" {
|
||||
channelKey = "--"
|
||||
}
|
||||
channelCounter := h.stats.Channels[channelKey]
|
||||
if channelCounter == nil {
|
||||
channelCounter = &trafficCounter{}
|
||||
h.stats.Channels[channelKey] = channelCounter
|
||||
}
|
||||
channelCounter.Published += 1
|
||||
channelCounter.Fanout += uint64(matched)
|
||||
if dropped {
|
||||
channelCounter.Dropped += 1
|
||||
}
|
||||
}
|
||||
|
||||
func matchesAny(envelope model.Envelope, subscriptions []model.Subscription) bool {
|
||||
if len(subscriptions) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, subscription := range subscriptions {
|
||||
if matches(envelope, subscription) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func matches(envelope model.Envelope, subscription model.Subscription) bool {
|
||||
if subscription.ChannelID != "" && subscription.ChannelID != envelope.Target.ChannelID {
|
||||
return false
|
||||
}
|
||||
if subscription.DeviceID != "" && subscription.DeviceID != envelope.Target.DeviceID {
|
||||
return false
|
||||
}
|
||||
if subscription.GroupID != "" && subscription.GroupID != envelope.Target.GroupID {
|
||||
return false
|
||||
}
|
||||
if subscription.Topic != "" && subscription.Topic != envelope.Topic {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func latestStateKey(channelID string, deviceID string) string {
|
||||
if channelID == "" {
|
||||
return deviceID
|
||||
}
|
||||
return channelID + "::" + deviceID
|
||||
}
|
||||
109
realtime-gateway/internal/session/session.go
Normal file
109
realtime-gateway/internal/session/session.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"realtime-gateway/internal/model"
|
||||
)
|
||||
|
||||
type Session struct {
|
||||
ID string
|
||||
Role model.Role
|
||||
Authenticated bool
|
||||
ChannelID string
|
||||
Subscriptions []model.Subscription
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
ID string `json:"id"`
|
||||
Role model.Role `json:"role"`
|
||||
Authenticated bool `json:"authenticated"`
|
||||
ChannelID string `json:"channelId,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Subscriptions []model.Subscription `json:"subscriptions"`
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
sequence atomic.Uint64
|
||||
sessions map[string]*Session
|
||||
}
|
||||
|
||||
func NewManager() *Manager {
|
||||
return &Manager{
|
||||
sessions: make(map[string]*Session),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Create() *Session {
|
||||
id := m.sequence.Add(1)
|
||||
session := &Session{
|
||||
ID: formatSessionID(id),
|
||||
Role: model.RoleConsumer,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.sessions[session.ID] = session
|
||||
m.mu.Unlock()
|
||||
return session
|
||||
}
|
||||
|
||||
func (m *Manager) Delete(sessionID string) {
|
||||
m.mu.Lock()
|
||||
delete(m.sessions, sessionID)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *Manager) Get(sessionID string) (*Session, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
session, ok := m.sessions[sessionID]
|
||||
return session, ok
|
||||
}
|
||||
|
||||
func (m *Manager) Count() int {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return len(m.sessions)
|
||||
}
|
||||
|
||||
func (m *Manager) List() []Snapshot {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
snapshots := make([]Snapshot, 0, len(m.sessions))
|
||||
for _, current := range m.sessions {
|
||||
subscriptions := append([]model.Subscription(nil), current.Subscriptions...)
|
||||
snapshots = append(snapshots, Snapshot{
|
||||
ID: current.ID,
|
||||
Role: current.Role,
|
||||
Authenticated: current.Authenticated,
|
||||
ChannelID: current.ChannelID,
|
||||
CreatedAt: current.CreatedAt,
|
||||
Subscriptions: subscriptions,
|
||||
})
|
||||
}
|
||||
return snapshots
|
||||
}
|
||||
|
||||
func formatSessionID(id uint64) string {
|
||||
return "sess-" + itoa(id)
|
||||
}
|
||||
|
||||
func itoa(v uint64) string {
|
||||
if v == 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [20]byte
|
||||
i := len(buf)
|
||||
for v > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + v%10)
|
||||
v /= 10
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
Reference in New Issue
Block a user