344 lines
14 KiB
TypeScript
Executable File
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>
|
|
);
|
|
};
|