feat: error log panel - delete, export JSON/CSV, select rows
This commit is contained in:
parent
f451add13c
commit
3934e6a872
|
|
@ -6,6 +6,7 @@ import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
||||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
||||||
|
const supabaseServiceKey = import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY;
|
||||||
|
|
||||||
const DATE_RANGES = [
|
const DATE_RANGES = [
|
||||||
{ value: 'today', label: 'Сегодня' },
|
{ value: 'today', label: 'Сегодня' },
|
||||||
|
|
@ -25,6 +26,24 @@ const ERROR_TONES = {
|
||||||
Warning: 'warning',
|
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() {
|
export default function ErrorLogPanel() {
|
||||||
const [errors, setErrors] = useState([]);
|
const [errors, setErrors] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
@ -33,9 +52,12 @@ export default function ErrorLogPanel() {
|
||||||
const [filterType, setFilterType] = useState('');
|
const [filterType, setFilterType] = useState('');
|
||||||
const [filterRange, setFilterRange] = useState('7d');
|
const [filterRange, setFilterRange] = useState('7d');
|
||||||
const [availableTypes, setAvailableTypes] = useState([]);
|
const [availableTypes, setAvailableTypes] = useState([]);
|
||||||
|
const [selected, setSelected] = useState(new Set());
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
const intervalRef = useRef(null);
|
const intervalRef = useRef(null);
|
||||||
const client = createClient(supabaseUrl, supabaseAnonKey);
|
const client = createClient(supabaseUrl, supabaseAnonKey);
|
||||||
|
const adminClient = createClient(supabaseUrl, supabaseServiceKey);
|
||||||
|
|
||||||
const getRangeStart = (range) => {
|
const getRangeStart = (range) => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
@ -74,30 +96,135 @@ export default function ErrorLogPanel() {
|
||||||
const fmtDate = (iso) => iso ? new Date(iso).toLocaleString('ru-RU') : '—';
|
const fmtDate = (iso) => iso ? new Date(iso).toLocaleString('ru-RU') : '—';
|
||||||
const trunc = (s, n) => !s ? '—' : s.length > n ? s.slice(0, n) + '…' : s;
|
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 handleCopyAll = async () => {
|
||||||
const text = errors.map((e) => {
|
const text = errors.map(formatLogEntry).join('\n\n');
|
||||||
const ts = e.created_at ? new Date(e.created_at).toISOString() : 'NO_TIMESTAMP';
|
try { await navigator.clipboard.writeText(text); }
|
||||||
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 || '-'}`;
|
catch {
|
||||||
}).join('\n\n');
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(text);
|
|
||||||
} catch {
|
|
||||||
const ta = document.createElement('textarea');
|
const ta = document.createElement('textarea');
|
||||||
ta.value = text;
|
ta.value = text; document.body.appendChild(ta); ta.select();
|
||||||
document.body.appendChild(ta);
|
document.execCommand('copy'); document.body.removeChild(ta);
|
||||||
ta.select();
|
|
||||||
document.execCommand('copy');
|
|
||||||
document.body.removeChild(ta);
|
|
||||||
}
|
}
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 2000);
|
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 (
|
return (
|
||||||
<Panel>
|
<Panel>
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '0.75rem', marginBottom: '1rem' }}>
|
<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' }}>
|
<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!">
|
<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>)}
|
{DATE_RANGES.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||||
</Select>
|
</Select>
|
||||||
|
|
@ -106,25 +233,32 @@ export default function ErrorLogPanel() {
|
||||||
{availableTypes.map((t) => <option key={t} value={t}>{t}</option>)}
|
{availableTypes.map((t) => <option key={t} value={t}>{t}</option>)}
|
||||||
</Select>
|
</Select>
|
||||||
<span style={{ color: 'var(--color-text-muted, #94a3b8)', fontSize: '0.85rem' }}>
|
<span style={{ color: 'var(--color-text-muted, #94a3b8)', fontSize: '0.85rem' }}>
|
||||||
{errors.length} ошибок
|
{errors.length} ошибок {selected.size > 0 && `(${selected.size} выбр.)`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||||
<button onClick={fetchErrors} style={{
|
<button onClick={fetchErrors} style={btnBase} title="Обновить">↻</button>
|
||||||
background: 'transparent', border: '1px solid var(--color-border, #334155)',
|
<button onClick={handleCopyAll} disabled={errors.length === 0} style={{ ...btnAccent, opacity: errors.length === 0 ? 0.4 : 1 }}>
|
||||||
borderRadius: '9999px', padding: '0.4rem 0.75rem', cursor: 'pointer',
|
📋 Копировать всё
|
||||||
fontSize: '0.85rem', color: 'var(--color-text-muted, #94a3b8)',
|
|
||||||
}}>
|
|
||||||
↻
|
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleCopyAll} disabled={errors.length === 0} style={{
|
{selected.size > 0 && (
|
||||||
background: copied ? 'var(--color-accent, #22c55e)' : 'var(--color-accent-soft, rgba(18,128,92,0.15))',
|
<button onClick={handleCopySelected} style={btnAccent}>
|
||||||
color: copied ? '#fff' : 'var(--color-accent, #22c55e)',
|
📋 Копировать выбр.
|
||||||
border: '1px solid var(--color-accent, #22c55e)',
|
</button>
|
||||||
borderRadius: '9999px', padding: '0.4rem 0.75rem', cursor: 'pointer',
|
)}
|
||||||
fontSize: '0.85rem', fontWeight: 600,
|
<button onClick={downloadJSON} disabled={errors.length === 0} style={{ ...btnBase, opacity: errors.length === 0 ? 0.4 : 1 }}>
|
||||||
}}>
|
⬇ JSON
|
||||||
{copied ? '✓ Скопировано' : '📋 Копировать всё'}
|
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -154,21 +288,31 @@ export default function ErrorLogPanel() {
|
||||||
|
|
||||||
{errors.map((err) => {
|
{errors.map((err) => {
|
||||||
const isExpanded = expandedId === err.id;
|
const isExpanded = expandedId === err.id;
|
||||||
|
const isSelected = selected.has(err.id);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={err.id}
|
key={err.id}
|
||||||
style={{ borderBottom: '1px solid var(--color-border, #334155)', cursor: 'pointer' }}
|
style={{ borderBottom: '1px solid var(--color-border, #334155)' }}
|
||||||
onClick={() => setExpandedId(isExpanded ? null : err.id)}
|
|
||||||
>
|
>
|
||||||
{/* Summary */}
|
<div
|
||||||
<div style={{
|
style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: '1.4fr 1fr 2.5fr 1.5fr',
|
gridTemplateColumns: '2rem 1.4fr 1fr 2.5fr 1.5fr',
|
||||||
gap: '0.5rem',
|
gap: '0.5rem',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
padding: '0.55rem 0.75rem',
|
padding: '0.55rem 0.75rem',
|
||||||
fontSize: '0.88rem',
|
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' }}>
|
<span style={{ fontSize: '0.8rem', color: 'var(--color-text-muted, #94a3b8)', whiteSpace: 'nowrap' }}>
|
||||||
{fmtDate(err.created_at)}
|
{fmtDate(err.created_at)}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -183,7 +327,6 @@ export default function ErrorLogPanel() {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Expanded */}
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '0.75rem 1rem 1rem',
|
padding: '0.75rem 1rem 1rem',
|
||||||
|
|
@ -236,6 +379,11 @@ export default function ErrorLogPanel() {
|
||||||
<div><strong>UA:</strong> {trunc(err.user_agent, 60)}</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>}
|
{err.user_id && <div style={{ gridColumn: '1 / -1' }}><strong>User ID:</strong> {err.user_id}</div>}
|
||||||
</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>
|
</div>
|
||||||
|
|
@ -243,6 +391,17 @@ export default function ErrorLogPanel() {
|
||||||
})}
|
})}
|
||||||
</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>
|
</Panel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue