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

21
frontend/index.html Executable file
View File

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

8
frontend/metadata.json Executable file
View File

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

23
frontend/package.json Executable file
View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 B

View File

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

After

Width:  |  Height:  |  Size: 623 B

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

29
frontend/tsconfig.json Executable file
View File

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

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

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