Files
mqtt_explorer/backend/cmd/server/main.go
Gilles Soulier 383ad292d3 first
2025-12-24 14:47:39 +01:00

244 lines
5.7 KiB
Go

package main
import (
"fmt"
"log"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/google/uuid"
"mqtt_explorer/internal/api"
"mqtt_explorer/internal/config"
"mqtt_explorer/internal/filters"
"mqtt_explorer/internal/mqtt"
"mqtt_explorer/internal/settings"
"mqtt_explorer/internal/storage"
"mqtt_explorer/internal/sysinfo"
"mqtt_explorer/internal/topics"
"mqtt_explorer/internal/ws"
)
func main() {
cfg := config.Load()
store, err := storage.Open(cfg.SQLitePath)
if err != nil {
panic(err)
}
tree := topics.NewTree()
hub := ws.NewHub()
filterStore := filters.NewStore()
sysStore := sysinfo.NewStore()
settingsStore := settings.NewStore(cfg.SettingsFile)
defaultSettings := settings.Settings{
Theme: "dark-monokai",
RepoURL: "https://gitea.maison43.duckdns.org/gilles/mqtt_explorer",
TTLDays: cfg.TTLDays,
MaxPayloadBytes: 1024 * 100,
AutoPurgePayloads: false,
AutoPurgePayloadBytes: 1024 * 250,
AutoExpandDepth: 2,
ImageDetection: true,
HighlightMs: 300,
MQTTProfiles: []settings.MQTTProfile{
{
ID: "default",
Name: "Broker local",
Host: "10.0.0.3",
Port: 1883,
Username: "",
Password: "",
IsDefault: true,
},
},
ActiveProfileID: "default",
ApplyViewFilter: true,
ExpandTreeOnStart: false,
TopicFilters: []settings.TopicFilter{},
UIFontSize: 13,
TopicFontSize: 12,
PayloadFontSize: 12,
StatsRefreshMs: 5000,
ResizeHandlePx: 8,
}
loadedSettings, err := settingsStore.Load(defaultSettings)
if err != nil {
log.Printf("settings load error: %v", err)
} else {
filterStore.Update(toFilterRules(loadedSettings.TopicFilters))
}
settingsRuntime := settings.NewRuntime(loadedSettings)
mqttMgr, err := mqtt.NewManager(
cfg.MQTTBroker,
cfg.MQTTUsername,
cfg.MQTTPassword,
cfg.MQTTClientID,
func(topic string, payload []byte, qos byte, retained bool) {
if cfg.MQTTDebug {
preview := string(payload)
if len(preview) > 256 {
preview = preview[:256] + "..."
}
log.Printf("mqtt message topic=%s qos=%d retained=%t size=%d payload=%q", topic, qos, retained, len(payload), preview)
}
if strings.HasPrefix(topic, "$SYS/") {
sysStore.Update(topic, string(payload))
return
}
msg := topics.Message{
ID: uuid.NewString(),
Topic: topic,
Payload: string(payload),
QOS: qos,
Retained: retained,
Timestamp: time.Now().UTC(),
Size: len(payload),
}
tree.AddMessage(msg)
if rule, ok := filterStore.Match(topic); !ok || rule.Save {
currentSettings := settingsRuntime.Get()
skipStore := currentSettings.AutoPurgePayloads && currentSettings.AutoPurgePayloadBytes > 0 && len(payload) >= currentSettings.AutoPurgePayloadBytes
if !skipStore {
_ = store.InsertMessage(storage.Message{
ID: msg.ID,
Topic: msg.Topic,
Payload: msg.Payload,
QOS: msg.QOS,
Retained: msg.Retained,
Timestamp: msg.Timestamp,
Size: msg.Size,
})
}
}
hub.Broadcast(ws.Event{Type: "message", Data: msg})
},
cfg.MQTTSubscribe,
cfg.MQTTQOS,
cfg.SysSubscribe,
)
if err != nil {
panic(err)
}
go startTTLJob(store, settingsRuntime)
go startOversizeJob(store, settingsRuntime)
go startDBSizeJob(store, 400*1024*1024)
go startStatsJob(store, hub)
staticDir := resolveStaticDir()
server := api.NewServer(store, tree, hub, mqttMgr, filterStore, settingsStore, settingsRuntime, defaultSettings, sysStore, staticDir)
go func() {
addr := fmt.Sprintf(":%s", cfg.HTTPPort)
if err := server.Run(addr); err != nil {
panic(err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
mqttMgr.Disconnect()
}
func toFilterRules(filtersIn []settings.TopicFilter) []filters.Rule {
out := make([]filters.Rule, 0, len(filtersIn))
for _, entry := range filtersIn {
out = append(out, filters.Rule{
Topic: entry.Topic,
Save: entry.Save,
View: entry.View,
})
}
return out
}
func resolveStaticDir() string {
candidates := []string{
"./frontend/dist",
"./dist",
}
for _, path := range candidates {
if info, err := os.Stat(path); err == nil && info.IsDir() {
abs, err := filepath.Abs(path)
if err == nil {
return abs
}
return path
}
}
return ""
}
func startTTLJob(store *storage.Store, settingsRuntime *settings.Runtime) {
ticker := time.NewTicker(10 * time.Minute)
defer ticker.Stop()
for range ticker.C {
ttlDays := settingsRuntime.Get().TTLDays
if ttlDays <= 0 {
continue
}
cutoff := time.Now().AddDate(0, 0, -ttlDays)
_, _ = store.PurgeBefore(cutoff)
}
}
func startOversizeJob(store *storage.Store, settingsRuntime *settings.Runtime) {
ticker := time.NewTicker(10 * time.Minute)
defer ticker.Stop()
for range ticker.C {
settingsSnapshot := settingsRuntime.Get()
if !settingsSnapshot.AutoPurgePayloads || settingsSnapshot.AutoPurgePayloadBytes <= 0 {
continue
}
_, _ = store.PurgeOversize(settingsSnapshot.AutoPurgePayloadBytes)
}
}
func startDBSizeJob(store *storage.Store, maxBytes int64) {
if maxBytes <= 0 {
return
}
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
stats, err := store.Stats()
if err != nil || stats.Bytes <= maxBytes {
continue
}
if deleted, err := store.DeleteOldestFraction(0.25); err == nil && deleted > 0 {
_ = store.Compact()
}
}
}
func startStatsJob(store *storage.Store, hub *ws.Hub) {
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
for range ticker.C {
stats, err := store.Stats()
if err != nil {
continue
}
hub.Broadcast(ws.Event{Type: "stats", Data: stats})
}
}