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