import React, { useState, useEffect, useCallback, useRef } from 'react'; import { Panel } from '../UI/Panel'; import { Badge } from '../UI/Badge'; import { Select } from '../UI/Select'; import { createClient } from '@supabase/supabase-js'; const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY; 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 client = createClient(supabaseUrl, supabaseAnonKey); 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 = client.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 client .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 client .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 client .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 ( {/* Toolbar */}
{errors.length} ошибок {selected.size > 0 && `(${selected.size} выбр.)`}
{selected.size > 0 && ( )} {selected.size > 0 && ( )}
Автообновление каждые 30 сек
{fetchError && (
{fetchError}
)} {loading ? (
Загрузка…
) : (
{errors.length === 0 && (
Нет ошибок за выбранный период
)} {errors.map((err) => { const isExpanded = expandedId === err.id; const isSelected = selected.has(err.id); return (
setExpandedId(isExpanded ? null : err.id)} > e.stopPropagation()} onChange={() => toggleSelect(err.id)} /> {fmtDate(err.created_at)} {err.error_type || 'Unknown'} {trunc(err.message, 120)} {trunc(err.url, 40)}
{isExpanded && (
Сообщение:
                        {err.message || '—'}
                      
Стек:
                        {err.stack || '—'}
                      
Props:
                        {err.props || '—'}
                      
URL: {err.url || '—'}
Компонент: {err.component || '—'}
Строка: {err.line_number ?? '—'}:{err.column_number ?? '—'}
UA: {trunc(err.user_agent, 60)}
{err.user_id &&
User ID: {err.user_id}
}
)}
); })}
)} {copied && (
Скопировано!
)}
); }