supersam/src/components/admin/ErrorLogPanel.jsx

404 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Panel } from '../UI/Panel';
import { Badge } from '../UI/Badge';
import { Select } from '../UI/Select';
import { supabase } from '../../supabaseClient';
const DATE_RANGES = [
{ value: 'today', label: 'Сегодня' },
{ value: '7d', label: '7 дней' },
{ value: '30d', label: '30 дней' },
{ value: 'all', label: 'Всё время' },
];
const ERROR_TONES = {
Error: 'danger',
TypeError: 'warning',
ReferenceError: 'warning',
SyntaxError: 'danger',
RangeError: 'warning',
NetworkError: 'info',
UnhandledRejection: 'danger',
Warning: 'warning',
};
function formatLogEntry(e) {
const ts = e.created_at ? new Date(e.created_at).toISOString() : 'NO_TIMESTAMP';
const lines = [
`[${ts}] ${e.error_type || 'Unknown'}: ${e.message || 'No message'}`,
` URL: ${e.url || '-'}`,
` Component: ${e.component || '-'}`,
` Line: ${e.line_number ?? '-'}:${e.column_number ?? '-'}`,
];
if (e.user_id) lines.push(` User: ${e.user_id}`);
if (e.props) lines.push(` Props: ${e.props}`);
if (e.stack) {
lines.push(' Stack:');
e.stack.split('\n').forEach(l => lines.push(' ' + l));
}
lines.push(` UA: ${e.user_agent || '-'}`);
return lines.join('\n');
}
export default function ErrorLogPanel() {
const [errors, setErrors] = useState([]);
const [loading, setLoading] = useState(true);
const [fetchError, setFetchError] = useState(null);
const [expandedId, setExpandedId] = useState(null);
const [filterType, setFilterType] = useState('');
const [filterRange, setFilterRange] = useState('7d');
const [availableTypes, setAvailableTypes] = useState([]);
const [selected, setSelected] = useState(new Set());
const [copied, setCopied] = useState(false);
const [deleting, setDeleting] = useState(false);
const intervalRef = useRef(null);
const getRangeStart = (range) => {
const now = new Date();
switch (range) {
case 'today': return new Date(now.getFullYear(), now.getMonth(), now.getDate()).toISOString();
case '7d': return new Date(now.getTime() - 7 * 86400000).toISOString();
case '30d': return new Date(now.getTime() - 30 * 86400000).toISOString();
default: return null;
}
};
const fetchErrors = useCallback(async () => {
setFetchError(null);
let query = supabase.from('client_error_logs').select('*').order('created_at', { ascending: false });
const rangeStart = getRangeStart(filterRange);
if (rangeStart) query = query.gte('created_at', rangeStart);
if (filterType) query = query.eq('error_type', filterType);
const { data, error: err } = await query;
if (err) { setFetchError(err.message); setErrors([]); }
else {
setErrors(data || []);
const types = [...new Set((data || []).map((e) => e.error_type).filter(Boolean))].sort();
setAvailableTypes(types);
}
setLoading(false);
}, [filterRange, filterType]);
useEffect(() => { setLoading(true); fetchErrors(); }, [fetchErrors]);
useEffect(() => {
if (intervalRef.current) clearInterval(intervalRef.current);
intervalRef.current = setInterval(fetchErrors, 30000);
return () => { if (intervalRef.current) clearInterval(intervalRef.current); };
}, [fetchErrors]);
const fmtDate = (iso) => iso ? new Date(iso).toLocaleString('ru-RU') : '—';
const trunc = (s, n) => !s ? '—' : s.length > n ? s.slice(0, n) + '…' : s;
const toggleSelect = (id) => {
setSelected(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
};
const toggleSelectAll = () => {
if (selected.size === errors.length) {
setSelected(new Set());
} else {
setSelected(new Set(errors.map(e => e.id)));
}
};
const handleCopyAll = async () => {
const text = errors.map(formatLogEntry).join('\n\n');
try { await navigator.clipboard.writeText(text); }
catch {
const ta = document.createElement('textarea');
ta.value = text; document.body.appendChild(ta); ta.select();
document.execCommand('copy'); document.body.removeChild(ta);
}
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleCopySelected = async () => {
const text = errors.filter(e => selected.has(e.id)).map(formatLogEntry).join('\n\n');
if (!text) return;
try { await navigator.clipboard.writeText(text); }
catch {
const ta = document.createElement('textarea');
ta.value = text; document.body.appendChild(ta); ta.select();
document.execCommand('copy'); document.body.removeChild(ta);
}
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleDeleteSelected = async () => {
if (selected.size === 0) return;
if (!confirm(`Удалить ${selected.size} записей?`)) return;
setDeleting(true);
const { error: err } = await supabase
.from('client_error_logs')
.delete()
.in('id', Array.from(selected));
if (err) { setFetchError('Ошибка удаления: ' + err.message); }
else { setSelected(new Set()); fetchErrors(); }
setDeleting(false);
};
const handleDeleteAll = async () => {
if (!confirm('Удалить ВСЕ записи об ошибках? Это необратимо.')) return;
setDeleting(true);
const { error: err } = await supabase
.from('client_error_logs')
.delete()
.neq('id', '00000000-0000-0000-0000-000000000000');
if (err) { setFetchError('Ошибка удаления: ' + err.message); }
else { setSelected(new Set()); fetchErrors(); }
setDeleting(false);
};
const handleDeleteOne = async (id) => {
if (!confirm('Удалить эту запись?')) return;
setDeleting(true);
const { error: err } = await supabase
.from('client_error_logs')
.delete()
.eq('id', id);
if (err) { setFetchError('Ошибка удаления: ' + err.message); }
else { setExpandedId(null); fetchErrors(); }
setDeleting(false);
};
const downloadJSON = () => {
const data = selected.size > 0
? errors.filter(e => selected.has(e.id))
: errors;
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `errors_${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
};
const downloadCSV = () => {
const data = selected.size > 0
? errors.filter(e => selected.has(e.id))
: errors;
const headers = ['created_at', 'error_type', 'message', 'url', 'component', 'line_number', 'column_number', 'user_id', 'stack', 'props', 'user_agent'];
const escape = (v) => {
const s = v == null ? '' : String(v);
if (s.includes(',') || s.includes('"') || s.includes('\n')) return '"' + s.replace(/"/g, '""') + '"';
return s;
};
const rows = data.map(e => headers.map(h => escape(e[h])).join(','));
const csv = [headers.join(','), ...rows].join('\n');
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `errors_${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
};
const btnBase = {
borderRadius: '9999px', padding: '0.4rem 0.75rem', cursor: 'pointer',
fontSize: '0.85rem', fontWeight: 600, border: '1px solid var(--color-border, #334155)',
background: 'transparent', color: 'var(--color-text, #e2e8f0)',
};
const btnDanger = { ...btnBase, borderColor: 'var(--color-danger, #ef4444)', color: 'var(--color-danger, #ef4444)' };
const btnAccent = { ...btnBase, borderColor: 'var(--color-accent, #22c55e)', color: 'var(--color-accent, #22c55e)' };
return (
<Panel>
{/* Toolbar */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '0.75rem', marginBottom: '1rem' }}>
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', cursor: 'pointer', fontSize: '0.85rem' }}>
<input type="checkbox" checked={selected.size === errors.length && errors.length > 0} onChange={toggleSelectAll} />
Все
</label>
<Select value={filterRange} onChange={(e) => setFilterRange(e.target.value)} className="min-w-[100px]! text-sm! py-2!">
{DATE_RANGES.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</Select>
<Select value={filterType} onChange={(e) => setFilterType(e.target.value)} className="min-w-[140px]! text-sm! py-2!">
<option value="">Все типы</option>
{availableTypes.map((t) => <option key={t} value={t}>{t}</option>)}
</Select>
<span style={{ color: 'var(--color-text-muted, #94a3b8)', fontSize: '0.85rem' }}>
{errors.length} ошибок {selected.size > 0 && `(${selected.size} выбр.)`}
</span>
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<button onClick={fetchErrors} style={btnBase} title="Обновить"></button>
<button onClick={handleCopyAll} disabled={errors.length === 0} style={{ ...btnAccent, opacity: errors.length === 0 ? 0.4 : 1 }}>
📋 Копировать всё
</button>
{selected.size > 0 && (
<button onClick={handleCopySelected} style={btnAccent}>
📋 Копировать выбр.
</button>
)}
<button onClick={downloadJSON} disabled={errors.length === 0} style={{ ...btnBase, opacity: errors.length === 0 ? 0.4 : 1 }}>
JSON
</button>
<button onClick={downloadCSV} disabled={errors.length === 0} style={{ ...btnBase, opacity: errors.length === 0 ? 0.4 : 1 }}>
CSV
</button>
{selected.size > 0 && (
<button onClick={handleDeleteSelected} disabled={deleting} style={{ ...btnDanger, opacity: deleting ? 0.5 : 1 }}>
🗑 Удалить выбр.
</button>
)}
<button onClick={handleDeleteAll} disabled={deleting || errors.length === 0} style={{ ...btnDanger, opacity: deleting || errors.length === 0 ? 0.4 : 1 }}>
🗑 Удалить все
</button>
</div>
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-muted, #94a3b8)', marginBottom: '0.5rem' }}>
Автообновление каждые 30 сек
</div>
{fetchError && (
<div style={{
background: 'rgba(201,61,61,0.12)', color: 'var(--color-danger, #ef4444)',
borderRadius: '12px', padding: '0.75rem 1rem', marginBottom: '1rem', fontSize: '0.9rem',
}}>
{fetchError}
</div>
)}
{loading ? (
<div style={{ textAlign: 'center', padding: '2rem', color: 'var(--color-text-muted, #94a3b8)' }}>Загрузка</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0' }}>
{errors.length === 0 && (
<div style={{ textAlign: 'center', padding: '2rem', color: 'var(--color-text-muted, #94a3b8)' }}>
Нет ошибок за выбранный период
</div>
)}
{errors.map((err) => {
const isExpanded = expandedId === err.id;
const isSelected = selected.has(err.id);
return (
<div
key={err.id}
style={{ borderBottom: '1px solid var(--color-border, #334155)' }}
>
<div
style={{
display: 'grid',
gridTemplateColumns: '2rem 1.4fr 1fr 2.5fr 1.5fr',
gap: '0.5rem',
alignItems: 'center',
padding: '0.55rem 0.75rem',
fontSize: '0.88rem',
cursor: 'pointer',
background: isSelected ? 'rgba(34,197,94,0.08)' : 'transparent',
}}
onClick={() => setExpandedId(isExpanded ? null : err.id)}
>
<input
type="checkbox"
checked={isSelected}
onClick={(e) => e.stopPropagation()}
onChange={() => toggleSelect(err.id)}
/>
<span style={{ fontSize: '0.8rem', color: 'var(--color-text-muted, #94a3b8)', whiteSpace: 'nowrap' }}>
{fmtDate(err.created_at)}
</span>
<Badge tone={ERROR_TONES[err.error_type] || 'neutral'}>
{err.error_type || 'Unknown'}
</Badge>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={err.message}>
{trunc(err.message, 120)}
</span>
<span style={{ fontSize: '0.8rem', color: 'var(--color-text-muted, #94a3b8)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={err.url}>
{trunc(err.url, 40)}
</span>
</div>
{isExpanded && (
<div style={{
padding: '0.75rem 1rem 1rem',
background: 'var(--color-surface-strong, #1e293b)',
fontSize: '0.85rem',
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
}}>
<div>
<strong>Сообщение:</strong>
<pre style={{
margin: '0.25rem 0 0', whiteSpace: 'pre-wrap', wordBreak: 'break-word',
fontFamily: 'monospace', fontSize: '0.82rem',
}}>
{err.message || '—'}
</pre>
</div>
<div>
<strong>Стек:</strong>
<pre style={{
margin: '0.25rem 0 0', whiteSpace: 'pre-wrap', wordBreak: 'break-word',
fontFamily: 'monospace', fontSize: '0.82rem',
background: 'var(--color-surface, #0f172a)', padding: '0.5rem',
borderRadius: '8px', border: '1px solid var(--color-border, #334155)',
maxHeight: '250px', overflow: 'auto',
}}>
{err.stack || '—'}
</pre>
</div>
<div>
<strong>Props:</strong>
<pre style={{
margin: '0.25rem 0 0', whiteSpace: 'pre-wrap', wordBreak: 'break-word',
fontFamily: 'monospace', fontSize: '0.82rem',
background: 'var(--color-surface, #0f172a)', padding: '0.5rem',
borderRadius: '8px', border: '1px solid var(--color-border, #334155)',
maxHeight: '180px', overflow: 'auto',
}}>
{err.props || '—'}
</pre>
</div>
<div style={{
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.5rem',
fontSize: '0.82rem', color: 'var(--color-text-muted, #94a3b8)',
}}>
<div><strong>URL:</strong> {err.url || ''}</div>
<div><strong>Компонент:</strong> {err.component || ''}</div>
<div><strong>Строка:</strong> {err.line_number ?? ''}:{err.column_number ?? ''}</div>
<div><strong>UA:</strong> {trunc(err.user_agent, 60)}</div>
{err.user_id && <div style={{ gridColumn: '1 / -1' }}><strong>User ID:</strong> {err.user_id}</div>}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button onClick={() => handleDeleteOne(err.id)} disabled={deleting} style={{ ...btnDanger, fontSize: '0.8rem' }}>
Удалить запись
</button>
</div>
</div>
)}
</div>
);
})}
</div>
)}
{copied && (
<div style={{
position: 'fixed', bottom: '1rem', right: '1rem',
background: 'var(--color-accent, #22c55e)', color: '#fff',
padding: '0.5rem 1rem', borderRadius: '9999px', fontSize: '0.85rem',
fontWeight: 600, zIndex: 1000,
}}>
Скопировано!
</div>
)}
</Panel>
);
}