first
This commit is contained in:
Executable
+343
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user