This commit is contained in:
Gilles Soulier
2025-12-24 14:47:39 +01:00
parent 4590c120fb
commit 383ad292d3
52 changed files with 4694 additions and 1 deletions

View 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
}
}
}

View 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
}
}

View 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
}

View 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)
}

View 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)
}
}

View 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()
}

View 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
}

View 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
}

View 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
}

View 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 = &copyMsg
}
}
}
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 = &copyMsg
}
return NodeSnapshot{
Name: n.name,
FullName: n.fullName,
MessageCount: n.messageCount,
LastMessage: last,
Children: children,
}
}

View 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)
}
}