first
This commit is contained in:
243
backend/cmd/server/main.go
Normal file
243
backend/cmd/server/main.go
Normal file
@@ -0,0 +1,243 @@
|
||||
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})
|
||||
}
|
||||
}
|
||||
52
backend/go.mod
Executable file
52
backend/go.mod
Executable file
@@ -0,0 +1,52 @@
|
||||
module mqtt_explorer
|
||||
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.24.4
|
||||
|
||||
require (
|
||||
github.com/eclipse/paho.mqtt.golang v1.4.3
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
modernc.org/sqlite v1.32.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.12.8 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.2 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gin-contrib/sse v1.0.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.25.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.14.0 // indirect
|
||||
golang.org/x/crypto v0.36.0 // indirect
|
||||
golang.org/x/net v0.37.0 // indirect
|
||||
golang.org/x/sync v0.12.0 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
google.golang.org/protobuf v1.36.5 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
||||
modernc.org/libc v1.55.3 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.8.0 // indirect
|
||||
modernc.org/strutil v1.2.0 // indirect
|
||||
modernc.org/token v1.1.0 // indirect
|
||||
)
|
||||
134
backend/go.sum
Normal file
134
backend/go.sum
Normal file
@@ -0,0 +1,134 @@
|
||||
github.com/bytedance/sonic v1.12.8 h1:4xYRVRlXIgvSZ4e8iVTlMF5szgpXd4AfvuWgA8I8lgs=
|
||||
github.com/bytedance/sonic v1.12.8/go.mod h1:uVvFidNmlt9+wa31S1urfwwthTWteBgG0hWuoKAXTx8=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/bytedance/sonic/loader v0.2.2 h1:jxAJuN9fOot/cyz5Q6dUuMJF5OqQ6+5GfA8FjjQ0R4o=
|
||||
github.com/bytedance/sonic/loader v0.2.2/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
||||
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/eclipse/paho.mqtt.golang v1.4.3 h1:2kwcUGn8seMUfWndX0hGbvH8r7crgcJguQNCyp70xik=
|
||||
github.com/eclipse/paho.mqtt.golang v1.4.3/go.mod h1:CSYvoAlsMkhYOXh/oKyxa8EcBci6dVkLCbo5tTC1RIE=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
|
||||
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.25.0 h1:5Dh7cjvzR7BRZadnsVOzPhWsrwUr0nmsZJxEAnFLNO8=
|
||||
github.com/go-playground/validator/v10 v10.25.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
golang.org/x/arch v0.14.0 h1:z9JUEZWr8x4rR0OU6c4/4t6E6jOZ8/QBS2bBYBm4tx4=
|
||||
golang.org/x/arch v0.14.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
|
||||
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
|
||||
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
|
||||
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
||||
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
||||
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||
modernc.org/sqlite v1.32.0 h1:6BM4uGza7bWypsw4fdLRsLxut6bHe4c58VeqjRgST8s=
|
||||
modernc.org/sqlite v1.32.0/go.mod h1:UqoylwmTb9F+IqXERT8bW9zzOWN8qwAIcLdzeBZs4hA=
|
||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
292
backend/internal/api/server.go
Normal file
292
backend/internal/api/server.go
Normal file
@@ -0,0 +1,292 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"mqtt_explorer/internal/filters"
|
||||
"mqtt_explorer/internal/metrics"
|
||||
"mqtt_explorer/internal/mqtt"
|
||||
"mqtt_explorer/internal/settings"
|
||||
"mqtt_explorer/internal/storage"
|
||||
"mqtt_explorer/internal/sysinfo"
|
||||
"mqtt_explorer/internal/topics"
|
||||
"mqtt_explorer/internal/ws"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
router *gin.Engine
|
||||
store *storage.Store
|
||||
tree *topics.Tree
|
||||
hub *ws.Hub
|
||||
mqttMgr *mqtt.Manager
|
||||
metrics *metrics.Collector
|
||||
filters *filters.Store
|
||||
settings *settings.Store
|
||||
settingsRuntime *settings.Runtime
|
||||
defaults settings.Settings
|
||||
sysinfo *sysinfo.Store
|
||||
}
|
||||
|
||||
type PublishRequest struct {
|
||||
Topic string `json:"topic"`
|
||||
Payload string `json:"payload"`
|
||||
QOS byte `json:"qos"`
|
||||
Retained bool `json:"retained"`
|
||||
}
|
||||
|
||||
type TestConnectionRequest struct {
|
||||
Broker string `json:"broker"`
|
||||
}
|
||||
|
||||
func NewServer(store *storage.Store, tree *topics.Tree, hub *ws.Hub, mqttMgr *mqtt.Manager, filterStore *filters.Store, settingsStore *settings.Store, settingsRuntime *settings.Runtime, defaults settings.Settings, sysStore *sysinfo.Store, staticDir string) *Server {
|
||||
router := gin.Default()
|
||||
|
||||
s := &Server{
|
||||
router: router,
|
||||
store: store,
|
||||
tree: tree,
|
||||
hub: hub,
|
||||
mqttMgr: mqttMgr,
|
||||
metrics: &metrics.Collector{},
|
||||
filters: filterStore,
|
||||
settings: settingsStore,
|
||||
settingsRuntime: settingsRuntime,
|
||||
defaults: defaults,
|
||||
sysinfo: sysStore,
|
||||
}
|
||||
s.registerRoutes()
|
||||
if staticDir != "" {
|
||||
router.Static("/assets", staticDir+"/assets")
|
||||
router.Static("/themes", staticDir+"/themes")
|
||||
router.Static("/favicon", staticDir+"/favicon")
|
||||
router.StaticFile("/site.webmanifest", staticDir+"/site.webmanifest")
|
||||
router.StaticFile("/favicon.ico", staticDir+"/favicon.ico")
|
||||
router.NoRoute(func(c *gin.Context) {
|
||||
c.File(staticDir + "/index.html")
|
||||
})
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) registerRoutes() {
|
||||
s.router.GET("/api/health", s.health)
|
||||
s.router.GET("/api/stats", s.stats)
|
||||
s.router.GET("/api/metrics", s.metricsHandler)
|
||||
s.router.GET("/api/filters", s.getFilters)
|
||||
s.router.POST("/api/filters", s.updateFilters)
|
||||
s.router.GET("/api/settings", s.getSettings)
|
||||
s.router.POST("/api/settings", s.updateSettings)
|
||||
s.router.GET("/api/sysinfo", s.getSysinfo)
|
||||
s.router.POST("/api/test-connection", s.testConnection)
|
||||
s.router.GET("/api/topics", s.getTopics)
|
||||
s.router.GET("/api/topic/:topic/history", s.getHistory)
|
||||
s.router.POST("/api/topic/:topic/clear-history", s.clearHistory)
|
||||
s.router.POST("/api/history/clear", s.clearAllHistory)
|
||||
s.router.POST("/api/publish", s.publish)
|
||||
s.router.GET("/ws/events", s.wsEvents)
|
||||
}
|
||||
|
||||
func (s *Server) Run(addr string) error {
|
||||
return s.router.Run(addr)
|
||||
}
|
||||
|
||||
func (s *Server) health(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) stats(c *gin.Context) {
|
||||
stats, err := s.store.Stats()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
|
||||
func (s *Server) metricsHandler(c *gin.Context) {
|
||||
stats, err := s.store.Stats()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
usage := s.metrics.Snapshot()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"cpuPercent": usage.CPUPercent,
|
||||
"memBytes": usage.MemBytes,
|
||||
"memLimit": usage.MemLimit,
|
||||
"dbBytes": stats.Bytes,
|
||||
"dbSize": stats.Size,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) getFilters(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, s.filters.Snapshot())
|
||||
}
|
||||
|
||||
func (s *Server) updateFilters(c *gin.Context) {
|
||||
var rules []filters.Rule
|
||||
if err := c.ShouldBindJSON(&rules); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "payload invalide"})
|
||||
return
|
||||
}
|
||||
s.filters.Update(rules)
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) getSettings(c *gin.Context) {
|
||||
current, err := s.settings.Load(s.defaults)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, current)
|
||||
}
|
||||
|
||||
func (s *Server) updateSettings(c *gin.Context) {
|
||||
var next settings.Settings
|
||||
if err := c.ShouldBindJSON(&next); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "payload invalide"})
|
||||
return
|
||||
}
|
||||
if err := s.settings.Save(next); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
s.filters.Update(toFilterRules(next.TopicFilters))
|
||||
if s.settingsRuntime != nil {
|
||||
s.settingsRuntime.Update(next)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) getSysinfo(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, s.sysinfo.Snapshot())
|
||||
}
|
||||
|
||||
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 (s *Server) testConnection(c *gin.Context) {
|
||||
var req TestConnectionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.Broker == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "broker manquant"})
|
||||
return
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(req.Broker)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "broker invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
host := parsed.Hostname()
|
||||
port := parsed.Port()
|
||||
if port == "" {
|
||||
port = "1883"
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(host, port)
|
||||
start := time.Now()
|
||||
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
_ = conn.Close()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
"latency": time.Since(start).Milliseconds(),
|
||||
"endpoint": addr,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) getTopics(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, s.tree.Snapshot())
|
||||
}
|
||||
|
||||
func (s *Server) getHistory(c *gin.Context) {
|
||||
topic := c.Param("topic")
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
from := c.DefaultQuery("from", "")
|
||||
to := c.DefaultQuery("to", "")
|
||||
|
||||
messages, err := s.store.GetHistory(topic, limit, from, to)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, messages)
|
||||
}
|
||||
|
||||
func (s *Server) clearHistory(c *gin.Context) {
|
||||
topic := c.Param("topic")
|
||||
deleted, err := s.store.ClearTopicHistory(topic)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": deleted})
|
||||
}
|
||||
|
||||
func (s *Server) clearAllHistory(c *gin.Context) {
|
||||
deleted, err := s.store.ClearAllHistory()
|
||||
if err != nil {
|
||||
log.Printf("clear db error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
log.Printf("clear db ok: deleted=%d", deleted)
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": deleted})
|
||||
}
|
||||
|
||||
func (s *Server) publish(c *gin.Context) {
|
||||
var req PublishRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.Topic == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "requete invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.mqttMgr.Publish(req.Topic, []byte(req.Payload), req.QOS, req.Retained); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) wsEvents(c *gin.Context) {
|
||||
upgrader := websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
}
|
||||
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
s.hub.Add(conn)
|
||||
|
||||
for {
|
||||
if _, _, err := conn.ReadMessage(); err != nil {
|
||||
s.hub.Remove(conn)
|
||||
_ = conn.Close()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
76
backend/internal/config/config.go
Normal file
76
backend/internal/config/config.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
HTTPPort string
|
||||
MQTTBroker string
|
||||
MQTTUsername string
|
||||
MQTTPassword string
|
||||
MQTTClientID string
|
||||
MQTTSubscribe string
|
||||
MQTTQOS byte
|
||||
SQLitePath string
|
||||
TTLDays int
|
||||
MQTTDebug bool
|
||||
SettingsFile string
|
||||
SysSubscribe string
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
cfg := Config{
|
||||
HTTPPort: getEnv("PORT", "8088"),
|
||||
MQTTBroker: getEnv("MQTT_BROKER", "tcp://broker.hivemq.com:1883"),
|
||||
MQTTUsername: getEnv("MQTT_USERNAME", ""),
|
||||
MQTTPassword: getEnv("MQTT_PASSWORD", ""),
|
||||
MQTTClientID: getEnv("MQTT_CLIENT_ID", "mqtt-web-explorer"),
|
||||
MQTTSubscribe: getEnv("MQTT_SUBSCRIBE", "#"),
|
||||
SQLitePath: getEnv("SQLITE_DB", "./data/mqtt.db"),
|
||||
TTLDays: getEnvInt("TTL_DAYS", 7),
|
||||
MQTTQOS: byte(getEnvInt("MQTT_QOS", 0)),
|
||||
MQTTDebug: getEnvBool("MQTT_DEBUG", false),
|
||||
SettingsFile: getEnv("SETTINGS_FILE", "/data/settings.yml"),
|
||||
SysSubscribe: getEnv("MQTT_SYS_SUBSCRIBE", "$SYS/#"),
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
val := os.Getenv(key)
|
||||
if val == "" {
|
||||
return fallback
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
func getEnvInt(key string, fallback int) int {
|
||||
val := os.Getenv(key)
|
||||
if val == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.Atoi(val)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func getEnvBool(key string, fallback bool) bool {
|
||||
val := os.Getenv(key)
|
||||
if val == "" {
|
||||
return fallback
|
||||
}
|
||||
switch strings.ToLower(val) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
case "0", "false", "no", "off":
|
||||
return false
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
68
backend/internal/filters/filters.go
Normal file
68
backend/internal/filters/filters.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package filters
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Rule struct {
|
||||
Topic string `json:"topic"`
|
||||
Save bool `json:"save"`
|
||||
View bool `json:"view"`
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
rules []Rule
|
||||
}
|
||||
|
||||
func NewStore() *Store {
|
||||
return &Store{rules: []Rule{}}
|
||||
}
|
||||
|
||||
func (s *Store) Update(rules []Rule) {
|
||||
clean := make([]Rule, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
if strings.TrimSpace(rule.Topic) == "" {
|
||||
continue
|
||||
}
|
||||
clean = append(clean, rule)
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.rules = clean
|
||||
}
|
||||
|
||||
func (s *Store) Snapshot() []Rule {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]Rule, len(s.rules))
|
||||
copy(out, s.rules)
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) Match(topic string) (Rule, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, rule := range s.rules {
|
||||
if matchTopic(rule.Topic, topic) {
|
||||
return rule, true
|
||||
}
|
||||
}
|
||||
return Rule{}, false
|
||||
}
|
||||
|
||||
func matchTopic(rule, topic string) bool {
|
||||
rule = strings.TrimSpace(rule)
|
||||
if rule == "" {
|
||||
return false
|
||||
}
|
||||
if rule == "#" {
|
||||
return true
|
||||
}
|
||||
if strings.HasSuffix(rule, "/#") {
|
||||
prefix := strings.TrimSuffix(rule, "/#")
|
||||
return strings.HasPrefix(topic, prefix)
|
||||
}
|
||||
return rule == topic
|
||||
}
|
||||
207
backend/internal/metrics/metrics.go
Normal file
207
backend/internal/metrics/metrics.go
Normal file
@@ -0,0 +1,207 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Snapshot struct {
|
||||
CPUPercent float64 `json:"cpuPercent"`
|
||||
MemBytes int64 `json:"memBytes"`
|
||||
MemLimit int64 `json:"memLimit"`
|
||||
}
|
||||
|
||||
type Collector struct {
|
||||
mu sync.Mutex
|
||||
lastCPUUseUse uint64
|
||||
lastTime time.Time
|
||||
}
|
||||
|
||||
func (c *Collector) Snapshot() Snapshot {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
usage, okCPU := readCPUUsage()
|
||||
mem, limit, okMem := readMemoryUsage()
|
||||
|
||||
cpuPercent := 0.0
|
||||
if okCPU {
|
||||
now := time.Now()
|
||||
if !c.lastTime.IsZero() {
|
||||
deltaUsage := float64(usage - c.lastCPUUseUse)
|
||||
deltaTime := now.Sub(c.lastTime).Seconds()
|
||||
cpuQuota := cpuQuotaCount()
|
||||
if cpuQuota <= 0 {
|
||||
cpuQuota = float64(runtime.NumCPU())
|
||||
}
|
||||
if deltaTime > 0 && cpuQuota > 0 {
|
||||
cpuPercent = (deltaUsage / (deltaTime * 1_000_000)) * (100 / cpuQuota)
|
||||
}
|
||||
}
|
||||
c.lastCPUUseUse = usage
|
||||
c.lastTime = now
|
||||
}
|
||||
|
||||
if !okMem || mem <= 0 {
|
||||
var memStats runtime.MemStats
|
||||
runtime.ReadMemStats(&memStats)
|
||||
mem = int64(memStats.Alloc)
|
||||
limit = int64(memStats.Sys)
|
||||
}
|
||||
|
||||
return Snapshot{
|
||||
CPUPercent: cpuPercent,
|
||||
MemBytes: mem,
|
||||
MemLimit: limit,
|
||||
}
|
||||
}
|
||||
|
||||
func readCPUUsage() (uint64, bool) {
|
||||
file, err := os.Open("/sys/fs/cgroup/cpu.stat")
|
||||
if err != nil {
|
||||
return readCPUUsageV1()
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) == 2 && parts[0] == "usage_usec" {
|
||||
val, err := strconv.ParseUint(parts[1], 10, 64)
|
||||
if err == nil {
|
||||
return val, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return readCPUUsageV1()
|
||||
}
|
||||
|
||||
func readMemoryUsage() (int64, int64, bool) {
|
||||
current, err := os.ReadFile("/sys/fs/cgroup/memory.current")
|
||||
if err != nil {
|
||||
return readMemoryUsageV1()
|
||||
}
|
||||
limit, err := os.ReadFile("/sys/fs/cgroup/memory.max")
|
||||
if err != nil {
|
||||
return readMemoryUsageV1()
|
||||
}
|
||||
memBytes, err := strconv.ParseInt(strings.TrimSpace(string(current)), 10, 64)
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
limitRaw := strings.TrimSpace(string(limit))
|
||||
if limitRaw == "max" {
|
||||
return memBytes, 0, true
|
||||
}
|
||||
limitBytes, err := strconv.ParseInt(limitRaw, 10, 64)
|
||||
if err != nil {
|
||||
return memBytes, 0, true
|
||||
}
|
||||
return memBytes, limitBytes, true
|
||||
}
|
||||
|
||||
func readCPUUsageV1() (uint64, bool) {
|
||||
data, err := os.ReadFile("/sys/fs/cgroup/cpuacct/cpuacct.usage")
|
||||
if err != nil {
|
||||
return readProcCPUUsage()
|
||||
}
|
||||
val, err := strconv.ParseUint(strings.TrimSpace(string(data)), 10, 64)
|
||||
if err != nil {
|
||||
return readProcCPUUsage()
|
||||
}
|
||||
return val / 1000, true
|
||||
}
|
||||
|
||||
func readMemoryUsageV1() (int64, int64, bool) {
|
||||
current, err := os.ReadFile("/sys/fs/cgroup/memory/memory.usage_in_bytes")
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
limit, err := os.ReadFile("/sys/fs/cgroup/memory/memory.limit_in_bytes")
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
memBytes, err := strconv.ParseInt(strings.TrimSpace(string(current)), 10, 64)
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
limitBytes, err := strconv.ParseInt(strings.TrimSpace(string(limit)), 10, 64)
|
||||
if err != nil {
|
||||
return memBytes, 0, true
|
||||
}
|
||||
return memBytes, limitBytes, true
|
||||
}
|
||||
|
||||
func readProcCPUUsage() (uint64, bool) {
|
||||
data, err := os.ReadFile("/proc/self/stat")
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
contents := string(data)
|
||||
closeIdx := strings.LastIndex(contents, ")")
|
||||
if closeIdx == -1 || closeIdx+2 >= len(contents) {
|
||||
return 0, false
|
||||
}
|
||||
fields := strings.Fields(contents[closeIdx+2:])
|
||||
if len(fields) < 15 {
|
||||
return 0, false
|
||||
}
|
||||
utime, err := strconv.ParseUint(fields[11], 10, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
stime, err := strconv.ParseUint(fields[12], 10, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
clkTck := uint64(100)
|
||||
totalTicks := utime + stime
|
||||
usec := (totalTicks * 1_000_000) / clkTck
|
||||
return usec, true
|
||||
}
|
||||
|
||||
func cpuQuotaCount() float64 {
|
||||
data, err := os.ReadFile("/sys/fs/cgroup/cpu.max")
|
||||
if err != nil {
|
||||
return float64(runtime.NumCPU())
|
||||
}
|
||||
parts := strings.Fields(string(data))
|
||||
if len(parts) != 2 {
|
||||
return 0
|
||||
}
|
||||
if parts[0] == "max" {
|
||||
return float64(runtime.NumCPU())
|
||||
}
|
||||
quota, err := strconv.ParseFloat(parts[0], 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
period, err := strconv.ParseFloat(parts[1], 64)
|
||||
if err != nil || period == 0 {
|
||||
return 0
|
||||
}
|
||||
return quota / period
|
||||
}
|
||||
|
||||
func FormatBytes(size int64) string {
|
||||
if size < 1024 {
|
||||
return fmt.Sprintf("%d B", size)
|
||||
}
|
||||
kb := float64(size) / 1024
|
||||
if kb < 1024 {
|
||||
return fmt.Sprintf("%.1f KB", kb)
|
||||
}
|
||||
mb := kb / 1024
|
||||
if mb < 1024 {
|
||||
return fmt.Sprintf("%.1f MB", mb)
|
||||
}
|
||||
gb := mb / 1024
|
||||
return fmt.Sprintf("%.2f GB", gb)
|
||||
}
|
||||
82
backend/internal/mqtt/manager.go
Normal file
82
backend/internal/mqtt/manager.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package mqtt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
paho "github.com/eclipse/paho.mqtt.golang"
|
||||
)
|
||||
|
||||
type MessageHandler func(topic string, payload []byte, qos byte, retained bool)
|
||||
|
||||
type Manager struct {
|
||||
client paho.Client
|
||||
handler MessageHandler
|
||||
subTopic string
|
||||
subQOS byte
|
||||
sysTopic string
|
||||
}
|
||||
|
||||
func NewManager(broker, username, password, clientID string, handler MessageHandler, subTopic string, subQOS byte, sysTopic string) (*Manager, error) {
|
||||
opts := paho.NewClientOptions()
|
||||
opts.AddBroker(broker)
|
||||
opts.SetClientID(clientID)
|
||||
opts.SetAutoReconnect(true)
|
||||
opts.SetConnectRetry(true)
|
||||
opts.SetConnectRetryInterval(5 * time.Second)
|
||||
if username != "" {
|
||||
opts.SetUsername(username)
|
||||
opts.SetPassword(password)
|
||||
}
|
||||
|
||||
mgr := &Manager{
|
||||
handler: handler,
|
||||
subTopic: subTopic,
|
||||
subQOS: subQOS,
|
||||
sysTopic: sysTopic,
|
||||
}
|
||||
|
||||
opts.SetOnConnectHandler(func(c paho.Client) {
|
||||
if token := c.Subscribe(mgr.subTopic, mgr.subQOS, mgr.onMessage); token.Wait() && token.Error() != nil {
|
||||
fmt.Printf("erreur subscribe MQTT: %v\n", token.Error())
|
||||
}
|
||||
if mgr.sysTopic != "" {
|
||||
if token := c.Subscribe(mgr.sysTopic, mgr.subQOS, mgr.onMessage); token.Wait() && token.Error() != nil {
|
||||
fmt.Printf("erreur subscribe SYS MQTT: %v\n", token.Error())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
opts.SetConnectionLostHandler(func(_ paho.Client, err error) {
|
||||
fmt.Printf("connexion MQTT perdue: %v\n", err)
|
||||
})
|
||||
|
||||
mgr.client = paho.NewClient(opts)
|
||||
if token := mgr.client.Connect(); token.Wait() && token.Error() != nil {
|
||||
return nil, fmt.Errorf("connexion MQTT: %w", token.Error())
|
||||
}
|
||||
|
||||
return mgr, nil
|
||||
}
|
||||
|
||||
func (m *Manager) onMessage(_ paho.Client, msg paho.Message) {
|
||||
if m.handler == nil {
|
||||
return
|
||||
}
|
||||
m.handler(msg.Topic(), msg.Payload(), msg.Qos(), msg.Retained())
|
||||
}
|
||||
|
||||
func (m *Manager) Publish(topic string, payload []byte, qos byte, retained bool) error {
|
||||
if m.client == nil {
|
||||
return fmt.Errorf("client MQTT indisponible")
|
||||
}
|
||||
token := m.client.Publish(topic, qos, retained, payload)
|
||||
token.Wait()
|
||||
return token.Error()
|
||||
}
|
||||
|
||||
func (m *Manager) Disconnect() {
|
||||
if m.client != nil && m.client.IsConnected() {
|
||||
m.client.Disconnect(250)
|
||||
}
|
||||
}
|
||||
24
backend/internal/settings/runtime.go
Normal file
24
backend/internal/settings/runtime.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package settings
|
||||
|
||||
import "sync"
|
||||
|
||||
type Runtime struct {
|
||||
mu sync.RWMutex
|
||||
current Settings
|
||||
}
|
||||
|
||||
func NewRuntime(initial Settings) *Runtime {
|
||||
return &Runtime{current: initial}
|
||||
}
|
||||
|
||||
func (r *Runtime) Get() Settings {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.current
|
||||
}
|
||||
|
||||
func (r *Runtime) Update(next Settings) {
|
||||
r.mu.Lock()
|
||||
r.current = next
|
||||
r.mu.Unlock()
|
||||
}
|
||||
148
backend/internal/settings/store.go
Normal file
148
backend/internal/settings/store.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
}
|
||||
|
||||
type Settings struct {
|
||||
Theme string `yaml:"theme" json:"theme"`
|
||||
RepoURL string `yaml:"repoUrl" json:"repoUrl"`
|
||||
TTLDays int `yaml:"ttlDays" json:"ttlDays"`
|
||||
MaxPayloadBytes int `yaml:"maxPayloadBytes" json:"maxPayloadBytes"`
|
||||
AutoPurgePayloads bool `yaml:"autoPurgePayloads" json:"autoPurgePayloads"`
|
||||
AutoPurgePayloadBytes int `yaml:"autoPurgePayloadBytes" json:"autoPurgePayloadBytes"`
|
||||
AutoExpandDepth int `yaml:"autoExpandDepth" json:"autoExpandDepth"`
|
||||
ImageDetection bool `yaml:"imageDetectionEnabled" json:"imageDetectionEnabled"`
|
||||
HighlightMs int `yaml:"highlightMs" json:"highlightMs"`
|
||||
MQTTProfiles []MQTTProfile `yaml:"mqttProfiles" json:"mqttProfiles"`
|
||||
ActiveProfileID string `yaml:"activeProfileId" json:"activeProfileId"`
|
||||
ApplyViewFilter bool `yaml:"applyViewFilter" json:"applyViewFilter"`
|
||||
ExpandTreeOnStart bool `yaml:"expandTreeOnStart" json:"expandTreeOnStart"`
|
||||
TopicFilters []TopicFilter `yaml:"topicFilters" json:"topicFilters"`
|
||||
UIFontSize int `yaml:"uiFontSize" json:"uiFontSize"`
|
||||
TopicFontSize int `yaml:"topicFontSize" json:"topicFontSize"`
|
||||
PayloadFontSize int `yaml:"payloadFontSize" json:"payloadFontSize"`
|
||||
StatsRefreshMs int `yaml:"statsRefreshMs" json:"statsRefreshMs"`
|
||||
ResizeHandlePx int `yaml:"resizeHandlePx" json:"resizeHandlePx"`
|
||||
}
|
||||
|
||||
type MQTTProfile struct {
|
||||
ID string `yaml:"id" json:"id"`
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Host string `yaml:"host" json:"host"`
|
||||
Port int `yaml:"port" json:"port"`
|
||||
Username string `yaml:"username" json:"username"`
|
||||
Password string `yaml:"password" json:"password"`
|
||||
IsDefault bool `yaml:"isDefault" json:"isDefault"`
|
||||
}
|
||||
|
||||
type TopicFilter struct {
|
||||
Topic string `yaml:"topic" json:"topic"`
|
||||
Save bool `yaml:"save" json:"save"`
|
||||
View bool `yaml:"view" json:"view"`
|
||||
}
|
||||
|
||||
func NewStore(path string) *Store {
|
||||
return &Store{path: path}
|
||||
}
|
||||
|
||||
func (s *Store) Load(defaults Settings) (Settings, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
content, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return defaults, nil
|
||||
}
|
||||
return defaults, fmt.Errorf("lecture settings: %w", err)
|
||||
}
|
||||
|
||||
var loaded Settings
|
||||
if err := yaml.Unmarshal(content, &loaded); err != nil {
|
||||
return defaults, fmt.Errorf("yaml settings: %w", err)
|
||||
}
|
||||
|
||||
return merge(defaults, loaded), nil
|
||||
}
|
||||
|
||||
func (s *Store) Save(next Settings) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
dir := filepath.Dir(s.path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("mkdir settings: %w", err)
|
||||
}
|
||||
|
||||
content, err := yaml.Marshal(next)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal settings: %w", err)
|
||||
}
|
||||
|
||||
return os.WriteFile(s.path, content, 0o644)
|
||||
}
|
||||
|
||||
func merge(base, override Settings) Settings {
|
||||
merged := base
|
||||
if override.Theme != "" {
|
||||
merged.Theme = override.Theme
|
||||
}
|
||||
if override.RepoURL != "" {
|
||||
merged.RepoURL = override.RepoURL
|
||||
}
|
||||
if override.TTLDays != 0 {
|
||||
merged.TTLDays = override.TTLDays
|
||||
}
|
||||
if override.MaxPayloadBytes != 0 {
|
||||
merged.MaxPayloadBytes = override.MaxPayloadBytes
|
||||
}
|
||||
merged.AutoPurgePayloads = override.AutoPurgePayloads
|
||||
if override.AutoPurgePayloadBytes != 0 {
|
||||
merged.AutoPurgePayloadBytes = override.AutoPurgePayloadBytes
|
||||
}
|
||||
if override.AutoExpandDepth != 0 {
|
||||
merged.AutoExpandDepth = override.AutoExpandDepth
|
||||
}
|
||||
merged.ImageDetection = override.ImageDetection
|
||||
if override.HighlightMs != 0 {
|
||||
merged.HighlightMs = override.HighlightMs
|
||||
}
|
||||
if len(override.MQTTProfiles) > 0 {
|
||||
merged.MQTTProfiles = override.MQTTProfiles
|
||||
}
|
||||
if override.ActiveProfileID != "" {
|
||||
merged.ActiveProfileID = override.ActiveProfileID
|
||||
}
|
||||
merged.ApplyViewFilter = override.ApplyViewFilter
|
||||
merged.ExpandTreeOnStart = override.ExpandTreeOnStart
|
||||
if len(override.TopicFilters) > 0 {
|
||||
merged.TopicFilters = override.TopicFilters
|
||||
}
|
||||
if override.UIFontSize != 0 {
|
||||
merged.UIFontSize = override.UIFontSize
|
||||
}
|
||||
if override.TopicFontSize != 0 {
|
||||
merged.TopicFontSize = override.TopicFontSize
|
||||
}
|
||||
if override.PayloadFontSize != 0 {
|
||||
merged.PayloadFontSize = override.PayloadFontSize
|
||||
}
|
||||
if override.StatsRefreshMs != 0 {
|
||||
merged.StatsRefreshMs = override.StatsRefreshMs
|
||||
}
|
||||
if override.ResizeHandlePx != 0 {
|
||||
merged.ResizeHandlePx = override.ResizeHandlePx
|
||||
}
|
||||
return merged
|
||||
}
|
||||
326
backend/internal/storage/storage.go
Normal file
326
backend/internal/storage/storage.go
Normal file
@@ -0,0 +1,326 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
path string
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
ID string
|
||||
Topic string
|
||||
Payload string
|
||||
QOS byte
|
||||
Retained bool
|
||||
Timestamp time.Time
|
||||
Size int
|
||||
}
|
||||
|
||||
type Stats struct {
|
||||
Count int64 `json:"count"`
|
||||
Size string `json:"size"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
}
|
||||
|
||||
func Open(path string) (*Store, error) {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("creation dossier sqlite: %w", err)
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ouverture sqlite: %w", err)
|
||||
}
|
||||
|
||||
if _, err := db.Exec("PRAGMA journal_mode=WAL;"); err != nil {
|
||||
return nil, fmt.Errorf("pragma WAL: %w", err)
|
||||
}
|
||||
if _, err := db.Exec("PRAGMA synchronous=NORMAL;"); err != nil {
|
||||
return nil, fmt.Errorf("pragma synchronous: %w", err)
|
||||
}
|
||||
|
||||
store := &Store{db: db, path: path}
|
||||
if err := store.initSchema(); err != nil {
|
||||
if isCorruptErr(err) {
|
||||
_ = db.Close()
|
||||
return recoverCorruptDB(path)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if err := store.integrityCheck(); err != nil {
|
||||
if isCorruptErr(err) {
|
||||
_ = db.Close()
|
||||
return recoverCorruptDB(path)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func (s *Store) initSchema() error {
|
||||
schema := `
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
topic TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
qos INTEGER NOT NULL,
|
||||
retained INTEGER NOT NULL,
|
||||
ts TEXT NOT NULL,
|
||||
size INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_topic_ts ON messages(topic, ts);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_ts ON messages(ts);
|
||||
`
|
||||
_, err := s.db.Exec(schema)
|
||||
if err != nil {
|
||||
return fmt.Errorf("schema sqlite: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) integrityCheck() error {
|
||||
var result string
|
||||
if err := s.db.QueryRow("PRAGMA integrity_check;").Scan(&result); err != nil {
|
||||
return fmt.Errorf("integrity check: %w", err)
|
||||
}
|
||||
if strings.ToLower(result) != "ok" {
|
||||
return fmt.Errorf("integrity check: %s", result)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func recoverCorruptDB(path string) (*Store, error) {
|
||||
suffix := time.Now().UTC().Format("20060102-150405")
|
||||
backupWithSuffix(path, suffix)
|
||||
backupWithSuffix(path+"-wal", suffix)
|
||||
backupWithSuffix(path+"-shm", suffix)
|
||||
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ouverture sqlite apres recovery: %w", err)
|
||||
}
|
||||
if _, err := db.Exec("PRAGMA journal_mode=WAL;"); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("pragma WAL: %w", err)
|
||||
}
|
||||
if _, err := db.Exec("PRAGMA synchronous=NORMAL;"); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("pragma synchronous: %w", err)
|
||||
}
|
||||
store := &Store{db: db, path: path}
|
||||
if err := store.initSchema(); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func backupWithSuffix(path, suffix string) {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.Rename(path, fmt.Sprintf("%s.corrupt-%s", path, suffix))
|
||||
}
|
||||
|
||||
func isCorruptErr(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "database disk image is malformed") ||
|
||||
strings.Contains(msg, "malformed") ||
|
||||
strings.Contains(msg, "btreeinitpage") ||
|
||||
strings.Contains(msg, "error code 11")
|
||||
}
|
||||
|
||||
func (s *Store) InsertMessage(msg Message) error {
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO messages(id, topic, payload, qos, retained, ts, size) VALUES(?,?,?,?,?,?,?)`,
|
||||
msg.ID,
|
||||
msg.Topic,
|
||||
msg.Payload,
|
||||
int(msg.QOS),
|
||||
boolToInt(msg.Retained),
|
||||
msg.Timestamp.UTC().Format(time.RFC3339Nano),
|
||||
msg.Size,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert message: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) GetHistory(topic string, limit int, from, to string) ([]Message, error) {
|
||||
args := []any{topic}
|
||||
query := "SELECT id, topic, payload, qos, retained, ts, size FROM messages WHERE topic = ?"
|
||||
if from != "" {
|
||||
query += " AND ts >= ?"
|
||||
args = append(args, from)
|
||||
}
|
||||
if to != "" {
|
||||
query += " AND ts <= ?"
|
||||
args = append(args, to)
|
||||
}
|
||||
query += " ORDER BY ts DESC"
|
||||
if limit > 0 {
|
||||
query += " LIMIT ?"
|
||||
args = append(args, limit)
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lecture historique: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Message
|
||||
for rows.Next() {
|
||||
var msg Message
|
||||
var retained int
|
||||
var ts string
|
||||
if err := rows.Scan(&msg.ID, &msg.Topic, &msg.Payload, &msg.QOS, &retained, &ts, &msg.Size); err != nil {
|
||||
return nil, fmt.Errorf("scan historique: %w", err)
|
||||
}
|
||||
msg.Retained = retained == 1
|
||||
parsed, _ := time.Parse(time.RFC3339Nano, ts)
|
||||
msg.Timestamp = parsed
|
||||
out = append(out, msg)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) ClearTopicHistory(topic string) (int64, error) {
|
||||
res, err := s.db.Exec("DELETE FROM messages WHERE topic = ?", topic)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("suppression topic: %w", err)
|
||||
}
|
||||
affected, _ := res.RowsAffected()
|
||||
return affected, nil
|
||||
}
|
||||
|
||||
func (s *Store) ClearAllHistory() (int64, error) {
|
||||
res, err := s.db.Exec("DELETE FROM messages")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("suppression db: %w", err)
|
||||
}
|
||||
affected, _ := res.RowsAffected()
|
||||
if err := s.Compact(); err != nil {
|
||||
return affected, err
|
||||
}
|
||||
return affected, nil
|
||||
}
|
||||
|
||||
func (s *Store) PurgeOversize(maxBytes int) (int64, error) {
|
||||
res, err := s.db.Exec("DELETE FROM messages WHERE size >= ?", maxBytes)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("purge oversize: %w", err)
|
||||
}
|
||||
affected, _ := res.RowsAffected()
|
||||
return affected, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteOldestFraction(fraction float64) (int64, error) {
|
||||
if fraction <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
var count int64
|
||||
if err := s.db.QueryRow("SELECT COUNT(*) FROM messages").Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("count messages: %w", err)
|
||||
}
|
||||
limit := int64(math.Round(float64(count) * fraction))
|
||||
if limit <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
res, err := s.db.Exec("DELETE FROM messages WHERE id IN (SELECT id FROM messages ORDER BY ts ASC LIMIT ?)", limit)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("purge oldest fraction: %w", err)
|
||||
}
|
||||
affected, _ := res.RowsAffected()
|
||||
return affected, nil
|
||||
}
|
||||
|
||||
func (s *Store) PurgeBefore(cutoff time.Time) (int64, error) {
|
||||
res, err := s.db.Exec("DELETE FROM messages WHERE ts < ?", cutoff.UTC().Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("purge ttl: %w", err)
|
||||
}
|
||||
affected, _ := res.RowsAffected()
|
||||
return affected, nil
|
||||
}
|
||||
|
||||
func (s *Store) Stats() (Stats, error) {
|
||||
var count int64
|
||||
if err := s.db.QueryRow("SELECT COUNT(*) FROM messages").Scan(&count); err != nil {
|
||||
return Stats{}, fmt.Errorf("stats count: %w", err)
|
||||
}
|
||||
sizeBytes := s.totalSize()
|
||||
return Stats{
|
||||
Count: count,
|
||||
Size: formatBytes(sizeBytes),
|
||||
Bytes: sizeBytes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) totalSize() int64 {
|
||||
if s.path == "" {
|
||||
return 0
|
||||
}
|
||||
total := fileSize(s.path)
|
||||
total += fileSize(s.path + "-wal")
|
||||
total += fileSize(s.path + "-shm")
|
||||
return total
|
||||
}
|
||||
|
||||
func (s *Store) Compact() error {
|
||||
if _, err := s.db.Exec("PRAGMA wal_checkpoint(TRUNCATE);"); err != nil {
|
||||
return fmt.Errorf("checkpoint wal: %w", err)
|
||||
}
|
||||
if _, err := s.db.Exec("VACUUM;"); err != nil {
|
||||
return fmt.Errorf("vacuum: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fileSize(path string) int64 {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return info.Size()
|
||||
}
|
||||
|
||||
func formatBytes(size int64) string {
|
||||
if size < 1024 {
|
||||
return fmt.Sprintf("%d B", size)
|
||||
}
|
||||
kb := float64(size) / 1024
|
||||
if kb < 1024 {
|
||||
return fmt.Sprintf("%.1f KB", kb)
|
||||
}
|
||||
mb := kb / 1024
|
||||
if mb < 1024 {
|
||||
return fmt.Sprintf("%.1f MB", mb)
|
||||
}
|
||||
gb := mb / 1024
|
||||
return fmt.Sprintf("%.2f GB", gb)
|
||||
}
|
||||
|
||||
func boolToInt(val bool) int {
|
||||
if val {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
55
backend/internal/sysinfo/sysinfo.go
Normal file
55
backend/internal/sysinfo/sysinfo.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package sysinfo
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Snapshot struct {
|
||||
Version string `json:"version"`
|
||||
Clients string `json:"clients"`
|
||||
MsgReceived string `json:"msgReceived"`
|
||||
MsgSent string `json:"msgSent"`
|
||||
MsgStored string `json:"msgStored"`
|
||||
Subscriptions string `json:"subscriptions"`
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
data Snapshot
|
||||
}
|
||||
|
||||
func NewStore() *Store {
|
||||
return &Store{}
|
||||
}
|
||||
|
||||
func (s *Store) Update(topic string, payload string) {
|
||||
trimmed := strings.TrimSpace(payload)
|
||||
if trimmed == "" {
|
||||
return
|
||||
}
|
||||
key := strings.TrimPrefix(topic, "$SYS/broker/")
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
switch key {
|
||||
case "version":
|
||||
s.data.Version = trimmed
|
||||
case "clients/connected":
|
||||
s.data.Clients = trimmed
|
||||
case "messages/received":
|
||||
s.data.MsgReceived = trimmed
|
||||
case "messages/sent":
|
||||
s.data.MsgSent = trimmed
|
||||
case "messages/stored":
|
||||
s.data.MsgStored = trimmed
|
||||
case "subscriptions/count":
|
||||
s.data.Subscriptions = trimmed
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) Snapshot() Snapshot {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.data
|
||||
}
|
||||
109
backend/internal/topics/tree.go
Normal file
109
backend/internal/topics/tree.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package topics
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
ID string `json:"id"`
|
||||
Topic string `json:"topic"`
|
||||
Payload string `json:"payload"`
|
||||
QOS byte `json:"qos"`
|
||||
Retained bool `json:"retained"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Size int `json:"size"`
|
||||
}
|
||||
|
||||
type Tree struct {
|
||||
mu sync.RWMutex
|
||||
root *node
|
||||
}
|
||||
|
||||
type node struct {
|
||||
name string
|
||||
fullName string
|
||||
children map[string]*node
|
||||
messageCount int
|
||||
lastMessage *Message
|
||||
}
|
||||
|
||||
type NodeSnapshot struct {
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"fullName"`
|
||||
MessageCount int `json:"messageCount"`
|
||||
LastMessage *Message `json:"lastMessage,omitempty"`
|
||||
Children []NodeSnapshot `json:"children"`
|
||||
}
|
||||
|
||||
func NewTree() *Tree {
|
||||
return &Tree{
|
||||
root: &node{
|
||||
name: "root",
|
||||
fullName: "",
|
||||
children: make(map[string]*node),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tree) AddMessage(msg Message) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
parts := strings.Split(msg.Topic, "/")
|
||||
current := t.root
|
||||
for i, part := range parts {
|
||||
child, ok := current.children[part]
|
||||
if !ok {
|
||||
full := strings.Join(parts[:i+1], "/")
|
||||
child = &node{
|
||||
name: part,
|
||||
fullName: full,
|
||||
children: make(map[string]*node),
|
||||
}
|
||||
current.children[part] = child
|
||||
}
|
||||
current = child
|
||||
current.messageCount++
|
||||
if i == len(parts)-1 {
|
||||
copyMsg := msg
|
||||
current.lastMessage = ©Msg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tree) Snapshot() NodeSnapshot {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
|
||||
return snapshotNode(t.root)
|
||||
}
|
||||
|
||||
func snapshotNode(n *node) NodeSnapshot {
|
||||
children := make([]NodeSnapshot, 0, len(n.children))
|
||||
keys := make([]string, 0, len(n.children))
|
||||
for key := range n.children {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
child := n.children[key]
|
||||
children = append(children, snapshotNode(child))
|
||||
}
|
||||
|
||||
var last *Message
|
||||
if n.lastMessage != nil {
|
||||
copyMsg := *n.lastMessage
|
||||
last = ©Msg
|
||||
}
|
||||
|
||||
return NodeSnapshot{
|
||||
Name: n.name,
|
||||
FullName: n.fullName,
|
||||
MessageCount: n.messageCount,
|
||||
LastMessage: last,
|
||||
Children: children,
|
||||
}
|
||||
}
|
||||
47
backend/internal/ws/hub.go
Normal file
47
backend/internal/ws/hub.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
clients map[*websocket.Conn]struct{}
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
Type string `json:"type"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
func NewHub() *Hub {
|
||||
return &Hub{clients: make(map[*websocket.Conn]struct{})}
|
||||
}
|
||||
|
||||
func (h *Hub) Add(conn *websocket.Conn) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.clients[conn] = struct{}{}
|
||||
}
|
||||
|
||||
func (h *Hub) Remove(conn *websocket.Conn) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
delete(h.clients, conn)
|
||||
}
|
||||
|
||||
func (h *Hub) Broadcast(event Event) {
|
||||
payload, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
for conn := range h.clients {
|
||||
_ = conn.WriteMessage(websocket.TextMessage, payload)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user