Files
mqtt_explorer/frontend/src/components/TopicDetails.tsx
Gilles Soulier 383ad292d3 first
2025-12-24 14:47:39 +01:00

344 lines
14 KiB
TypeScript
Executable File

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