375 lines
17 KiB
JavaScript
375 lines
17 KiB
JavaScript
import React, { useState } from 'react';
|
|
import { AlertCircle, Clock, Database, FileX, Loader2, CheckCircle2, Hash, Copy, Download, Check } from 'lucide-react';
|
|
|
|
const MAX_DISPLAY_ROWS = 1000;
|
|
|
|
export default function ResultTable({ result, loading, pollingProgress }) {
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
// Normalize data
|
|
let columns = [];
|
|
let rows = [];
|
|
|
|
if (result && !result.error) {
|
|
if (result.rows && result.columns) {
|
|
columns = result.columns;
|
|
rows = result.rows;
|
|
} else if (Array.isArray(result.data) && result.data.length > 0) {
|
|
columns = Object.keys(result.data[0]);
|
|
rows = result.data;
|
|
} else if (Array.isArray(result) && result.length > 0) {
|
|
columns = Object.keys(result[0]);
|
|
rows = result;
|
|
}
|
|
}
|
|
|
|
// Function to convert data to CSV
|
|
const generateCSV = (cols, dataRows) => {
|
|
const header = cols.join(',');
|
|
const csvRows = dataRows.map(row => {
|
|
return cols.map(col => {
|
|
let val = Array.isArray(row) ? row[cols.indexOf(col)] : row[col];
|
|
if (val === null || val === undefined) val = '';
|
|
val = String(val).replace(/"/g, '""');
|
|
if (val.includes(',') || val.includes('"') || val.includes('\n')) {
|
|
val = `"${val}"`;
|
|
}
|
|
return val;
|
|
}).join(',');
|
|
});
|
|
return [header, ...csvRows].join('\n');
|
|
};
|
|
|
|
// Download as CSV
|
|
const handleDownloadCSV = (cols, dataRows) => {
|
|
const csv = generateCSV(cols, dataRows);
|
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement('a');
|
|
link.setAttribute('href', url);
|
|
link.setAttribute('download', `query_result_${Date.now()}.csv`);
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
// Auto-download if too many rows
|
|
React.useEffect(() => {
|
|
if (rows.length > MAX_DISPLAY_ROWS) {
|
|
handleDownloadCSV(columns, rows);
|
|
}
|
|
}, [result]);
|
|
|
|
// Copy to clipboard
|
|
const handleCopy = (cols, dataRows) => {
|
|
const csv = generateCSV(cols, dataRows);
|
|
navigator.clipboard.writeText(csv).then(() => {
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
});
|
|
};
|
|
|
|
// Loading state
|
|
if (loading) {
|
|
return (
|
|
<div className="result-container" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
|
|
<div style={{
|
|
width: '5rem', height: '5rem',
|
|
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
|
borderRadius: '50%',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
marginBottom: '1.5rem',
|
|
border: '2px solid rgba(59, 130, 246, 0.3)'
|
|
}}>
|
|
<Loader2 size={32} style={{ color: 'var(--info)', animation: 'spin 1s linear infinite' }} />
|
|
</div>
|
|
<p style={{ color: 'var(--text-primary)', fontWeight: 500, marginBottom: '0.5rem' }}>
|
|
{pollingProgress ? 'Esperando respuesta del agente remoto...' : 'Enviando comando...'}
|
|
</p>
|
|
{pollingProgress && (
|
|
<div style={{ textAlign: 'center' }}>
|
|
<div style={{
|
|
width: '200px', height: '4px',
|
|
backgroundColor: 'var(--border)',
|
|
borderRadius: '2px',
|
|
overflow: 'hidden',
|
|
marginBottom: '0.5rem'
|
|
}}>
|
|
<div style={{
|
|
width: `${(pollingProgress.attempts / pollingProgress.maxAttempts) * 100}%`,
|
|
height: '100%',
|
|
backgroundColor: 'var(--info)',
|
|
transition: 'width 0.3s ease'
|
|
}} />
|
|
</div>
|
|
<p className="text-xs font-mono" style={{ color: 'var(--text-muted)' }}>
|
|
Polling: {pollingProgress.attempts} / {pollingProgress.maxAttempts}
|
|
</p>
|
|
</div>
|
|
)}
|
|
<style>{`@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }`}</style>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// No result yet
|
|
if (!result) {
|
|
return (
|
|
<div className="result-container" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
|
|
<div style={{ backgroundColor: 'var(--bg-panel)', padding: '2rem', borderRadius: '999px', marginBottom: '1rem', opacity: 0.5 }}>
|
|
<Database size={32} />
|
|
</div>
|
|
<p style={{ color: 'var(--text-muted)' }}>Los resultados aparecerán aquí</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Error state
|
|
if (result.error) {
|
|
return (
|
|
<div className="result-container" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '2rem' }}>
|
|
<div style={{
|
|
width: '4rem', height: '4rem',
|
|
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
|
borderRadius: '50%',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
marginBottom: '1rem'
|
|
}}>
|
|
<AlertCircle size={32} style={{ color: 'var(--error)' }} />
|
|
</div>
|
|
<h3 style={{ color: 'var(--text-primary)', fontWeight: 600, marginBottom: '0.75rem' }}>Error en la ejecución</h3>
|
|
<code style={{
|
|
backgroundColor: 'rgba(69, 10, 10, 0.5)',
|
|
color: '#fecaca',
|
|
padding: '1rem',
|
|
borderRadius: '0.5rem',
|
|
border: '1px solid rgba(127, 29, 29, 0.5)',
|
|
width: '100%',
|
|
maxWidth: '600px',
|
|
whiteSpace: 'pre-wrap',
|
|
fontSize: '0.875rem',
|
|
display: 'block'
|
|
}}>
|
|
{result.error}
|
|
</code>
|
|
{result.command_id && (
|
|
<p className="text-xs font-mono mt-3" style={{ color: 'var(--text-muted)' }}>
|
|
Command ID: #{result.command_id}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Success message (no data rows)
|
|
if (rows.length === 0 && !result.error) {
|
|
if (result.command_id || result.message) {
|
|
return (
|
|
<div className="result-container" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
|
|
<div style={{
|
|
width: '4rem', height: '4rem',
|
|
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
|
borderRadius: '50%',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
marginBottom: '1rem',
|
|
border: '2px solid rgba(16, 185, 129, 0.3)'
|
|
}}>
|
|
<CheckCircle2 size={28} style={{ color: 'var(--success)' }} />
|
|
</div>
|
|
<h3 style={{ color: 'var(--text-primary)', fontWeight: 600, marginBottom: '0.5rem' }}>
|
|
{result.message || 'Consulta ejecutada correctamente'}
|
|
</h3>
|
|
{result.downloadUrl && (
|
|
<div style={{ marginTop: '1.5rem', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
|
|
<a
|
|
href={result.downloadUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center gap-2 px-8 py-3 rounded-lg font-bold transition-all hover:scale-105 shadow-lg shadow-blue-500/20"
|
|
style={{
|
|
background: 'var(--accent)',
|
|
color: 'white',
|
|
textDecoration: 'none',
|
|
fontSize: '1rem'
|
|
}}
|
|
>
|
|
<Download size={20} />
|
|
Descargar Respaldo Ahora
|
|
</a>
|
|
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>
|
|
El archivo se encuentra disponible en el servidor FTP especificado.
|
|
</p>
|
|
</div>
|
|
)}
|
|
{result.command_id && !result.downloadUrl && (
|
|
<p className="font-mono text-xs flex items-center gap-1" style={{ color: 'var(--text-accent)' }}>
|
|
<Hash size={12} /> Command ID: {result.command_id}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="result-container" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
|
|
<FileX size={48} className="mb-4" style={{ opacity: 0.5, color: 'var(--text-secondary)' }} />
|
|
<p style={{ color: 'var(--text-secondary)' }}>La consulta no devolvió resultados.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Large dataset display
|
|
if (rows.length > MAX_DISPLAY_ROWS) {
|
|
return (
|
|
<div className="result-container" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '2rem' }}>
|
|
<div style={{
|
|
width: '5rem', height: '5rem',
|
|
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
|
borderRadius: '50%',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
marginBottom: '1.5rem',
|
|
border: '2px solid rgba(59, 130, 246, 0.3)'
|
|
}}>
|
|
<Download size={32} style={{ color: 'var(--info)' }} />
|
|
</div>
|
|
<h3 style={{ color: 'var(--text-primary)', fontWeight: 600, marginBottom: '0.5rem' }}>Resultado muy grande ({rows.length} filas)</h3>
|
|
<p style={{ color: 'var(--text-muted)', textAlign: 'center', maxWidth: '500px', marginBottom: '1.5rem' }}>
|
|
Para mantener el rendimiento del navegador, los resultados que superan las {MAX_DISPLAY_ROWS} filas no se muestran en pantalla y se descargan automáticamente como CSV.
|
|
</p>
|
|
<button
|
|
onClick={() => handleDownloadCSV(columns, rows)}
|
|
className="flex items-center gap-2 px-6 py-3 rounded-lg font-medium transition-all hover:scale-105"
|
|
style={{
|
|
background: 'var(--accent)',
|
|
color: 'white',
|
|
border: 'none',
|
|
cursor: 'pointer',
|
|
boxShadow: '0 4px 12px rgba(59, 130, 246, 0.3)'
|
|
}}
|
|
>
|
|
<Download size={18} />
|
|
Descargar CSV de nuevo
|
|
</button>
|
|
{result.command_id && (
|
|
<p className="text-xs font-mono mt-6" style={{ color: 'var(--text-muted)' }}>
|
|
Command ID: #{result.command_id}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Data table display - DARK THEME
|
|
return (
|
|
<div className="result-container">
|
|
{/* Stats Header */}
|
|
<div className="result-header">
|
|
<div className="flex gap-4 items-center">
|
|
{result.command_id && (
|
|
<span className="flex items-center gap-1.5 font-mono" style={{ color: 'var(--text-accent)' }}>
|
|
<Hash size={12} /> {result.command_id}
|
|
</span>
|
|
)}
|
|
{result.stats?.row_count !== undefined && (
|
|
<span className="flex items-center gap-1.5" style={{ color: 'var(--text-secondary)' }}>
|
|
<Database size={12} style={{ color: 'var(--success)' }} />
|
|
<span className="font-mono" style={{ color: 'var(--text-primary)' }}>{result.stats.row_count}</span> filas
|
|
</span>
|
|
)}
|
|
{result.stats?.duration && (
|
|
<span className="flex items-center gap-1.5" style={{ color: 'var(--text-secondary)' }}>
|
|
<Clock size={12} style={{ color: 'var(--info)' }} />
|
|
<span className="font-mono" style={{ color: 'var(--text-primary)' }}>{result.stats.duration}</span>
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{/* Copy Button */}
|
|
<button
|
|
onClick={() => handleCopy(columns, rows)}
|
|
className="flex items-center gap-1 px-3 py-1.5 text-xs rounded transition-colors"
|
|
style={{
|
|
background: copied ? 'var(--success)' : 'var(--border)',
|
|
color: copied ? 'white' : 'var(--text-primary)',
|
|
border: 'none',
|
|
cursor: 'pointer'
|
|
}}
|
|
title="Copiar al portapapeles"
|
|
>
|
|
{copied ? <Check size={14} /> : <Copy size={14} />}
|
|
{copied ? 'Copiado!' : 'Copiar'}
|
|
</button>
|
|
{/* Download CSV Button */}
|
|
<button
|
|
onClick={() => handleDownloadCSV(columns, rows)}
|
|
className="flex items-center gap-1 px-3 py-1.5 text-xs rounded transition-colors"
|
|
style={{
|
|
background: 'var(--accent)',
|
|
color: 'white',
|
|
border: 'none',
|
|
cursor: 'pointer'
|
|
}}
|
|
title="Descargar CSV"
|
|
>
|
|
<Download size={14} />
|
|
CSV
|
|
</button>
|
|
<span className="flex items-center gap-1 ml-2">
|
|
<CheckCircle2 size={14} style={{ color: 'var(--success)' }} />
|
|
<span className="text-xs" style={{ color: 'var(--success)' }}>Completado</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Data Table */}
|
|
<div className="table-wrapper">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th style={{ width: '50px', textAlign: 'center' }}>#</th>
|
|
{columns.map((col, idx) => (
|
|
<th key={idx}>{col}</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((row, rIdx) => (
|
|
<tr key={rIdx}>
|
|
<td style={{ textAlign: 'center', color: 'var(--text-muted)', fontSize: '0.75rem' }}>{rIdx + 1}</td>
|
|
{columns.map((col, cIdx) => {
|
|
let val;
|
|
if (Array.isArray(row)) {
|
|
val = row[cIdx];
|
|
} else {
|
|
val = row[col];
|
|
}
|
|
return (
|
|
<td key={cIdx}>
|
|
{val === null ? (
|
|
<span style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>NULL</span>
|
|
) : typeof val === 'object' ? (
|
|
<span style={{ color: 'var(--text-accent)' }}>{JSON.stringify(val)}</span>
|
|
) : (
|
|
String(val)
|
|
)}
|
|
</td>
|
|
);
|
|
})}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|