first
This commit is contained in:
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