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