feat: AdminLogs — error log viewer
AdminLogs.js:
Топ-5 частых ошибок с прогресс-баром
Фильтр по источнику (все/генерация/AI/публикация)
Список с раскрываемыми карточками:
- Левая граница цветом по типу (timeout/auth/model/other)
- Краткое и полное описание ошибки
- Контекстные подсказки (ссылка на настройки, объяснение причины)
Классификация: Таймаут/Rate limit/Модель/Пустой ответ/Сеть/Авторизация
AdminPanel: раздел Логи ошибок с AlertTriangle иконкой
API route: /api/admin/logs
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { Settings2, CreditCard, TrendingUp, Users, ChevronRight, Loader2, Eye, EyeOff, Save, RefreshCw, Check, AlertCircle, BarChart3, ArrowLeft, Zap, Tag } from 'lucide-react';
|
||||
import { Settings2, CreditCard, TrendingUp, Users, ChevronRight, Loader2, Eye, EyeOff, Save, RefreshCw, Check, AlertCircle, BarChart3, ArrowLeft, Zap, Tag, AlertTriangle } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import AdminBilling from './admin/AdminBilling';
|
||||
import AdminUsers from './admin/AdminUsers';
|
||||
import AdminPromos from './admin/AdminPromos';
|
||||
import AdminQueue from './admin/AdminQueue';
|
||||
import AdminLogs from './admin/AdminLogs';
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Sidebar navigation
|
||||
@@ -17,6 +18,7 @@ const SECTIONS = [
|
||||
{ id: 'payments', label: 'ЮKassa', icon: CreditCard, desc: 'Ключи для приёма оплат' },
|
||||
{ id: 'spending', label: 'Расходы AI', icon: TrendingUp, desc: 'aiprimetech + routerai' },
|
||||
{ id: 'queue', label: 'Очередь', icon: Zap, desc: 'Задачи генерации, ошибки' },
|
||||
{ id: 'logs', label: 'Логи ошибок', icon: AlertTriangle, desc: 'Последние сбои и проблемы' },
|
||||
{ id: 'plans', label: 'Тарифы', icon: BarChart3, desc: 'Планы, кредиты, операции' },
|
||||
{ id: 'promos', label: 'Промокоды', icon: Tag, desc: 'Коды для кредитов и скидок' },
|
||||
{ id: 'billing', label: 'Пользователи', icon: Users, desc: 'Балансы и кредиты' },
|
||||
@@ -68,6 +70,7 @@ export default function AdminPanel({ initialSection = 'settings' }) {
|
||||
{section === 'payments' && <SettingsSection categories={['payments']} />}
|
||||
{section === 'spending' && <SpendingSection />}
|
||||
{section === 'queue' && <AdminQueue />}
|
||||
{section === 'logs' && <AdminLogs />}
|
||||
{section === 'plans' && <PlansSection />}
|
||||
{section === 'promos' && <AdminPromos />}
|
||||
{section === 'billing' && <AdminUsers />}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { RefreshCw, Loader2, AlertTriangle, Cpu, Send, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
const SOURCE_CONFIG = {
|
||||
generation: { icon: '⚙️', label: 'Генерация', color: 'text-purple-400', bg: 'bg-purple-500/10' },
|
||||
ai_provider:{ icon: '🤖', label: 'AI провайдер', color: 'text-blue-400', bg: 'bg-blue-500/10' },
|
||||
publish: { icon: '📤', label: 'Публикация', color: 'text-orange-400', bg: 'bg-orange-500/10' },
|
||||
};
|
||||
|
||||
// Категоризируем ошибку по тексту
|
||||
function classifyError(msg) {
|
||||
if (!msg) return { type: 'unknown', label: 'Неизвестно', color: 'text-gray-400' };
|
||||
const m = msg.toLowerCase();
|
||||
if (m.includes('timeout')) return { type: 'timeout', label: 'Таймаут', color: 'text-yellow-400' };
|
||||
if (m.includes('rate limit')) return { type: 'ratelimit',label: 'Rate limit', color: 'text-orange-400' };
|
||||
if (m.includes('not supported')) return { type: 'model', label: 'Модель', color: 'text-red-400' };
|
||||
if (m.includes('empty response')) return { type: 'empty', label: 'Пустой ответ', color: 'text-red-400' };
|
||||
if (m.includes('network') || m.includes('connect')) return { type: 'network', label: 'Сеть', color: 'text-orange-400' };
|
||||
if (m.includes('auth') || m.includes('key') || m.includes('401')) return { type: 'auth', label: 'Авторизация', color: 'text-red-400' };
|
||||
return { type: 'other', label: 'Другое', color: 'text-gray-400' };
|
||||
}
|
||||
|
||||
function timeAgo(s) {
|
||||
const diff = Date.now() - new Date(s);
|
||||
if (diff < 60000) return Math.floor(diff / 1000) + 'с назад';
|
||||
if (diff < 3600000) return Math.floor(diff / 60000) + 'м назад';
|
||||
if (diff < 86400000)return Math.floor(diff / 3600000) + 'ч назад';
|
||||
return new Date(s).toLocaleString('ru-RU', { day:'2-digit', month:'2-digit', hour:'2-digit', minute:'2-digit' });
|
||||
}
|
||||
|
||||
export default function AdminLogs() {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState('all'); // all | generation | ai_provider | publish
|
||||
const [expanded, setExpanded] = useState(null);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/logs?limit=100').then(r => r.json());
|
||||
setData(res);
|
||||
} catch {}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const errors = (data?.errors || []).filter(e =>
|
||||
filter === 'all' || e.source === filter
|
||||
);
|
||||
|
||||
const counts = (data?.errors || []).reduce((acc, e) => {
|
||||
acc[e.source] = (acc[e.source] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-semibold">Логи ошибок</h2>
|
||||
<button onClick={load} className="btn-ghost p-2">
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && !data && (
|
||||
<div className="py-8 text-center"><Loader2 className="w-5 h-5 animate-spin mx-auto text-accent" /></div>
|
||||
)}
|
||||
|
||||
{data && (<>
|
||||
{/* Топ ошибок */}
|
||||
{data.topErrors?.length > 0 && (
|
||||
<div className="card p-4">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-3">Частые ошибки</p>
|
||||
<div className="space-y-2">
|
||||
{data.topErrors.map((e, i) => {
|
||||
const cls = classifyError(e.msg);
|
||||
const pct = Math.round((e.cnt / data.total) * 100);
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<div className="w-16 text-right text-xs font-mono text-gray-400">{e.cnt}×</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs text-gray-200 truncate">{e.msg}</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<div className="flex-1 h-1 bg-surface2 rounded-full">
|
||||
<div className="h-1 bg-accent/60 rounded-full" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className={`text-xs ${cls.color}`}>{cls.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Статистика + фильтр */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button onClick={() => setFilter('all')}
|
||||
className={`px-2.5 py-1 rounded-lg text-xs transition-colors ${filter === 'all' ? 'bg-accent/10 text-accent font-medium' : 'text-gray-500 hover:text-gray-300'}`}>
|
||||
Все ({data.total})
|
||||
</button>
|
||||
{Object.entries(SOURCE_CONFIG).map(([k, cfg]) => (
|
||||
<button key={k} onClick={() => setFilter(k)}
|
||||
className={`px-2.5 py-1 rounded-lg text-xs transition-colors flex items-center gap-1 ${filter === k ? `${cfg.bg} ${cfg.color} font-medium` : 'text-gray-500 hover:text-gray-300'}`}>
|
||||
{cfg.icon} {cfg.label} {counts[k] ? `(${counts[k]})` : ''}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Список */}
|
||||
{errors.length === 0 && (
|
||||
<div className="py-12 text-center text-gray-500">
|
||||
<AlertTriangle className="w-10 h-10 mx-auto mb-3 opacity-20" />
|
||||
<div className="text-sm">Ошибок не найдено 🎉</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{errors.map((err, i) => {
|
||||
const src = SOURCE_CONFIG[err.source] || SOURCE_CONFIG.generation;
|
||||
const cls = classifyError(err.message);
|
||||
const isOpen = expanded === i;
|
||||
const shortMsg = err.message?.split('\n')[0]?.slice(0, 100) || 'Unknown error';
|
||||
|
||||
return (
|
||||
<div key={i} className={`card border-l-2 overflow-hidden transition-all ${
|
||||
cls.type === 'timeout' ? 'border-yellow-500/40' :
|
||||
cls.type === 'auth' ? 'border-red-500/60' :
|
||||
cls.type === 'model' ? 'border-red-400/40' :
|
||||
'border-gray-600'
|
||||
}`}>
|
||||
<button
|
||||
onClick={() => setExpanded(isOpen ? null : i)}
|
||||
className="w-full text-left px-4 py-3 flex items-start gap-3 hover:bg-surface2/30 transition-colors"
|
||||
>
|
||||
<span className="text-base shrink-0 mt-0.5">{src.icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5 flex-wrap">
|
||||
<span className={`text-xs font-medium ${src.color}`}>{src.label}</span>
|
||||
<span className="text-xs text-gray-500">·</span>
|
||||
<span className="text-xs text-gray-400 font-mono">{err.operation}</span>
|
||||
{err.user_email && (
|
||||
<>
|
||||
<span className="text-xs text-gray-500">·</span>
|
||||
<span className="text-xs text-gray-500">{err.user_email}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-xs text-gray-600 ml-auto">{timeAgo(err.created_at)}</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-200">{shortMsg}</div>
|
||||
{err.context && (
|
||||
<div className="text-xs text-gray-500 mt-0.5 truncate">{err.context}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 mt-1">
|
||||
{isOpen
|
||||
? <ChevronUp className="w-3.5 h-3.5 text-gray-500" />
|
||||
: <ChevronDown className="w-3.5 h-3.5 text-gray-500" />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="px-4 pb-3 border-t border-border bg-surface2/30">
|
||||
<div className="mt-2 space-y-1.5 text-xs">
|
||||
<div className="flex gap-2">
|
||||
<span className="text-gray-500 w-20 shrink-0">ID:</span>
|
||||
<span className="font-mono text-gray-300">{err.entity_id}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="text-gray-500 w-20 shrink-0">Источник:</span>
|
||||
<span className="text-gray-300">{err.source}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="text-gray-500 w-20 shrink-0">Операция:</span>
|
||||
<span className="font-mono text-gray-300">{err.operation}</span>
|
||||
</div>
|
||||
{err.context && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-gray-500 w-20 shrink-0">Контекст:</span>
|
||||
<span className="text-gray-300 break-all">{err.context}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<span className="text-gray-500 w-20 shrink-0">Ошибка:</span>
|
||||
<span className="text-red-300 break-all font-mono">{err.message}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="text-gray-500 w-20 shrink-0">Время:</span>
|
||||
<span className="text-gray-300">
|
||||
{new Date(err.created_at).toLocaleString('ru-RU')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="text-gray-500 w-20 shrink-0">Тип ошибки:</span>
|
||||
<span className={`${cls.color} font-medium`}>{cls.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Действие для ошибок модели */}
|
||||
{cls.type === 'model' && err.source === 'ai_provider' && (
|
||||
<div className="mt-2 p-2 rounded bg-red-500/10 border border-red-500/20 text-xs text-red-300">
|
||||
💡 Проверь настройку AI_IMAGE_MODEL_VIA_RESPONSES в{' '}
|
||||
<a href="/system?section=settings" className="underline">Настройках API</a>
|
||||
</div>
|
||||
)}
|
||||
{cls.type === 'timeout' && (
|
||||
<div className="mt-2 p-2 rounded bg-yellow-500/10 border border-yellow-500/20 text-xs text-yellow-300">
|
||||
💡 Таймаут {err.operation?.includes('chat') ? 'текстовой генерации' : 'изображений'} — возможны проблемы у провайдера
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user