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

24
.gitignore vendored Executable file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

29
Dockerfile Normal file
View File

@@ -0,0 +1,29 @@
# Build frontend
FROM node:20-alpine AS frontend
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
COPY frontend ./
RUN npm run build
# Build backend
FROM golang:1.23-alpine AS backend
WORKDIR /app/backend
RUN apk add --no-cache git
COPY backend/go.mod ./
COPY backend/go.sum ./
RUN go mod download
COPY backend ./
RUN CGO_ENABLED=0 go build -o /app/bin/server ./cmd/server
# Final image
FROM alpine:3.20
RUN adduser -D -H -u 10001 app && apk add --no-cache ca-certificates \
&& mkdir -p /data /app \
&& chown -R app:app /data /app
WORKDIR /app
COPY --from=backend /app/bin/server /app/server
COPY --from=frontend /app/frontend/dist /app/frontend/dist
EXPOSE 8088
ENV PORT=8088
CMD ["/app/server"]

47
README.md Normal file → Executable file
View File

@@ -1,2 +1,47 @@
# mqtt_explorer
# MQTT Web Explorer - Monokai Pro
Interface web moderne pour l'exploration de brokers MQTT avec backend persistant en Go.
## Caractéristiques
- **Backend Go**: Connexion continue même quand l'onglet est fermé.
- **SQLite**: Historique complet des messages stocké localement.
- **UI Monokai**: Thème sombre haute fidélité pour les développeurs.
- **Explorateur CLI**: Arbre hiérarchique intelligent avec filtres regex.
- **Payload Intelligent**: Détection automatique de JSON et Images Base64.
- **Responsive**: Compatible Desktop, Tablette et Mobile.
## Lancement rapide
### Via Docker (Recommandé)
```bash
docker compose up -d --build
```
L'application sera disponible sur `http://localhost:8088`.
### Développement local (Backend)
```bash
cd backend
go run ./cmd/server
```
### Développement local (Frontend)
```bash
cd frontend
npm install
npm run dev
```
## Structure du projet
- `backend/`: Serveur Go (Gin + Paho MQTT + SQLite).
- `frontend/`: Client React (Tailwind + Lucide + Vite).
- `doc/`: Analyses détaillées et documentation technique.
- `docker-compose.yml`: Orchestration des services (Mosquitto + App).
## Configuration
Les variables d'environnement suivantes peuvent être configurées :
- `MQTT_BROKER`: URL du broker (default: tcp://broker.hivemq.com:1883)
- `SQLITE_DB`: Chemin de la DB (default: ./data/mqtt.db)
- `TTL_DAYS`: Durée de conservation des messages (default: 7)
- `MQTT_SUBSCRIBE`: Topic de souscription par défaut (default: #)
- `MQTT_QOS`: QoS par défaut (default: 0)

243
backend/cmd/server/main.go Normal file
View 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
View 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
View 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=

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

BIN
data/mqtt.db Normal file

Binary file not shown.

BIN
data/mqtt.db-shm Normal file

Binary file not shown.

BIN
data/mqtt.db-wal Normal file

Binary file not shown.

36
data/settings.yml Normal file
View File

@@ -0,0 +1,36 @@
theme: dark-monokai
repoUrl: https://gitea.maison43.duckdns.org/gilles/mqtt_explorer
ttlDays: 7
maxPayloadBytes: 102400
autoPurgePayloads: false
autoPurgePayloadBytes: 256000
autoExpandDepth: 2
imageDetectionEnabled: false
highlightMs: 1000
mqttProfiles:
- id: default
name: 10.0.0.3
host: 10.0.0.3
port: 1883
username: ""
password: ""
isDefault: true
- id: profile-1766565436272
name: test
host: 10.0.0.50
port: 1883
username: ""
password: ""
isDefault: false
activeProfileId: default
applyViewFilter: false
expandTreeOnStart: true
topicFilters:
- topic: $SYS
save: true
view: true
uiFontSize: 12
topicFontSize: 12
payloadFontSize: 12
statsRefreshMs: 1000
resizeHandlePx: 4

56
doc/analyse/architecture.md Executable file
View File

@@ -0,0 +1,56 @@
# Analyse Technique - MQTT Web Explorer
## 1. Vision Globale
L'objectif est de créer un outil de monitoring MQTT robuste capable de maintenir la continuité des données (via un backend Go persistant) tout en offrant une expérience utilisateur fluide et élégante (Inspiré de MQTT Explorer, thème Monokai).
## 2. Dessin ASCII de l'Interface (Desktop)
```text
+----------------------------------------------------------------------------------+
| [M] MQTT EXPLORER | Profil: Production v1 | Status: [ CONNECTED ] [Settings]|
+----------------------------------------------------------------------------------+
| RECHERCHE: [ sensors/.* ] [X] Retained [X] JSON [X] Active | TOPIC SELECTIONNÉ|
+----------------------------------------------------------------------------------+
| EXPLORATEUR (Arbre CLI) | DÉTAILS DU MESSAGE (sensors/kitchen/temp) |
| ▾ sensors | ------------------------------------------ |
| ▾ kitchen | [ RAW ] [ PRETTY ] [ DIFF ] [ GRAPH ] |
| ▸ light | ------------------------------------------ |
| ▸ temp (3 msgs/s) | { |
| ▾ livingroom | "value": 21.4, |
| ▸ door | "unit": "°C", |
| ▾ devices | "timestamp": "2023-10-27T10:00:00Z" |
| ▸ bridge-01 | } |
| | ------------------------------------------ |
| | IMAGES DÉTECTÉES: [ Image Preview ] |
+-----------------------------------+- ------------------------------------------ |
| [ GRAPH DOCK: | \/\/\/\/\ | Value: 21.4 | Max: 22.0 | Min: 20.1 ] |
+----------------------------------------------------------------------------------+
```
## 3. Architecture Logicielle
### Backend (Go + Gin)
- **MQTT Manager**: Service singleton gérant les connexions Paho MQTT.
- **Persistence Layer**: SQLite pour le stockage des messages (Index sur Topic+Timestamp).
- **TTL Manager**: Routine de purge automatique (Nettoyage des messages > N jours).
- **WebSocket Hub**: Streaming en temps réel vers le frontend React.
- **API REST**: Configuration des profils, tests réseau, historique paginé.
### Frontend (React + TypeScript)
- **State Management**: Context API / Hooks pour l'état de l'arbre.
- **Rendering**: Composant d'arbre récursif optimisé "CLI style".
- **Theming**: Système de variables CSS (Monokai Pro).
- **Charts**: Recharts pour la visualisation des séries temporelles.
## 4. Plan de Développement
1. **Phase 1: Backend Core** (Go, SQLite, MQTT loop).
2. **Phase 2: WebSocket & API** (Hub de messages, endpoints profils).
3. **Phase 3: UI Layout** (Shell responsive, Thème Monokai).
4. **Phase 4: Explorateur de Topics** (Arbre récursif, filtres).
5. **Phase 5: Payload & Historique** (Pretty JSON, Diff, SQLite Fetch).
6. **Phase 6: Fonctionnalités Avancées** (Images, Graphes, Dockerisation).
## 5. Icônes & Ressources
- **Icônes**: Utilisation de Lucide-React (Antenna, Share2, Search, Settings, Trash, Image, LineChart).
- **Polices**: JetBrains Mono pour les données techniques, Inter pour l'UI.

31
docker-compose.yml Executable file
View File

@@ -0,0 +1,31 @@
services:
mqtt-explorer:
build: .
container_name: mqtt-web-explorer
ports:
- "8088:8088"
environment:
- MQTT_BROKER=tcp://10.0.0.3:1883
- PORT=8088
- SQLITE_DB=/data/mqtt.db
- MQTT_DEBUG=false
- SETTINGS_FILE=/data/settings.yml
- MQTT_SYS_SUBSCRIBE=$$SYS/#
depends_on:
- mosquitto
volumes:
- ./data:/data
restart: unless-stopped
mosquitto:
image: eclipse-mosquitto:latest
container_name: mosquitto-test
ports:
- "1883:1883"
- "9001:9001"
volumes:
- ./docker/mosquitto/mosquitto.conf:/mosquitto/config/mosquitto.conf
restart: unless-stopped
volumes: {}

View File

@@ -0,0 +1,8 @@
listener 1883
allow_anonymous true
persistence true
persistence_location /mosquitto/data/
sys_interval 10
listener 9001
protocol websockets

21
frontend/index.html Executable file
View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MQTT Web Explorer</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link id="theme-css" rel="stylesheet" href="/themes/theme-dark-monokai.css">
<link rel="icon" type="image/svg+xml" href="/favicon/favicon.svg">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon/favicon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon/favicon-16.png">
<link rel="apple-touch-icon" href="/favicon/apple-touch-icon.png">
<link rel="manifest" href="/site.webmanifest">
</head>
<body class="bg-[#272822] text-[#f8f8f2]">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
<script src="https://cdn.tailwindcss.com"></script>

8
frontend/metadata.json Executable file
View File

@@ -0,0 +1,8 @@
{
"name": "MQTT Web Explorer - Monokai Pro",
"description": "Explorateur MQTT avancé avec backend Go persistant, historique SQLite et interface responsive thème Monokai.",
"requestFramePermissions": [
"notifications"
]
}

23
frontend/package.json Executable file
View File

@@ -0,0 +1,23 @@
{
"name": "mqtt-web-explorer---monokai-pro",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.3",
"react-dom": "^19.2.3",
"lucide-react": "^0.562.0",
"uplot": "^1.6.30"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<rect width="128" height="128" rx="24" fill="#272822"/>
<circle cx="64" cy="64" r="8" fill="#a6e22e"/>
<path d="M24 64c0-22.1 17.9-40 40-40" fill="none" stroke="#66d9ef" stroke-width="8" stroke-linecap="round"/>
<path d="M104 64c0-22.1-17.9-40-40-40" fill="none" stroke="#f92672" stroke-width="8" stroke-linecap="round"/>
<path d="M24 64c0 22.1 17.9 40 40 40" fill="none" stroke="#e6db74" stroke-width="8" stroke-linecap="round"/>
<path d="M104 64c0 22.1-17.9 40-40 40" fill="none" stroke="#ae81ff" stroke-width="8" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 623 B

View File

@@ -0,0 +1,12 @@
{
"name": "MQTT Web Explorer",
"short_name": "MQTT Explorer",
"icons": [
{"src": "/favicon/favicon-16.png", "sizes": "16x16", "type": "image/png"},
{"src": "/favicon/favicon-32.png", "sizes": "32x32", "type": "image/png"},
{"src": "/favicon/apple-touch-icon.png", "sizes": "180x180", "type": "image/png"}
],
"theme_color": "#272822",
"background_color": "#272822",
"display": "standalone"
}

View File

@@ -0,0 +1,25 @@
:root {
--bg-main: #272822;
--bg-panel: #1e1f1c;
--bg-code: #141411;
--border: #49483e;
--text-main: #f8f8f2;
--text-secondary: #e0e0d8;
--text-muted: #75715e;
--accent-blue: #66d9ef;
--accent-green: #a6e22e;
--accent-yellow: #e6db74;
--accent-orange: #fd971f;
--accent-red: #f92672;
--accent-purple: #ae81ff;
--json-key: #66d9ef;
--json-string: #e6db74;
--json-number: #a6e22e;
--json-boolean: #ae81ff;
--json-null: #f92672;
--tree-guide: #49483e;
--selected-bg: #3e3d32;
--hover-bg: #34342f;
--focus-ring: #66d9ef;
--icon: #f8f8f2;
}

View File

@@ -0,0 +1,25 @@
:root {
--bg-main: #f8f9fa;
--bg-panel: #ffffff;
--bg-code: #e9ecef;
--border: #dee2e6;
--text-main: #212529;
--text-secondary: #343a40;
--text-muted: #6c757d;
--accent-blue: #007bff;
--accent-green: #28a745;
--accent-yellow: #ffc107;
--accent-orange: #fd7e14;
--accent-red: #dc3545;
--accent-purple: #6f42c1;
--json-key: #007bff;
--json-string: #ffc107;
--json-number: #28a745;
--json-boolean: #6f42c1;
--json-null: #dc3545;
--tree-guide: #dee2e6;
--selected-bg: #e9ecef;
--hover-bg: #f1f3f5;
--focus-ring: #007bff;
--icon: #212529;
}

View File

@@ -0,0 +1,137 @@
import React, { useEffect, useRef } from 'react';
import uPlot from 'uplot';
import 'uplot/dist/uPlot.min.css';
import { Database, LineChart, Radio } from 'lucide-react';
interface ChartsDockProps {
topic: string | null;
series: { times: number[]; values: number[] } | null;
fields: { path: string; label: string }[];
selectedField: string;
onFieldChange: (path: string) => void;
source: 'live' | 'db';
onSourceChange: (source: 'live' | 'db') => void;
}
export const ChartsDock: React.FC<ChartsDockProps> = ({
topic,
series,
fields,
selectedField,
onFieldChange,
source,
onSourceChange
}) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const plotRef = useRef<uPlot | null>(null);
const labelRef = useRef<string>('value');
const effectiveField = selectedField || (fields.length === 1 ? fields[0].path : '');
const seriesLabel = effectiveField
? fields.find((field) => field.path === effectiveField)?.label || 'value'
: 'value';
useEffect(() => {
if (!containerRef.current) {
return;
}
if (!series || !effectiveField) {
if (plotRef.current) {
plotRef.current.destroy();
plotRef.current = null;
}
return;
}
const opts: uPlot.Options = {
width: containerRef.current.clientWidth,
height: 120,
scales: { x: { time: true } },
series: [
{},
{ label: seriesLabel, stroke: '#66d9ef', width: 2 }
]
};
const data: uPlot.AlignedData = [series.times, series.values];
if (plotRef.current) {
if (labelRef.current !== seriesLabel) {
plotRef.current.destroy();
plotRef.current = null;
} else {
plotRef.current.setData(data);
return;
}
}
if (!plotRef.current) {
labelRef.current = seriesLabel;
plotRef.current = new uPlot(opts, data, containerRef.current);
}
return () => {
plotRef.current?.destroy();
plotRef.current = null;
};
}, [series, effectiveField, seriesLabel]);
useEffect(() => {
const resize = () => {
if (plotRef.current && containerRef.current) {
plotRef.current.setSize({ width: containerRef.current.clientWidth, height: 120 });
}
};
window.addEventListener('resize', resize);
return () => window.removeEventListener('resize', resize);
}, []);
return (
<div className="border-t border-[color:var(--border)] bg-[color:var(--bg-panel)] px-4 py-2">
<div className="flex items-center justify-between text-[10px] opacity-70 mb-2">
<div className="flex items-center gap-2">
<LineChart size={12} />
<span>Graph Dock</span>
<div className="flex items-center gap-1 ml-2">
<button
type="button"
className={`p-1 rounded border text-[10px] ${source === 'db' ? 'border-[color:var(--accent-green)] text-[color:var(--accent-green)]' : 'border-[color:var(--border)] opacity-60'}`}
title="Historique (SQLite)"
onClick={() => onSourceChange('db')}
>
<Database size={12} />
</button>
<button
type="button"
className={`p-1 rounded border text-[10px] ${source === 'live' ? 'border-[color:var(--accent-green)] text-[color:var(--accent-green)]' : 'border-[color:var(--border)] opacity-60'}`}
title="Flux en direct"
onClick={() => onSourceChange('live')}
>
<Radio size={12} />
</button>
</div>
</div>
<div className="flex items-center gap-2">
{fields.length > 0 && (
<select
className="bg-[color:var(--bg-main)] border border-[color:var(--border)] rounded px-1 py-0.5 text-[10px]"
value={selectedField}
onChange={(event) => onFieldChange(event.target.value)}
>
<option value="">Auto</option>
{fields.map((field) => (
<option key={field.path} value={field.path}>
{field.label}
</option>
))}
</select>
)}
<div className="truncate max-w-[40%]">{topic || 'Aucun topic sélectionné'}</div>
</div>
</div>
{series && effectiveField ? (
<div ref={containerRef} className="w-full" />
) : (
<div className="text-[10px] italic opacity-60">Aucune série numérique disponible.</div>
)}
</div>
);
};

View File

@@ -0,0 +1,29 @@
import React from 'react';
type GiteaIconProps = {
size?: number;
className?: string;
};
export const GiteaIcon: React.FC<GiteaIconProps> = ({ size = 20, className }) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
focusable="false"
>
<path d="M7 6h8l-1 9H8L7 6z" />
<path d="M15 7h2a2 2 0 0 1 0 4h-1" />
<path d="M9 5V3M12 5V3M15 5V3" />
<circle cx="6" cy="18" r="1" />
<circle cx="12" cy="19" r="1" />
<circle cx="18" cy="18" r="1" />
</svg>
);

View File

@@ -0,0 +1,184 @@
import React, { useEffect, useState } from 'react';
import { Copy, Send } from 'lucide-react';
import { publishMessage } from '../utils/api';
interface PublishPanelProps {
draft: { topic: string; payload: string };
onDraftChange: (draft: { topic: string; payload: string }) => void;
onPasteTopic: () => void;
onPastePayload: () => void;
}
export const PublishPanel: React.FC<PublishPanelProps> = ({ draft, onDraftChange, onPasteTopic, onPastePayload }) => {
const [topic, setTopic] = useState(draft.topic);
const [payload, setPayload] = useState(draft.payload);
const [qos, setQos] = useState(0);
const [retained, setRetained] = useState(false);
const [status, setStatus] = useState<string | null>(null);
useEffect(() => {
setTopic(draft.topic);
setPayload(draft.payload);
}, [draft.payload, draft.topic]);
const handlePublish = async () => {
if (!topic) {
setStatus('Topic requis.');
return;
}
try {
await publishMessage({ topic, payload, qos, retained });
setStatus('Message publié.');
} catch (err) {
setStatus('Erreur de publication.');
}
};
const copyWithFallback = async (text: string) => {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', 'true');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
const ok = document.execCommand('copy');
document.body.removeChild(textarea);
return ok;
};
const handleCopyPayload = async () => {
if (!payload) {
setStatus('Payload vide.');
return;
}
try {
const ok = await copyWithFallback(payload);
setStatus(ok ? 'Payload copié.' : 'Copie impossible.');
} catch (err) {
setStatus('Copie impossible.');
}
};
const handleCopyTopic = async () => {
if (!topic) {
setStatus('Topic vide.');
return;
}
try {
const ok = await copyWithFallback(topic);
setStatus(ok ? 'Topic copié.' : 'Copie impossible.');
} catch (err) {
setStatus('Copie impossible.');
}
};
useEffect(() => {
onDraftChange({ topic, payload });
}, [onDraftChange, payload, topic]);
return (
<div className="flex flex-col h-full bg-[color:var(--bg-main)] ui-font">
<div className="p-4 border-b border-[color:var(--border)] bg-[color:var(--bg-panel)]">
<h2 className="font-semibold">Publier un message</h2>
</div>
<div className="flex-1 overflow-auto p-4 space-y-4 custom-scrollbar">
<div>
<label className="opacity-70">Topic</label>
<div className="flex items-center gap-2">
<div className="flex flex-col gap-2">
<button
type="button"
onClick={onPasteTopic}
className="w-8 h-8 rounded border border-[color:var(--border)] flex items-center justify-center hover:border-[color:var(--accent-green)]"
title="Insérer le topic sélectionné"
>
-&gt;
</button>
</div>
<div className="relative w-full">
<input
value={topic}
onChange={(e) => setTopic(e.target.value)}
className="w-full bg-[color:var(--bg-code)] border border-[color:var(--border)] rounded px-3 py-2 pr-12"
placeholder="ex: devices/esp32/cmd"
/>
<button
type="button"
onClick={handleCopyTopic}
className="absolute top-1/2 right-3 -translate-y-1/2 p-1.5 rounded border border-[color:var(--border)] hover:border-[color:var(--accent-green)]"
title="Copier le topic"
>
<Copy size={14} />
</button>
</div>
</div>
</div>
<div>
<label className="opacity-70">Payload</label>
<div className="flex items-start gap-2">
<div className="flex flex-col gap-2">
<button
type="button"
onClick={onPastePayload}
className="w-8 h-8 rounded border border-[color:var(--border)] flex items-center justify-center hover:border-[color:var(--accent-green)]"
title="Insérer le payload sélectionné"
>
-&gt;
</button>
</div>
<div className="relative w-full">
<textarea
value={payload}
onChange={(e) => setPayload(e.target.value)}
className="w-full bg-[color:var(--bg-code)] border border-[color:var(--border)] rounded px-3 py-2 h-32 pr-12 payload-font font-mono"
placeholder='{"action":"on"}'
/>
<button
type="button"
onClick={handleCopyPayload}
className="absolute top-2 right-3 p-1.5 rounded border border-[color:var(--border)] hover:border-[color:var(--accent-green)]"
title="Copier le payload"
>
<Copy size={14} />
</button>
</div>
</div>
</div>
<div className="flex gap-4">
<div>
<label className="opacity-70">QoS</label>
<select
value={qos}
onChange={(e) => setQos(Number(e.target.value))}
className="bg-[color:var(--bg-code)] border border-[color:var(--border)] rounded px-3 py-2"
>
<option value={0}>0</option>
<option value={1}>1</option>
<option value={2}>2</option>
</select>
</div>
<label className="flex items-center gap-2 mt-6">
<input
type="checkbox"
checked={retained}
onChange={(e) => setRetained(e.target.checked)}
/>
Retained
</label>
</div>
<button
onClick={handlePublish}
className="flex items-center gap-2 px-4 py-2 rounded bg-[color:var(--accent-green)] text-black font-semibold"
>
<Send size={14} /> Publier
</button>
{status && <div className="opacity-70">{status}</div>}
</div>
</div>
);
};

View File

@@ -0,0 +1,408 @@
import React, { useState } from 'react';
import { Activity, Database, FolderTree, Link, Shield, Sliders, Wifi } from 'lucide-react';
import { testConnection } from '../utils/api';
import { AppSettings, MQTTProfile, TopicFilter } from '../types';
import { ThemeName, setTheme } from '../utils/theme';
interface SettingsPanelProps {
settings: AppSettings;
onSettingsChange: (next: AppSettings) => void;
}
const makeProfile = (): MQTTProfile => ({
id: `profile-${Date.now()}`,
name: 'Nouveau profil',
host: '10.0.0.3',
port: 1883,
username: '',
password: '',
isDefault: false
});
const makeFilter = (topic: string): TopicFilter => ({
topic,
save: false,
view: false
});
export const SettingsPanel: React.FC<SettingsPanelProps> = ({ settings, onSettingsChange }) => {
const [testStatus, setTestStatus] = useState<string | null>(null);
const [bulkTopics, setBulkTopics] = useState('');
const setProfile = (id: string, patch: Partial<MQTTProfile>) => {
const next = settings.mqttProfiles.map((profile) =>
profile.id === id ? { ...profile, ...patch } : profile
);
onSettingsChange({ ...settings, mqttProfiles: next });
};
const setDefaultProfile = (id: string) => {
const next = settings.mqttProfiles.map((profile) => ({
...profile,
isDefault: profile.id === id
}));
onSettingsChange({ ...settings, mqttProfiles: next, activeProfileId: id });
};
const addProfile = () => {
const nextProfile = makeProfile();
onSettingsChange({
...settings,
mqttProfiles: [...settings.mqttProfiles, nextProfile],
activeProfileId: nextProfile.id
});
};
const removeProfile = (id: string) => {
const remaining = settings.mqttProfiles.filter((profile) => profile.id !== id);
if (remaining.length === 0) {
const fresh = makeProfile();
fresh.isDefault = true;
onSettingsChange({ ...settings, mqttProfiles: [fresh], activeProfileId: fresh.id });
return;
}
if (!remaining.some((profile) => profile.isDefault)) {
remaining[0].isDefault = true;
}
const activeId = settings.activeProfileId === id ? remaining[0].id : settings.activeProfileId;
onSettingsChange({ ...settings, mqttProfiles: remaining, activeProfileId: activeId });
};
const handleTest = async (profile: MQTTProfile) => {
const broker = `tcp://${profile.host}:${profile.port}`;
setTestStatus(`Test ${profile.name}...`);
try {
const res = await testConnection(broker);
if (res.ok) {
setTestStatus(`${profile.name} OK (${res.latency} ms)`);
} else {
setTestStatus(`${profile.name} ${res.error || 'Echec'}`);
}
} catch (err) {
setTestStatus(`${profile.name} Erreur réseau`);
}
};
const handleTheme = (next: ThemeName) => {
setTheme(next);
onSettingsChange({ ...settings, theme: next });
};
const setFilter = (index: number, patch: Partial<TopicFilter>) => {
const next = settings.topicFilters.map((entry, idx) =>
idx === index ? { ...entry, ...patch } : entry
);
onSettingsChange({ ...settings, topicFilters: next });
};
const addFiltersFromBulk = () => {
const items = bulkTopics
.split(/[\n,;]/g)
.map((entry) => entry.trim())
.filter(Boolean);
if (items.length === 0) return;
const existing = new Set(settings.topicFilters.map((entry) => entry.topic));
const next = [...settings.topicFilters];
items.forEach((topic) => {
if (!existing.has(topic)) {
next.push(makeFilter(topic));
}
});
onSettingsChange({ ...settings, topicFilters: next });
setBulkTopics('');
};
const removeFilter = (index: number) => {
const next = settings.topicFilters.filter((_, idx) => idx !== index);
onSettingsChange({ ...settings, topicFilters: next });
};
return (
<div className="flex flex-col h-full bg-[color:var(--bg-main)] ui-font">
<div className="p-4 border-b border-[color:var(--border)] bg-[color:var(--bg-panel)]">
<h2 className="text-sm font-semibold">Paramètres</h2>
</div>
<div className="flex-1 overflow-auto p-4 space-y-6 custom-scrollbar">
<section className="space-y-3">
<div className="flex items-center gap-2 text-xs uppercase tracking-widest opacity-60"><Wifi size={12}/> Profils MQTT</div>
<div className="space-y-4">
{settings.mqttProfiles.map((profile) => (
<div key={profile.id} className="border border-[color:var(--border)] rounded p-3 bg-[color:var(--bg-code)]">
<div className="flex items-center justify-between mb-2">
<input
className="bg-transparent text-xs font-semibold w-full"
value={profile.name}
onChange={(e) => setProfile(profile.id, { name: e.target.value })}
/>
<button
className="text-[10px] opacity-60"
onClick={() => removeProfile(profile.id)}
>
Supprimer
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="text-xs opacity-70">Serveur</label>
<input
value={profile.host}
onChange={(e) => setProfile(profile.id, { host: e.target.value })}
className="w-full bg-[color:var(--bg-panel)] border border-[color:var(--border)] rounded px-3 py-2 text-xs"
/>
</div>
<div>
<label className="text-xs opacity-70">Port</label>
<input
type="number"
value={profile.port}
onChange={(e) => setProfile(profile.id, { port: Number(e.target.value) })}
className="w-full bg-[color:var(--bg-panel)] border border-[color:var(--border)] rounded px-3 py-2 text-xs"
/>
</div>
<div>
<label className="text-xs opacity-70">Utilisateur</label>
<input
value={profile.username}
onChange={(e) => setProfile(profile.id, { username: e.target.value })}
className="w-full bg-[color:var(--bg-panel)] border border-[color:var(--border)] rounded px-3 py-2 text-xs"
/>
</div>
<div>
<label className="text-xs opacity-70">Mot de passe</label>
<input
type="password"
value={profile.password}
onChange={(e) => setProfile(profile.id, { password: e.target.value })}
className="w-full bg-[color:var(--bg-panel)] border border-[color:var(--border)] rounded px-3 py-2 text-xs"
/>
</div>
</div>
<div className="flex items-center gap-3 mt-3 text-xs">
<label className="flex items-center gap-2">
<input
type="radio"
checked={profile.isDefault}
onChange={() => setDefaultProfile(profile.id)}
/>
Connexion par défaut
</label>
<button
onClick={() => handleTest(profile)}
className="px-3 py-1 text-[10px] rounded border border-[color:var(--border)]"
>
Tester
</button>
<span className="text-[10px] opacity-60">tcp://{profile.host}:{profile.port}</span>
</div>
</div>
))}
</div>
<button
onClick={addProfile}
className="px-3 py-2 text-xs rounded border border-[color:var(--border)]"
>
Ajouter un profil
</button>
{testStatus && <div className="text-xs opacity-70">{testStatus}</div>}
</section>
<section className="space-y-3">
<div className="flex items-center gap-2 text-xs uppercase tracking-widest opacity-60"><Database size={12}/> Historique</div>
<div className="text-xs opacity-70">TTL actuel : {settings.ttlDays} jours</div>
<div className="text-xs opacity-50">La purge automatique s'effectue toutes les 10 minutes.</div>
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={settings.autoPurgePayloads}
onChange={(e) => onSettingsChange({ ...settings, autoPurgePayloads: e.target.checked })}
/>
Supprimer auto les messages &gt;
<input
type="number"
value={Math.round(settings.autoPurgePayloadBytes / 1024)}
onChange={(e) => onSettingsChange({ ...settings, autoPurgePayloadBytes: Number(e.target.value) * 1024 })}
className="w-20 bg-[color:var(--bg-code)] border border-[color:var(--border)] rounded px-2 py-1 text-[10px]"
/>
Ko
</label>
<div className="text-[10px] opacity-50">Utile pour filtrer les payloads volumineux (ex: images base64).</div>
<div className="text-[10px] opacity-50">Auto-purge si la base dépasse 400 Mo : suppression des 25% les plus anciens.</div>
</section>
<section className="space-y-3">
<div className="flex items-center gap-2 text-xs uppercase tracking-widest opacity-60"><Activity size={12}/> Barre du bas</div>
<label className="flex flex-col gap-1 text-xs">
Fréquence d'actualisation (ms)
<input
type="number"
value={settings.statsRefreshMs}
onChange={(e) => onSettingsChange({ ...settings, statsRefreshMs: Number(e.target.value) })}
className="bg-[color:var(--bg-code)] border border-[color:var(--border)] rounded px-2 py-1"
/>
</label>
<div className="text-[10px] opacity-60">Affecte stats DB et résumé $SYS.</div>
</section>
<section className="space-y-3">
<div className="flex items-center gap-2 text-xs uppercase tracking-widest opacity-60"><Sliders size={12}/> Volets</div>
<label className="flex flex-col gap-1 text-xs">
Largeur barre de redimensionnement (px)
<input
type="number"
value={settings.resizeHandlePx}
onChange={(e) => onSettingsChange({ ...settings, resizeHandlePx: Number(e.target.value) })}
className="bg-[color:var(--bg-code)] border border-[color:var(--border)] rounded px-2 py-1"
/>
</label>
</section>
<section className="space-y-3">
<div className="flex items-center gap-2 text-xs uppercase tracking-widest opacity-60"><FolderTree size={12}/> Topics</div>
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={settings.expandTreeOnStart}
onChange={(e) => onSettingsChange({ ...settings, expandTreeOnStart: e.target.checked })}
/>
Ouvrir l'arbre au démarrage
</label>
</section>
<section className="space-y-3">
<div className="flex items-center gap-2 text-xs uppercase tracking-widest opacity-60"><Sliders size={12}/> Thème</div>
<select
value={settings.theme}
onChange={(e) => handleTheme(e.target.value as ThemeName)}
className="bg-[color:var(--bg-code)] border border-[color:var(--border)] rounded px-3 py-2 text-xs"
>
<option value="dark-monokai">Night</option>
<option value="light">Day</option>
</select>
</section>
<section className="space-y-3">
<div className="flex items-center gap-2 text-xs uppercase tracking-widest opacity-60"><Sliders size={12}/> Surbrillance</div>
<div className="flex items-center gap-3">
<input
type="number"
value={settings.highlightMs}
onChange={(e) => onSettingsChange({ ...settings, highlightMs: Number(e.target.value) })}
className="w-24 bg-[color:var(--bg-code)] border border-[color:var(--border)] rounded px-3 py-2 text-xs"
/>
<span className="text-xs opacity-70">ms</span>
</div>
</section>
<section className="space-y-3">
<div className="flex items-center gap-2 text-xs uppercase tracking-widest opacity-60"><Sliders size={12}/> Taille police</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 text-xs">
<label className="flex flex-col gap-1">
UI
<input
type="number"
value={settings.uiFontSize}
onChange={(e) => onSettingsChange({ ...settings, uiFontSize: Number(e.target.value) })}
className="bg-[color:var(--bg-code)] border border-[color:var(--border)] rounded px-2 py-1"
/>
</label>
<label className="flex flex-col gap-1">
Topics
<input
type="number"
value={settings.topicFontSize}
onChange={(e) => onSettingsChange({ ...settings, topicFontSize: Number(e.target.value) })}
className="bg-[color:var(--bg-code)] border border-[color:var(--border)] rounded px-2 py-1"
/>
</label>
<label className="flex flex-col gap-1">
Payload
<input
type="number"
value={settings.payloadFontSize}
onChange={(e) => onSettingsChange({ ...settings, payloadFontSize: Number(e.target.value) })}
className="bg-[color:var(--bg-code)] border border-[color:var(--border)] rounded px-2 py-1"
/>
</label>
</div>
</section>
<section className="space-y-3">
<div className="flex items-center gap-2 text-xs uppercase tracking-widest opacity-60"><Shield size={12}/> Topics ignorés</div>
<div className="text-xs opacity-70">Liste des topics à ne pas afficher ou sauvegarder.</div>
<div className="flex gap-2">
<textarea
value={bulkTopics}
onChange={(e) => setBulkTopics(e.target.value)}
className="flex-1 bg-[color:var(--bg-code)] border border-[color:var(--border)] rounded px-3 py-2 text-xs h-20"
placeholder="colle une liste de topics, un par ligne"
/>
<button
onClick={addFiltersFromBulk}
className="px-3 py-2 text-xs rounded border border-[color:var(--border)] h-fit"
>
Ajouter
</button>
</div>
<div className="space-y-2">
{settings.topicFilters.length === 0 && (
<div className="text-xs opacity-60 italic">Aucun filtre défini.</div>
)}
{settings.topicFilters.map((entry, index) => (
<div key={`${entry.topic}-${index}`} className="flex items-center gap-3 text-xs">
<input
value={entry.topic}
onChange={(e) => setFilter(index, { topic: e.target.value })}
className="flex-1 bg-[color:var(--bg-code)] border border-[color:var(--border)] rounded px-2 py-1"
/>
<label className="flex items-center gap-1">
<input
type="checkbox"
checked={entry.view}
onChange={(e) => setFilter(index, { view: e.target.checked })}
/>
view
</label>
<label className="flex items-center gap-1">
<input
type="checkbox"
checked={entry.save}
onChange={(e) => setFilter(index, { save: e.target.checked })}
/>
save
</label>
<button className="text-[10px] opacity-60" onClick={() => removeFilter(index)}>
supprimer
</button>
</div>
))}
</div>
</section>
<section className="space-y-3">
<div className="flex items-center gap-2 text-xs uppercase tracking-widest opacity-60"><Shield size={12}/> Sécurité</div>
<div className="text-xs opacity-70">Mode lecture seule : à venir</div>
</section>
<section className="space-y-3">
<div className="flex items-center gap-2 text-xs uppercase tracking-widest opacity-60"><Link size={12}/> Liens</div>
<label className="flex flex-col gap-1 text-xs">
Lien dépôt
<input
value={settings.repoUrl}
onChange={(e) => onSettingsChange({ ...settings, repoUrl: e.target.value })}
className="bg-[color:var(--bg-code)] border border-[color:var(--border)] rounded px-3 py-2 text-xs"
/>
</label>
</section>
<section className="space-y-3">
<div className="flex items-center gap-2 text-xs uppercase tracking-widest opacity-60"><Activity size={12}/> Diagnostic</div>
<div className="text-xs opacity-70">Logs et stats runtime disponibles via WebSocket.</div>
</section>
</div>
<div className="p-4 border-t border-[color:var(--border)] bg-[color:var(--bg-panel)] text-[10px] opacity-60">
Sauvegarde automatique dans `settings.yml`.
</div>
</div>
);
};

View File

@@ -0,0 +1,343 @@
import React, { useMemo, useState } from 'react';
import { Copy, Clock, Image as ImageIcon, LineChart, Code, History, GitCompare, Braces } from 'lucide-react';
import { ChartsDock } from './ChartsDock';
import { MQTTMessage } from '../types';
interface TopicDetailsProps {
topic: string;
lastMessage: MQTTMessage | null;
previousMessage: MQTTMessage | null;
maxPayloadBytes: number;
isRecent: boolean;
chartTopic: string | null;
chartSeries: { times: number[]; values: number[] } | null;
chartFields: { path: string; label: string }[];
chartField: string;
onChartFieldChange: (path: string) => void;
chartSource: 'live' | 'db';
onChartSourceChange: (source: 'live' | 'db') => void;
}
type ViewMode = 'pretty' | 'raw' | 'tree' | 'diff';
const isJSON = (value: string) => {
try {
JSON.parse(value);
return true;
} catch {
return false;
}
};
const isImagePayload = (payload: string) => {
return payload.startsWith('data:image/') || (payload.length > 200 && /^[A-Za-z0-9+/=]+$/.test(payload));
};
const toImageDataUrl = (payload: string) => {
if (payload.startsWith('data:image/')) return payload;
return `data:image/jpeg;base64,${payload}`;
};
const sanitizeJSON = (value: unknown): { sanitized: unknown; imageDataUrl?: string } => {
if (typeof value === 'string') {
if (isImagePayload(value)) {
return { sanitized: '<image>', imageDataUrl: toImageDataUrl(value) };
}
return { sanitized: value };
}
if (Array.isArray(value)) {
let imageDataUrl: string | undefined;
const sanitized = value.map((entry) => {
const result = sanitizeJSON(entry);
if (!imageDataUrl && result.imageDataUrl) {
imageDataUrl = result.imageDataUrl;
}
return result.sanitized;
});
return { sanitized, imageDataUrl };
}
if (value && typeof value === 'object') {
let imageDataUrl: string | undefined;
const sanitized: Record<string, unknown> = {};
Object.entries(value as Record<string, unknown>).forEach(([key, entry]) => {
const result = sanitizeJSON(entry);
if (!imageDataUrl && result.imageDataUrl) {
imageDataUrl = result.imageDataUrl;
}
sanitized[key] = result.sanitized;
});
return { sanitized, imageDataUrl };
}
return { sanitized: value };
};
const copyWithFallback = async (text: string) => {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', 'true');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
const ok = document.execCommand('copy');
document.body.removeChild(textarea);
return ok;
};
const JsonTree: React.FC<{ value: unknown; depth?: number }> = ({ value, depth = 0 }) => {
if (value === null) {
return <span className="text-[color:var(--json-null)]">null</span>;
}
if (Array.isArray(value)) {
return (
<div className="pl-4 border-l border-[color:var(--tree-guide)]">
{value.map((entry, idx) => (
<div key={idx} className="flex gap-2">
<span className="text-[color:var(--text-muted)]">[{idx}]</span>
<JsonTree value={entry} depth={depth + 1} />
</div>
))}
</div>
);
}
if (typeof value === 'object') {
return (
<div className="pl-4 border-l border-[color:var(--tree-guide)]">
{Object.entries(value).map(([key, entry]) => (
<div key={key} className="flex gap-2">
<span className="text-[color:var(--json-key)]">{key}</span>
<JsonTree value={entry} depth={depth + 1} />
</div>
))}
</div>
);
}
if (typeof value === 'number') {
return <span className="text-[color:var(--json-number)]">{value}</span>;
}
if (typeof value === 'boolean') {
return <span className="text-[color:var(--json-boolean)]">{value ? 'true' : 'false'}</span>;
}
return <span className="text-[color:var(--json-string)]">"{String(value)}"</span>;
};
export const TopicDetails: React.FC<TopicDetailsProps> = ({
topic,
lastMessage,
previousMessage,
maxPayloadBytes,
isRecent,
chartTopic,
chartSeries,
chartFields,
chartField,
onChartFieldChange,
chartSource,
onChartSourceChange
}) => {
const [viewMode, setViewMode] = useState<ViewMode>('pretty');
const [showImage, setShowImage] = useState(false);
const payloadPreview = useMemo(() => {
if (!lastMessage) return '';
if (lastMessage.payload.length <= maxPayloadBytes) return lastMessage.payload;
return `${lastMessage.payload.slice(0, maxPayloadBytes)}...`;
}, [lastMessage, maxPayloadBytes]);
if (!lastMessage) {
return (
<div className="flex-1 flex items-center justify-center text-[color:var(--text-muted)] bg-[color:var(--bg-main)] topic-font">
Sélectionnez un topic pour voir les détails
</div>
);
}
const payloadIsJSON = isJSON(lastMessage.payload);
const payloadIsImage = isImagePayload(lastMessage.payload);
const parsedJSON = payloadIsJSON ? JSON.parse(lastMessage.payload) : null;
const parsedPreviousJSON = previousMessage && isJSON(previousMessage.payload)
? JSON.parse(previousMessage.payload)
: null;
const sanitized = payloadIsJSON ? sanitizeJSON(parsedJSON) : null;
const imageDataUrl = sanitized?.imageDataUrl || (payloadIsImage ? toImageDataUrl(lastMessage.payload) : undefined);
return (
<div className="flex-1 flex flex-col bg-[color:var(--bg-main)] overflow-hidden">
<div className={`p-4 border-b border-[color:var(--border)] flex justify-between items-center bg-[color:var(--bg-panel)] ${isRecent ? 'flash-topic' : ''}`}>
<div className="flex flex-col min-w-0">
<h2 className="text-sm font-mono text-[color:var(--accent-blue)] whitespace-normal break-all">{topic}</h2>
<div className="flex items-center gap-3 mt-1 text-[10px] opacity-70">
<span className="flex items-center gap-1"><Clock size={12}/> {new Date(lastMessage.timestamp).toLocaleTimeString()}</span>
<span>QoS: {lastMessage.qos}</span>
{lastMessage.retained && <span className="text-[color:var(--accent-purple)] font-bold">RETAINED</span>}
<span>Size: {lastMessage.size} B</span>
</div>
</div>
<div className="flex gap-2 items-center">
<span className="text-[10px] opacity-50">Topic</span>
<button
className="p-1.5 hover:bg-[color:var(--hover-bg)] rounded flex items-center gap-1 text-[10px]"
title="Copier le topic"
onClick={() => {
void copyWithFallback(topic);
}}
>
<Copy size={14}/>
</button>
</div>
</div>
<div className="flex border-b border-[color:var(--border)] px-4 py-1 text-xs gap-4 bg-[color:var(--bg-panel)]/70">
<button
onClick={() => setViewMode('pretty')}
className={`py-2 border-b-2 transition-all flex items-center gap-1 ${viewMode === 'pretty' ? 'border-[color:var(--accent-green)] text-[color:var(--accent-green)]' : 'border-transparent opacity-60'}`}
>
<Code size={12}/> Pretty JSON
</button>
<button
onClick={() => setViewMode('raw')}
className={`py-2 border-b-2 transition-all flex items-center gap-1 ${viewMode === 'raw' ? 'border-[color:var(--accent-green)] text-[color:var(--accent-green)]' : 'border-transparent opacity-60'}`}
>
<Braces size={12}/> Raw
</button>
<button
onClick={() => setViewMode('tree')}
className={`py-2 border-b-2 transition-all flex items-center gap-1 ${viewMode === 'tree' ? 'border-[color:var(--accent-green)] text-[color:var(--accent-green)]' : 'border-transparent opacity-60'}`}
>
<LineChart size={12}/> JSON Tree
</button>
<button
onClick={() => setViewMode('diff')}
className={`py-2 border-b-2 transition-all flex items-center gap-1 ${viewMode === 'diff' ? 'border-[color:var(--accent-green)] text-[color:var(--accent-green)]' : 'border-transparent opacity-60'}`}
>
<GitCompare size={12}/> Diff
</button>
<div className="ml-auto flex items-center gap-3 text-[10px] opacity-70">
<button
className="flex items-center gap-1 px-2 py-1 rounded border border-[color:var(--border)] hover:border-[color:var(--accent-blue)]"
title="Copier le payload"
onClick={() => {
void copyWithFallback(lastMessage.payload);
}}
>
<Copy size={12}/> Copier payload
</button>
<div className="flex items-center opacity-50 italic">
<History size={10} className="mr-1"/> Archivage actif (SQLite)
</div>
</div>
</div>
<div className="flex-1 overflow-auto p-4 custom-scrollbar">
{imageDataUrl && (
<div className="mb-4 bg-[color:var(--bg-code)] p-2 rounded border border-[color:var(--border)] inline-block shadow-xl">
<div className="flex items-center gap-2 mb-2 text-xs opacity-70 font-mono text-[color:var(--accent-purple)]"><ImageIcon size={14}/> DETECTION IMAGE BASE64</div>
<img
src={imageDataUrl}
alt="MQTT Payload"
className="max-h-[300px] object-contain rounded cursor-zoom-in"
onClick={() => setShowImage(true)}
/>
</div>
)}
{showImage && (
<div
className="fixed inset-0 bg-black/70 z-50 flex items-center justify-center p-6"
onClick={() => setShowImage(false)}
>
<div
className="max-w-5xl max-h-[80vh] bg-[color:var(--bg-panel)] border border-[color:var(--border)] p-4 rounded shadow-xl"
onClick={(event) => event.stopPropagation()}
>
{imageDataUrl && <img src={imageDataUrl} alt="MQTT Payload" className="max-h-[70vh] object-contain" />}
</div>
</div>
)}
{viewMode === 'pretty' && (
<div className={`bg-[color:var(--bg-code)] p-4 rounded border border-[color:var(--border)] shadow-inner ${isRecent ? 'flash-topic' : ''}`}>
{payloadIsJSON ? (
<pre className="font-mono payload-font text-[color:var(--json-string)] whitespace-pre-wrap">
{JSON.stringify(sanitized?.sanitized ?? parsedJSON, null, 2)}
</pre>
) : (
<pre className="font-mono payload-font text-[color:var(--text-main)] break-all whitespace-pre-wrap">
{payloadPreview}
</pre>
)}
</div>
)}
{viewMode === 'raw' && (
<div className={`bg-[color:var(--bg-code)] p-4 rounded border border-[color:var(--border)] shadow-inner ${isRecent ? 'flash-topic' : ''}`}>
<pre className="font-mono payload-font text-[color:var(--text-main)] break-all whitespace-pre-wrap">
{payloadPreview}
</pre>
</div>
)}
{viewMode === 'tree' && (
<div className="bg-[color:var(--bg-code)] p-4 rounded border border-[color:var(--border)] shadow-inner text-xs">
{payloadIsJSON ? <JsonTree value={sanitized?.sanitized ?? parsedJSON} /> : 'Payload non JSON.'}
</div>
)}
{viewMode === 'diff' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-[color:var(--bg-code)] p-4 rounded border border-[color:var(--border)] shadow-inner">
<div className="text-[10px] uppercase tracking-widest opacity-50 mb-2">Précédent</div>
<pre className="font-mono payload-font whitespace-pre-wrap">
{previousMessage ? previousMessage.payload : 'Aucun message précédent.'}
</pre>
</div>
<div className="bg-[color:var(--bg-code)] p-4 rounded border border-[color:var(--border)] shadow-inner">
<div className="text-[10px] uppercase tracking-widest opacity-50 mb-2">Dernier</div>
<pre className="font-mono payload-font whitespace-pre-wrap">
{payloadPreview}
</pre>
</div>
{payloadIsJSON && parsedPreviousJSON && (
<div className="md:col-span-2 bg-[color:var(--bg-code)] p-4 rounded border border-[color:var(--border)] shadow-inner">
<div className="text-[10px] uppercase tracking-widest opacity-50 mb-2">Diff JSON (référence)</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
<div>
<div className="text-[10px] uppercase tracking-widest opacity-40 mb-2">Avant</div>
<pre className="font-mono payload-font whitespace-pre-wrap">{JSON.stringify(parsedPreviousJSON, null, 2)}</pre>
</div>
<div>
<div className="text-[10px] uppercase tracking-widest opacity-40 mb-2">Après</div>
<pre className="font-mono payload-font whitespace-pre-wrap">{JSON.stringify(sanitized?.sanitized ?? parsedJSON, null, 2)}</pre>
</div>
</div>
</div>
)}
</div>
)}
</div>
<ChartsDock
topic={chartTopic}
series={chartSeries}
fields={chartFields}
selectedField={chartField}
onFieldChange={onChartFieldChange}
source={chartSource}
onSourceChange={onChartSourceChange}
/>
</div>
);
};

View File

@@ -0,0 +1,213 @@
import React, { useState, useMemo } from 'react';
import { ArrowUpDown, ChevronRight, ChevronDown, Folder, FileText, Search } from 'lucide-react';
import { TopicNode } from '../types';
type SortMode = 'none' | 'recent' | 'alpha' | 'count';
const getLatestTimestamp = (node: TopicNode): number => {
const parsed = node.lastMessage ? Date.parse(node.lastMessage.timestamp) : 0;
let latest = Number.isNaN(parsed) ? 0 : parsed;
node.children.forEach((child) => {
const childLatest = getLatestTimestamp(child);
if (childLatest > latest) latest = childLatest;
});
return latest;
};
const sortNodes = (nodes: TopicNode[], mode: SortMode): TopicNode[] => {
if (mode === 'none') {
return nodes;
}
const sorted = [...nodes];
if (mode === 'alpha') {
return sorted.sort((a, b) => a.name.localeCompare(b.name));
}
if (mode === 'count') {
return sorted.sort((a, b) => (b.messageCount - a.messageCount) || a.name.localeCompare(b.name));
}
return sorted.sort((a, b) => (getLatestTimestamp(b) - getLatestTimestamp(a)) || a.name.localeCompare(b.name));
};
interface TopicTreeProps {
root: TopicNode;
onSelect: (topic: string) => void;
selectedTopic: string | null;
recentTopics: Record<string, number>;
topicFilters: { topic: string; view: boolean }[];
applyViewFilter: boolean;
}
const TopicNodeView: React.FC<{
node: TopicNode;
level: number;
onSelect: (topic: string) => void;
selectedTopic: string | null;
recentTopics: Record<string, number>;
topicFilters: { topic: string; view: boolean }[];
applyViewFilter: boolean;
sortMode: SortMode;
}> = ({ node, level, onSelect, selectedTopic, recentTopics, topicFilters, applyViewFilter, sortMode }) => {
const [isOpen, setIsOpen] = useState(node.isExpanded ?? false);
const hasChildren = node.children.length > 0;
const isSelected = selectedTopic === node.fullName;
const isRecent = Boolean(recentTopics[node.fullName]);
const toggle = (e: React.MouseEvent) => {
e.stopPropagation();
if (hasChildren) setIsOpen(!isOpen);
onSelect(node.fullName);
};
return (
<div className="font-mono topic-font">
<div
onClick={toggle}
className={`flex items-center py-0.5 px-2 cursor-pointer hover:bg-[color:var(--hover-bg)] transition-colors rounded ${isSelected ? 'bg-[color:var(--selected-bg)] text-[color:var(--accent-blue)]' : ''} ${isRecent ? 'flash-topic' : ''}`}
style={{ paddingLeft: `${level * 16}px` }}
>
<span className="mr-1 w-4 flex justify-center">
{hasChildren ? (
isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />
) : (
<span className="w-2" />
)}
</span>
<span className="mr-1 opacity-60">
{hasChildren ? <Folder size={14} /> : <FileText size={14} />}
</span>
<span className="whitespace-normal break-all">{node.name}</span>
{node.messageCount > 0 && (
<span className="ml-auto topic-font opacity-40 px-1 border border-[color:var(--border)] rounded">
{node.messageCount}
</span>
)}
</div>
{isOpen && hasChildren && (
<div>
{sortNodes(node.children, sortMode)
.map(child => (
<TopicNodeView
key={child.fullName}
node={child}
level={level + 1}
onSelect={onSelect}
selectedTopic={selectedTopic}
recentTopics={recentTopics}
topicFilters={topicFilters}
applyViewFilter={applyViewFilter}
sortMode={sortMode}
/>
))}
</div>
)}
</div>
);
};
const matchTopic = (rule: string, topic: string) => {
const trimmed = rule.trim();
if (!trimmed) return false;
if (trimmed === '#') return true;
if (trimmed.endsWith('/#')) {
const prefix = trimmed.slice(0, -2);
return topic.startsWith(prefix);
}
return trimmed === topic;
};
export const TopicTree: React.FC<TopicTreeProps> = ({ root, onSelect, selectedTopic, recentTopics, topicFilters, applyViewFilter }) => {
const [filter, setFilter] = useState('');
const [sortMode, setSortMode] = useState<SortMode>('none');
const normalizedFilter = filter.trim().toLowerCase();
const filtered = useMemo(() => {
const shouldHide = (topic: string) => {
if (!applyViewFilter) return false;
return topicFilters.some((entry) => !entry.view && matchTopic(entry.topic, topic));
};
if (!normalizedFilter) {
return sortNodes(root.children.filter((node) => !shouldHide(node.fullName)), sortMode);
}
const walk = (node: TopicNode): TopicNode | null => {
if (shouldHide(node.fullName)) return null;
const matches = node.fullName.toLowerCase().includes(normalizedFilter);
const nextChildren = node.children
.map(walk)
.filter((child): child is TopicNode => child !== null);
if (matches || nextChildren.length > 0) {
return { ...node, children: sortNodes(nextChildren, sortMode), isExpanded: true };
}
return null;
};
return sortNodes(root.children
.map(walk)
.filter((child): child is TopicNode => child !== null), sortMode);
}, [applyViewFilter, normalizedFilter, root.children, sortMode, topicFilters]);
return (
<div className="flex flex-col h-full bg-[color:var(--bg-panel)] border-r border-[color:var(--border)] topic-font">
<div className="p-3 border-b border-[color:var(--border)]">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-2.5 opacity-40" size={14} />
<input
type="text"
placeholder="Filtrer les topics..."
className="w-full bg-[color:var(--bg-code)] border border-[color:var(--border)] rounded py-1.5 pl-8 pr-8 text-xs focus:outline-none focus:border-[color:var(--focus-ring)]"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
{filter && (
<button
type="button"
className="absolute right-2 top-1.5 text-xs opacity-50 hover:opacity-80"
onClick={() => setFilter('')}
title="Effacer le filtre"
>
×
</button>
)}
</div>
<div className="flex items-center gap-1 text-[10px] opacity-70">
<ArrowUpDown size={12} />
<select
value={sortMode}
onChange={(e) => setSortMode(e.target.value as SortMode)}
className="bg-[color:var(--bg-code)] border border-[color:var(--border)] rounded px-2 py-1 text-[10px]"
title="Trier les topics"
>
<option value="none">Sans tri</option>
<option value="recent">Derniers messages</option>
<option value="alpha">Ordre alphabétique</option>
<option value="count">Messages (desc)</option>
</select>
</div>
</div>
</div>
<div className="flex-1 overflow-auto p-2 custom-scrollbar">
{filtered.map(node => (
<TopicNodeView
key={node.fullName}
node={node}
level={0}
onSelect={onSelect}
selectedTopic={selectedTopic}
recentTopics={recentTopics}
topicFilters={topicFilters}
applyViewFilter={applyViewFilter}
sortMode={sortMode}
/>
))}
{filtered.length === 0 && (
<div className="text-center py-10 text-xs opacity-40 italic">
Aucun topic trouvé...
</div>
)}
</div>
</div>
);
};

66
frontend/src/constants.ts Executable file
View File

@@ -0,0 +1,66 @@
import { AppSettings } from './types';
export const THEMES = {
dark: {
bgMain: '#272822',
bgPanel: '#1e1f1c',
bgCode: '#141411',
border: '#49483e',
textMain: '#f8f8f2',
textMuted: '#75715e',
accentBlue: '#66d9ef',
accentGreen: '#a6e22e',
accentYellow: '#e6db74',
accentOrange: '#fd971f',
accentRed: '#f92672',
accentPurple: '#ae81ff',
},
light: {
bgMain: '#f8f9fa',
bgPanel: '#ffffff',
bgCode: '#e9ecef',
border: '#dee2e6',
textMain: '#212529',
textMuted: '#6c757d',
accentBlue: '#007bff',
accentGreen: '#28a745',
accentYellow: '#ffc107',
accentOrange: '#fd7e14',
accentRed: '#dc3545',
accentPurple: '#6f42c1',
}
};
// Explicitly define INITIAL_SETTINGS as AppSettings to ensure theme is correctly typed as a literal union
export const INITIAL_SETTINGS: AppSettings = {
theme: 'dark-monokai',
repoUrl: 'https://gitea.maison43.duckdns.org/gilles/mqtt_explorer',
ttlDays: 7,
maxPayloadBytes: 1024 * 100, // 100KB
autoPurgePayloads: false,
autoPurgePayloadBytes: 1024 * 250, // 250KB
autoExpandDepth: 2,
imageDetectionEnabled: true,
highlightMs: 300,
mqttProfiles: [
{
id: 'default',
name: 'Broker local',
host: '10.0.0.3',
port: 1883,
username: '',
password: '',
isDefault: true
}
],
activeProfileId: 'default',
applyViewFilter: true,
expandTreeOnStart: false,
topicFilters: [],
uiFontSize: 13,
topicFontSize: 12,
payloadFontSize: 12,
statsRefreshMs: 5000,
resizeHandlePx: 8
};

19
frontend/src/main.tsx Executable file
View File

@@ -0,0 +1,19 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './pages/App';
import './styles/base.css';
import './styles/typography.css';
import './styles/components.css';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Impossible de trouver l'élément root");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

714
frontend/src/pages/App.tsx Executable file
View File

@@ -0,0 +1,714 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Activity, Settings, Radio, Layers, Wifi, Send, FileText, Filter, Trash2 } from 'lucide-react';
import { TopicTree } from '../components/TopicTree';
import { TopicDetails } from '../components/TopicDetails';
import { PublishPanel } from '../components/PublishPanel';
import { SettingsPanel } from '../components/SettingsPanel';
import { GiteaIcon } from '../components/GiteaIcon';
import { TopicNode, MQTTMessage } from '../types';
import { INITIAL_SETTINGS } from '../constants';
import { clearAllHistory, getHistory, getMetrics, getSettings, getStats, getTopics, getSysinfo, saveSettings, setFilters } from '../utils/api';
import { initTheme, setTheme } from '../utils/theme';
import { formatBytes } from '../utils/format';
type NumericField = { path: string; label: string };
const RAW_FIELD = '__raw__';
const isNumericString = (value: string) => value.trim() !== '' && !Number.isNaN(Number(value));
const parsePath = (path: string): (string | number)[] => {
const tokens: (string | number)[] = [];
path.replace(/([^[.\]]+)|\[(\d+)\]/g, (_match, key, index) => {
if (index !== undefined) {
tokens.push(Number(index));
} else {
tokens.push(key);
}
return '';
});
return tokens;
};
const extractNumericFields = (payload: string): NumericField[] => {
const out: NumericField[] = [];
try {
const parsed = JSON.parse(payload);
if (typeof parsed === 'number' || (typeof parsed === 'string' && isNumericString(parsed))) {
return [{ path: RAW_FIELD, label: 'value' }];
}
const walk = (value: unknown, path: string) => {
if (typeof value === 'number' && Number.isFinite(value)) {
out.push({ path, label: path });
return;
}
if (typeof value === 'string' && isNumericString(value)) {
out.push({ path, label: path });
return;
}
if (Array.isArray(value)) {
value.forEach((entry, index) => {
const nextPath = `${path}[${index}]`;
walk(entry, nextPath);
});
return;
}
if (value && typeof value === 'object') {
Object.entries(value as Record<string, unknown>).forEach(([key, entry]) => {
const nextPath = path ? `${path}.${key}` : key;
walk(entry, nextPath);
});
}
};
if (parsed && typeof parsed === 'object') {
walk(parsed, '');
}
} catch {
if (isNumericString(payload)) {
return [{ path: RAW_FIELD, label: 'value' }];
}
}
const deduped = new Map<string, NumericField>();
out.forEach((field) => {
if (field.path) deduped.set(field.path, field);
});
return Array.from(deduped.values());
};
const extractNumericValue = (payload: string, path: string): number | undefined => {
if (path === RAW_FIELD) {
if (!isNumericString(payload)) return undefined;
return Number(payload);
}
try {
const parsed = JSON.parse(payload);
const tokens = parsePath(path);
let current: unknown = parsed;
for (const token of tokens) {
if (current === null || current === undefined) {
return undefined;
}
current = (current as Record<string, unknown>)[token as keyof Record<string, unknown>];
}
if (typeof current === 'number' && Number.isFinite(current)) {
return current;
}
if (typeof current === 'string' && isNumericString(current)) {
return Number(current);
}
} catch {
return undefined;
}
return undefined;
};
const hydrateTree = (node: TopicNode): TopicNode => ({
...node,
isExpanded: node.isExpanded ?? false,
children: node.children ? node.children.map(hydrateTree) : []
});
const applyInitialExpansion = (node: TopicNode, expandAll: boolean): TopicNode => ({
...node,
isExpanded: node.fullName === '' ? true : expandAll,
children: node.children ? node.children.map((child) => applyInitialExpansion(child, expandAll)) : []
});
const cloneTree = (node: TopicNode): TopicNode => ({
...node,
children: node.children.map(cloneTree)
});
const insertMessage = (root: TopicNode, msg: MQTTMessage): TopicNode => {
const nextRoot = cloneTree(root);
const parts = msg.topic.split('/');
let current = nextRoot;
parts.forEach((part, index) => {
let child = current.children.find((entry) => entry.name === part);
if (!child) {
const fullName = parts.slice(0, index + 1).join('/');
child = {
name: part,
fullName,
children: [],
messageCount: 0,
isExpanded: false
};
current.children.push(child);
}
child.messageCount += 1;
if (index === parts.length - 1) {
child.lastMessage = msg;
}
current = child;
});
return nextRoot;
};
const App: React.FC = () => {
const [root, setRoot] = useState<TopicNode>({
name: 'root',
fullName: '',
children: [],
messageCount: 0,
isExpanded: true
});
const [treeInitialized, setTreeInitialized] = useState(false);
const [selectedTopic, setSelectedTopic] = useState<string | null>(null);
const [lastMessages, setLastMessages] = useState<Map<string, MQTTMessage>>(new Map());
const [previousMessages, setPreviousMessages] = useState<Map<string, MQTTMessage>>(new Map());
const [connected, setConnected] = useState(false);
const [dbStats, setDbStats] = useState({ count: 0, size: '0 MB' });
const [settings, setSettings] = useState(INITIAL_SETTINGS);
const [activeView, setActiveView] = useState<'topics' | 'details' | 'publish' | 'settings'>('topics');
const [seriesData, setSeriesData] = useState<Record<string, { times: number[]; values: number[] }>>({});
const [dbSeriesData, setDbSeriesData] = useState<Record<string, { times: number[]; values: number[] }>>({});
const [chartSource, setChartSource] = useState<'live' | 'db'>('live');
const [recentTopics, setRecentTopics] = useState<Record<string, number>>({});
const [sidebarWidth, setSidebarWidth] = useState(320);
const [resizing, setResizing] = useState(false);
const [settingsWidth, setSettingsWidth] = useState(360);
const [resizingSettings, setResizingSettings] = useState(false);
const [publishWidth, setPublishWidth] = useState(360);
const [resizingPublish, setResizingPublish] = useState(false);
const [publishOpen, setPublishOpen] = useState(false);
const [metrics, setMetrics] = useState({ cpuPercent: 0, memBytes: 0, memLimit: 0, dbBytes: 0, dbSize: '0 B' });
const [sysinfo, setSysinfo] = useState({ version: '', clients: '-', msgReceived: '-', msgSent: '-', msgStored: '-', subscriptions: '-' });
const [publishDraft, setPublishDraft] = useState({ topic: '', payload: '' });
const [chartFieldsByTopic, setChartFieldsByTopic] = useState<Record<string, NumericField[]>>({});
const [chartFieldByTopic, setChartFieldByTopic] = useState<Record<string, string>>({});
const chartFieldRef = useRef<Record<string, string>>({});
const defaultProfile = settings.mqttProfiles.find((profile) => profile.isDefault);
const activeProfile = settings.mqttProfiles.find((profile) => profile.id === settings.activeProfileId) || defaultProfile;
useEffect(() => {
const currentTheme = initTheme();
getSettings()
.then((remote) => {
const merged = { ...INITIAL_SETTINGS, ...remote, theme: remote.theme || currentTheme };
setSettings(merged);
setTheme(merged.theme);
})
.catch(() => {
setSettings((prev) => ({ ...prev, theme: currentTheme }));
});
}, []);
useEffect(() => {
setTheme(settings.theme);
}, [settings.theme]);
useEffect(() => {
chartFieldRef.current = chartFieldByTopic;
}, [chartFieldByTopic]);
const updateStats = useCallback((stats: { count: number; size: string }) => {
setDbStats(stats);
}, []);
const refreshStats = useCallback(async () => {
try {
const stats = await getStats();
setDbStats(stats);
} catch {
// stats optionnelles
}
}, []);
useEffect(() => {
if (settings.statsRefreshMs <= 0) return;
const interval = setInterval(refreshStats, settings.statsRefreshMs);
return () => clearInterval(interval);
}, [refreshStats, settings.statsRefreshMs]);
useEffect(() => {
getTopics()
.then((snapshot) => setRoot(hydrateTree(snapshot)))
.catch(() => null);
refreshStats();
}, [refreshStats]);
useEffect(() => {
if (treeInitialized) return;
if (root.children.length === 0) return;
setRoot((prev) => applyInitialExpansion(prev, settings.expandTreeOnStart));
setTreeInitialized(true);
}, [root.children.length, settings.expandTreeOnStart, treeInitialized]);
useEffect(() => {
setFilters(settings.topicFilters).catch(() => null);
}, [settings.topicFilters]);
useEffect(() => {
const handle = setTimeout(() => {
saveSettings(settings).catch(() => null);
}, 400);
return () => clearTimeout(handle);
}, [settings]);
useEffect(() => {
let active = true;
const load = async () => {
try {
const next = await getMetrics();
if (active) setMetrics(next);
} catch {
// métriques optionnelles
}
};
load();
const interval = setInterval(load, settings.statsRefreshMs);
return () => {
active = false;
clearInterval(interval);
};
}, [settings.statsRefreshMs]);
useEffect(() => {
let active = true;
const load = async () => {
try {
const next = await getSysinfo();
if (active) setSysinfo(next);
} catch {
// sysinfo optionnelles
}
};
load();
const interval = setInterval(load, settings.statsRefreshMs);
return () => {
active = false;
clearInterval(interval);
};
}, [settings.statsRefreshMs]);
useEffect(() => {
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
const wsUrl = `${protocol}://${window.location.host}/ws/events`;
const socket = new WebSocket(wsUrl);
socket.onopen = () => setConnected(true);
socket.onclose = () => setConnected(false);
socket.onerror = () => setConnected(false);
socket.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'message') {
const msg = data.data as MQTTMessage;
const now = Date.now();
setRoot((prevRoot) => insertMessage(prevRoot, msg));
setLastMessages((prev) => {
const next = new Map(prev);
const previous = next.get(msg.topic);
if (previous) {
setPreviousMessages((prevPrev) => {
const nextPrev = new Map(prevPrev);
nextPrev.set(msg.topic, previous);
return nextPrev;
});
}
next.set(msg.topic, msg);
return next;
});
const fields = extractNumericFields(msg.payload);
setChartFieldsByTopic((prev) => ({ ...prev, [msg.topic]: fields }));
let selectedPath = chartFieldRef.current[msg.topic];
if (!selectedPath || !fields.some((field) => field.path === selectedPath)) {
if (fields.length === 1) {
selectedPath = fields[0].path;
setChartFieldByTopic((prev) => ({ ...prev, [msg.topic]: selectedPath as string }));
} else {
selectedPath = undefined;
}
}
if (selectedPath) {
const numeric = extractNumericValue(msg.payload, selectedPath);
if (numeric !== undefined) {
const seriesKey = `${msg.topic}::${selectedPath}`;
setSeriesData((prevSeries) => {
const next = { ...prevSeries };
const current = next[seriesKey] || { times: [], values: [] };
const nextTimes = [...current.times, Date.now() / 1000];
const nextValues = [...current.values, numeric];
const cap = 200;
next[seriesKey] = {
times: nextTimes.slice(-cap),
values: nextValues.slice(-cap)
};
return next;
});
}
}
setRecentTopics((prev) => {
const next = { ...prev };
const parts = msg.topic.split('/');
let current = '';
parts.forEach((part) => {
current = current ? `${current}/${part}` : part;
next[current] = now;
});
return next;
});
}
if (data.type === 'stats') {
updateStats(data.data);
}
};
return () => socket.close();
}, [updateStats]);
useEffect(() => {
if (Object.keys(recentTopics).length === 0) return;
const timeout = setTimeout(() => {
const cutoff = Date.now() - settings.highlightMs;
setRecentTopics((prev) => {
const next: Record<string, number> = {};
Object.entries(prev).forEach(([topic, ts]) => {
if (ts >= cutoff) next[topic] = ts;
});
return next;
});
}, Math.max(30, Math.min(settings.highlightMs / 2, 120)));
return () => clearTimeout(timeout);
}, [recentTopics, settings.highlightMs]);
useEffect(() => {
if (!resizing) return;
const handleMove = (event: MouseEvent) => {
const nextWidth = Math.min(520, Math.max(220, event.clientX));
setSidebarWidth(nextWidth);
};
const stopResize = () => setResizing(false);
window.addEventListener('mousemove', handleMove);
window.addEventListener('mouseup', stopResize);
return () => {
window.removeEventListener('mousemove', handleMove);
window.removeEventListener('mouseup', stopResize);
};
}, [resizing]);
useEffect(() => {
if (!resizingSettings) return;
const handleMove = (event: MouseEvent) => {
const viewport = window.innerWidth;
const nextWidth = Math.min(520, Math.max(260, viewport - event.clientX));
setSettingsWidth(nextWidth);
};
const stopResize = () => setResizingSettings(false);
window.addEventListener('mousemove', handleMove);
window.addEventListener('mouseup', stopResize);
return () => {
window.removeEventListener('mousemove', handleMove);
window.removeEventListener('mouseup', stopResize);
};
}, [resizingSettings]);
useEffect(() => {
if (!resizingPublish) return;
const handleMove = (event: MouseEvent) => {
const viewport = window.innerWidth;
const nextWidth = Math.min(520, Math.max(260, viewport - event.clientX));
setPublishWidth(nextWidth);
};
const stopResize = () => setResizingPublish(false);
window.addEventListener('mousemove', handleMove);
window.addEventListener('mouseup', stopResize);
return () => {
window.removeEventListener('mousemove', handleMove);
window.removeEventListener('mouseup', stopResize);
};
}, [resizingPublish]);
const handleClearHistory = async () => {
await clearAllHistory();
setLastMessages(new Map());
setPreviousMessages(new Map());
setRoot({
name: 'root',
fullName: '',
children: [],
messageCount: 0,
isExpanded: true
});
setSelectedTopic(null);
refreshStats();
};
const activeMessage = selectedTopic ? lastMessages.get(selectedTopic) || null : null;
const previousMessage = selectedTopic ? previousMessages.get(selectedTopic) || null : null;
const chartFields = selectedTopic ? chartFieldsByTopic[selectedTopic] || [] : [];
const selectedChartField = selectedTopic ? chartFieldByTopic[selectedTopic] || '' : '';
const effectiveChartField = selectedTopic
? selectedChartField || (chartFields.length === 1 ? chartFields[0].path : '')
: '';
const chartSeriesKey = selectedTopic && effectiveChartField
? `${selectedTopic}::${effectiveChartField}`
: '';
const chartSeries = selectedTopic && effectiveChartField
? (chartSource === 'db'
? dbSeriesData[chartSeriesKey] || null
: seriesData[chartSeriesKey] || null)
: null;
useEffect(() => {
if (chartSource !== 'db' || !selectedTopic || !effectiveChartField) {
return;
}
let active = true;
const seriesKey = `${selectedTopic}::${effectiveChartField}`;
const loadHistory = async () => {
try {
const history = await getHistory(selectedTopic, 200);
const points = history
.map((msg) => {
const value = extractNumericValue(msg.payload, effectiveChartField);
if (value === undefined) return null;
const timestamp = Date.parse(msg.timestamp);
if (Number.isNaN(timestamp)) return null;
return { time: timestamp / 1000, value };
})
.filter((entry): entry is { time: number; value: number } => entry !== null)
.reverse();
if (!active) return;
setDbSeriesData((prev) => ({
...prev,
[seriesKey]: { times: points.map((p) => p.time), values: points.map((p) => p.value) }
}));
} catch {
if (!active) return;
setDbSeriesData((prev) => ({ ...prev, [seriesKey]: { times: [], values: [] } }));
}
};
loadHistory();
return () => {
active = false;
};
}, [chartSource, effectiveChartField, selectedTopic]);
return (
<div
className="flex flex-col h-screen overflow-hidden bg-[color:var(--bg-main)] text-[color:var(--text-main)] relative"
style={{
['--flash-duration' as string]: `${settings.highlightMs}ms`,
['--ui-font-size' as string]: `${settings.uiFontSize}px`,
['--topic-font-size' as string]: `${settings.topicFontSize}px`,
['--payload-font-size' as string]: `${settings.payloadFontSize}px`
}}
>
<header className="h-12 border-b border-[color:var(--border)] bg-[color:var(--bg-panel)] flex items-center justify-between px-4 shrink-0 ui-font">
<div className="flex items-center gap-3">
<div className="bg-[color:var(--accent-green)] p-1.5 rounded-lg text-black">
<Radio size={18} />
</div>
<h1 className="font-bold text-sm tracking-tight flex items-center gap-2">
MQTT EXPLORER
<span className="text-[10px] font-normal opacity-50 bg-white/5 px-1.5 py-0.5 rounded">v1.2.0-monokai</span>
</h1>
</div>
<div className="flex items-center gap-6">
<div className="flex items-center gap-2 text-xs">
<span className="opacity-40 uppercase tracking-widest text-[9px]">Status</span>
<div className="flex items-center gap-1.5 bg-[color:var(--bg-main)] px-2 py-1 rounded border border-[color:var(--border)]">
<div className={`w-2 h-2 rounded-full ${connected ? 'bg-[color:var(--accent-green)]' : 'bg-[color:var(--accent-red)]'} animate-pulse`} />
<span className={connected ? 'text-[color:var(--accent-green)]' : 'text-[color:var(--accent-red)]'}>{connected ? 'Connected' : 'Offline'}</span>
</div>
</div>
<div className="hidden md:flex items-center gap-4 text-[10px] opacity-70">
<span>CPU: {metrics.cpuPercent.toFixed(1)}%</span>
<span>MEM: {formatBytes(metrics.memBytes)}{metrics.memLimit ? ` / ${formatBytes(metrics.memLimit)}` : ''}</span>
<span>DB: {metrics.dbSize}</span>
<button
className="flex items-center gap-1 px-2 py-1 rounded border text-[10px] border-[color:var(--border)] hover:border-[color:var(--accent-red)] hover:text-[color:var(--accent-red)]"
title="Supprimer toute la base SQLite"
onClick={handleClearHistory}
>
<Trash2 size={12} /> Clear DB
</button>
</div>
<div className="hidden md:flex items-center gap-2 text-xs">
<select
value={activeProfile?.id || ''}
onChange={(e) => setSettings((prev) => ({ ...prev, activeProfileId: e.target.value }))}
className="bg-[color:var(--bg-main)] border border-[color:var(--border)] rounded px-2 py-1 text-[10px]"
>
{settings.mqttProfiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.name}
</option>
))}
</select>
<button
className={`flex items-center gap-1 px-2 py-1 rounded border text-[10px] ${settings.applyViewFilter ? 'border-[color:var(--accent-green)] text-[color:var(--accent-green)]' : 'border-[color:var(--border)] opacity-60'}`}
onClick={() => setSettings((prev) => ({ ...prev, applyViewFilter: !prev.applyViewFilter }))}
title="Filtrer les topics masqués"
>
<Filter size={12} />
Masqués
</button>
</div>
<nav className="hidden md:flex gap-4 opacity-70 hover:opacity-100 transition-opacity">
<button
className={`hover:text-[color:var(--accent-blue)] ${publishOpen ? 'text-[color:var(--accent-green)]' : ''}`}
title="Publish"
onClick={() => setPublishOpen((prev) => !prev)}
>
<Send size={20}/>
</button>
<button
className={`hover:text-[color:var(--accent-blue)] ${activeView === 'settings' ? 'text-[color:var(--accent-green)]' : ''}`}
title="Settings"
onClick={() => setActiveView(activeView === 'settings' ? 'details' : 'settings')}
>
<Settings size={20}/>
</button>
<button className="hover:text-[color:var(--accent-blue)]" title="Refresh Stats" onClick={refreshStats}><Layers size={20}/></button>
{settings.repoUrl && (
<a
href={settings.repoUrl}
className="hover:text-[color:var(--accent-blue)]"
title="Source Code"
target="_blank"
rel="noreferrer"
>
<GiteaIcon size={20} />
</a>
)}
</nav>
</div>
</header>
<main className="flex-1 flex overflow-hidden">
<aside
className={`shrink-0 ${activeView === 'topics' ? 'block' : 'hidden'} md:block`}
style={{ width: `${sidebarWidth}px` }}
>
<TopicTree
root={root}
selectedTopic={selectedTopic}
recentTopics={recentTopics}
topicFilters={settings.topicFilters}
applyViewFilter={settings.applyViewFilter}
onSelect={(topic) => {
setSelectedTopic(topic);
if (window.matchMedia('(max-width: 767px)').matches) {
setActiveView('details');
}
}}
/>
</aside>
<div
className="hidden md:block cursor-col-resize bg-[color:var(--border)]/60 hover:bg-[color:var(--accent-blue)]/40 transition-colors"
style={{ width: `${settings.resizeHandlePx}px` }}
onMouseDown={() => setResizing(true)}
/>
<section className={`flex-1 overflow-hidden flex flex-col ${activeView === 'details' ? 'block' : 'hidden'} md:flex`}>
<TopicDetails
topic={selectedTopic || ''}
lastMessage={activeMessage}
previousMessage={previousMessage}
isRecent={selectedTopic ? Boolean(recentTopics[selectedTopic]) : false}
maxPayloadBytes={settings.maxPayloadBytes}
chartTopic={selectedTopic}
chartSeries={chartSeries}
chartFields={chartFields}
chartField={selectedChartField}
onChartFieldChange={(path) => {
if (!selectedTopic) return;
setChartFieldByTopic((prev) => ({ ...prev, [selectedTopic]: path }));
}}
chartSource={chartSource}
onChartSourceChange={setChartSource}
/>
</section>
<section className={`flex-1 overflow-hidden flex flex-col ${activeView === 'publish' ? 'block' : 'hidden'} md:hidden`}>
<PublishPanel
draft={publishDraft}
onDraftChange={setPublishDraft}
onPasteTopic={() => {
if (selectedTopic) setPublishDraft((prev) => ({ ...prev, topic: selectedTopic }));
}}
onPastePayload={() => {
if (activeMessage) setPublishDraft((prev) => ({ ...prev, payload: activeMessage.payload }));
}}
/>
</section>
<section className={`flex-1 overflow-hidden flex flex-col ${activeView === 'settings' ? 'block' : 'hidden'} md:hidden`}>
<SettingsPanel settings={settings} onSettingsChange={setSettings} />
</section>
<div className={`${publishOpen ? 'hidden md:flex' : 'hidden'}`}>
<div
className="cursor-col-resize bg-[color:var(--border)]/60 hover:bg-[color:var(--accent-blue)]/40 transition-colors"
style={{ width: `${settings.resizeHandlePx}px` }}
onMouseDown={() => setResizingPublish(true)}
/>
<section className="border-l border-[color:var(--border)] bg-[color:var(--bg-main)]" style={{ width: `${publishWidth}px` }}>
<PublishPanel
draft={publishDraft}
onDraftChange={setPublishDraft}
onPasteTopic={() => {
if (selectedTopic) setPublishDraft((prev) => ({ ...prev, topic: selectedTopic }));
}}
onPastePayload={() => {
if (activeMessage) setPublishDraft((prev) => ({ ...prev, payload: activeMessage.payload }));
}}
/>
</section>
</div>
<div className={`${activeView === 'settings' ? 'hidden md:flex' : 'hidden'}`}>
<div
className="cursor-col-resize bg-[color:var(--border)]/60 hover:bg-[color:var(--accent-blue)]/40 transition-colors"
style={{ width: `${settings.resizeHandlePx}px` }}
onMouseDown={() => setResizingSettings(true)}
/>
<section className="border-l border-[color:var(--border)]" style={{ width: `${settingsWidth}px` }}>
<SettingsPanel settings={settings} onSettingsChange={setSettings} />
</section>
</div>
</main>
<footer className="h-8 border-t border-[color:var(--border)] bg-[color:var(--bg-panel)] flex items-center justify-between px-4 opacity-60 shrink-0 ui-font">
<div className="flex gap-4 items-center">
<span className="flex items-center gap-1"><Activity size={10}/> 2.4 msg/sec</span>
<span className="flex items-center gap-1">SQLite: {dbStats.size} ({dbStats.count} msgs)</span>
<span className="flex items-center gap-1">TTL: {settings.ttlDays} jours</span>
</div>
<div className="hidden md:flex items-center gap-3">
<span>MQTT {sysinfo.version || '—'}</span>
<span>Clients {sysinfo.clients}</span>
<span>Rx {sysinfo.msgReceived}</span>
<span>Tx {sysinfo.msgSent}</span>
<span>Stored {sysinfo.msgStored}</span>
<span>Subs {sysinfo.subscriptions}</span>
</div>
<div className="hidden md:block">
Broker: {activeProfile ? `${activeProfile.host}:${activeProfile.port}` : 'non défini'}
</div>
</footer>
<nav className="md:hidden h-12 border-t border-[color:var(--border)] bg-[color:var(--bg-panel)] flex items-center justify-around text-[10px]">
<button onClick={() => setActiveView('topics')} className={`flex flex-col items-center ${activeView === 'topics' ? 'text-[color:var(--accent-green)]' : 'opacity-60'}`}>
<Wifi size={14} /> Topics
</button>
<button onClick={() => setActiveView('details')} className={`flex flex-col items-center ${activeView === 'details' ? 'text-[color:var(--accent-green)]' : 'opacity-60'}`}>
<FileText size={14} /> Details
</button>
<button onClick={() => setActiveView('publish')} className={`flex flex-col items-center ${activeView === 'publish' ? 'text-[color:var(--accent-green)]' : 'opacity-60'}`}>
<Send size={14} /> Publish
</button>
<button onClick={() => setActiveView('settings')} className={`flex flex-col items-center ${activeView === 'settings' ? 'text-[color:var(--accent-green)]' : 'opacity-60'}`}>
<Settings size={14} /> Settings
</button>
</nav>
</div>
);
};
export default App;

View File

@@ -0,0 +1,88 @@
:root {
color-scheme: light dark;
--font-ui: 'Inter', sans-serif;
--font-mono: 'JetBrains Mono', monospace;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: var(--font-ui, 'Inter', sans-serif);
font-size: var(--ui-font-size, 13px);
background: var(--bg-main);
color: var(--text-main);
height: 100vh;
overflow: hidden;
user-select: none;
}
.font-mono {
font-family: var(--font-mono, 'JetBrains Mono', monospace);
}
.ui-font {
font-size: var(--ui-font-size, 13px);
}
.ui-font .text-xs,
.ui-font .text-sm {
font-size: var(--ui-font-size, 13px);
}
.topic-font {
font-size: var(--topic-font-size, 12px);
}
.payload-font {
font-size: var(--payload-font-size, 12px);
}
button,
input,
select,
textarea {
font-family: inherit;
}
input,
textarea,
pre,
code,
[contenteditable="true"] {
user-select: text;
}
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
.flash-topic {
animation: flash-bg var(--flash-duration, 300ms) ease-out;
background: #3c282a;
}
@keyframes flash-bg {
0% {
background: #3c282a;
}
100% {
background: transparent;
}
}

View File

@@ -0,0 +1,8 @@
.panel {
background: var(--bg-panel);
border: 1px solid var(--border);
}
.panel-header {
background: color-mix(in srgb, var(--bg-panel) 80%, black 20%);
}

View File

@@ -0,0 +1,7 @@
h1, h2, h3 {
margin: 0;
}
code, pre {
font-family: var(--font-mono, 'JetBrains Mono', monospace);
}

70
frontend/src/types.ts Executable file
View File

@@ -0,0 +1,70 @@
export interface MQTTMessage {
id: string;
topic: string;
payload: string;
qos: number;
retained: boolean;
timestamp: string;
size: number;
type?: 'json' | 'text' | 'image' | 'binary';
}
export interface TopicNode {
name: string;
fullName: string;
children: TopicNode[];
lastMessage?: MQTTMessage;
messageCount: number;
isExpanded?: boolean;
}
export interface BrokerProfile {
id: string;
name: string;
host: string;
port: number;
useTLS: boolean;
username?: string;
password?: string;
clientId: string;
defaultSubscribe: string;
}
export interface AppSettings {
theme: 'dark-monokai' | 'light';
repoUrl: string;
ttlDays: number;
maxPayloadBytes: number;
autoPurgePayloads: boolean;
autoPurgePayloadBytes: number;
autoExpandDepth: number;
imageDetectionEnabled: boolean;
highlightMs: number;
mqttProfiles: MQTTProfile[];
activeProfileId: string;
applyViewFilter: boolean;
expandTreeOnStart: boolean;
topicFilters: TopicFilter[];
uiFontSize: number;
topicFontSize: number;
payloadFontSize: number;
statsRefreshMs: number;
resizeHandlePx: number;
}
export interface MQTTProfile {
id: string;
name: string;
host: string;
port: number;
username: string;
password: string;
isDefault: boolean;
}
export interface TopicFilter {
topic: string;
save: boolean;
view: boolean;
}

108
frontend/src/utils/api.ts Normal file
View File

@@ -0,0 +1,108 @@
import { MQTTMessage, TopicNode } from '../types';
export async function getTopics(): Promise<TopicNode> {
const res = await fetch('/api/topics');
if (!res.ok) throw new Error('Impossible de charger les topics');
return res.json();
}
export async function getHistory(topic: string, limit = 50): Promise<MQTTMessage[]> {
const res = await fetch(`/api/topic/${encodeURIComponent(topic)}/history?limit=${limit}`);
if (!res.ok) throw new Error('Impossible de charger l\'historique');
return res.json();
}
export async function getStats(): Promise<{ count: number; size: string }> {
const res = await fetch('/api/stats');
if (!res.ok) throw new Error('Impossible de charger les stats');
return res.json();
}
export async function getMetrics(): Promise<{
cpuPercent: number;
memBytes: number;
memLimit: number;
dbBytes: number;
dbSize: string;
}> {
const res = await fetch('/api/metrics');
if (!res.ok) throw new Error('Impossible de charger les métriques');
return res.json();
}
export async function getSysinfo(): Promise<{
version: string;
clients: string;
msgReceived: string;
msgSent: string;
msgStored: string;
subscriptions: string;
}> {
const res = await fetch('/api/sysinfo');
if (!res.ok) throw new Error('Impossible de charger SYS');
return res.json();
}
export async function setFilters(rules: { topic: string; save: boolean; view: boolean }[]): Promise<void> {
const res = await fetch('/api/filters', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(rules)
});
if (!res.ok) throw new Error('Impossible de mettre à jour les filtres');
}
export async function getSettings(): Promise<any> {
const res = await fetch('/api/settings');
if (!res.ok) throw new Error('Impossible de charger les paramètres');
return res.json();
}
export async function saveSettings(payload: any): Promise<void> {
const res = await fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error('Impossible de sauvegarder les paramètres');
}
export async function clearHistory(topic: string): Promise<{ deleted: number }> {
const res = await fetch(`/api/topic/${encodeURIComponent(topic)}/clear-history`, {
method: 'POST'
});
if (!res.ok) throw new Error('Impossible de supprimer l\'historique');
return res.json();
}
export async function clearAllHistory(): Promise<{ deleted: number }> {
const res = await fetch('/api/history/clear', {
method: 'POST'
});
if (!res.ok) throw new Error('Impossible de supprimer la base');
return res.json();
}
export async function publishMessage(payload: {
topic: string;
payload: string;
qos: number;
retained: boolean;
}): Promise<void> {
const res = await fetch('/api/publish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error('Publication impossible');
}
export async function testConnection(broker: string): Promise<{ ok: boolean; latency?: number; error?: string }> {
const res = await fetch('/api/test-connection', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ broker })
});
if (!res.ok) throw new Error('Test impossible');
return res.json();
}

View File

@@ -0,0 +1,10 @@
export const formatBytes = (bytes: number) => {
if (!bytes || bytes < 0) return '0 B';
if (bytes < 1024) return `${bytes} B`;
const kb = bytes / 1024;
if (kb < 1024) return `${kb.toFixed(1)} KB`;
const mb = kb / 1024;
if (mb < 1024) return `${mb.toFixed(1)} MB`;
const gb = mb / 1024;
return `${gb.toFixed(2)} GB`;
};

View File

@@ -0,0 +1,16 @@
export type ThemeName = 'dark-monokai' | 'light';
export function setTheme(theme: ThemeName) {
const link = document.getElementById('theme-css') as HTMLLinkElement | null;
if (link) {
link.href = theme === 'dark-monokai' ? '/themes/theme-dark-monokai.css' : '/themes/theme-light.css';
}
document.documentElement.dataset.theme = theme;
}
export function initTheme() {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const fallback: ThemeName = prefersDark ? 'dark-monokai' : 'light';
setTheme(fallback);
return fallback;
}

29
frontend/tsconfig.json Executable file
View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"types": [
"node"
],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

30
frontend/vite.config.ts Executable file
View File

@@ -0,0 +1,30 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
server: {
port: 3000,
host: '0.0.0.0',
proxy: {
'/api': 'http://localhost:8088',
'/ws': {
target: 'ws://localhost:8088',
ws: true
}
}
},
plugins: [react()],
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
}
}
};
});