diff --git a/src/components/admin/ErrorLogPanel.jsx b/src/components/admin/ErrorLogPanel.jsx index 73612dd..d5453db 100644 --- a/src/components/admin/ErrorLogPanel.jsx +++ b/src/components/admin/ErrorLogPanel.jsx @@ -6,6 +6,7 @@ import { createClient } from '@supabase/supabase-js'; const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY; +const supabaseServiceKey = import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY; const DATE_RANGES = [ { value: 'today', label: 'Сегодня' }, @@ -25,6 +26,24 @@ const ERROR_TONES = { 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); @@ -33,9 +52,12 @@ export default function ErrorLogPanel() { 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 adminClient = createClient(supabaseUrl, supabaseServiceKey); const getRangeStart = (range) => { const now = new Date(); @@ -74,30 +96,135 @@ export default function ErrorLogPanel() { 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((e) => { - const ts = e.created_at ? new Date(e.created_at).toISOString() : 'NO_TIMESTAMP'; - return `[${ts}] ${e.error_type || 'Unknown'}: ${e.message || 'No message'} | URL: ${e.url || '-'} | Component: ${e.component || '-'} | Line: ${e.line_number ?? '-'}:${e.column_number ?? '-'} | Stack: ${e.stack || '-'} | Props: ${e.props || '-'} | UA: ${e.user_agent || '-'}`; - }).join('\n\n'); - try { - await navigator.clipboard.writeText(text); - } catch { + 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); + 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 adminClient + .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 adminClient + .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 adminClient + .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 */}
+ @@ -106,25 +233,32 @@ export default function ErrorLogPanel() { {availableTypes.map((t) => )} - {errors.length} ошибок + {errors.length} ошибок {selected.size > 0 && `(${selected.size} выбр.)`}
-
- + - + )} + + + {selected.size > 0 && ( + + )} +
@@ -154,21 +288,31 @@ export default function ErrorLogPanel() { {errors.map((err) => { const isExpanded = expandedId === err.id; + const isSelected = selected.has(err.id); return (
setExpandedId(isExpanded ? null : err.id)} + style={{ borderBottom: '1px solid var(--color-border, #334155)' }} > - {/* Summary */} -
+
setExpandedId(isExpanded ? null : err.id)} + > + e.stopPropagation()} + onChange={() => toggleSelect(err.id)} + /> {fmtDate(err.created_at)} @@ -183,7 +327,6 @@ export default function ErrorLogPanel() {
- {/* Expanded */} {isExpanded && (
UA: {trunc(err.user_agent, 60)}
{err.user_id &&
User ID: {err.user_id}
}
+
+ +
)} @@ -243,6 +391,17 @@ export default function ErrorLogPanel() { })} )} + + {copied && ( +
+ Скопировано! +
+ )}
); -} \ No newline at end of file +}