feat: AdminQueue — generation queue UI

AdminQueue.js: статистика по статусам + список 30 последних задач
  4 счётчика (done/processing/pending/failed) с цветами
  Алерт для застрявших задач + кнопка Сбросить
  Фильтр по статусу, retry для failed задач
  Детали: тип, тема, ошибка, токены, время
AdminPanel: раздел Очередь между Движком и Тарифами
API routes: /api/admin/queue (GET+DELETE), /api/admin/queue/[id]/retry
This commit is contained in:
Ник (Claude)
2026-06-13 10:14:10 +03:00
parent b620927c25
commit 92872ed59c
4 changed files with 222 additions and 2 deletions
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
export async function POST(req, { params }) {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const res = await fetch(`${ENGINE_URL}/api/admin/queue/${params.id}/retry`, {
method: 'POST',
headers: { 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(user.id) },
});
return NextResponse.json(await res.json());
}
+20
View File
@@ -0,0 +1,20 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
const h = (uid) => ({ 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(uid) });
export async function GET() {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const res = await fetch(`${ENGINE_URL}/api/admin/queue`, { headers: h(user.id), cache: 'no-store' });
return NextResponse.json(await res.json());
}
export async function DELETE() {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const res = await fetch(`${ENGINE_URL}/api/admin/queue/stuck`, { method: 'DELETE', headers: h(user.id) });
return NextResponse.json(await res.json());
}
+5 -2
View File
@@ -3,8 +3,9 @@ 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 Link from 'next/link';
import AdminBilling from './admin/AdminBilling';
import AdminUsers from './admin/AdminUsers';
import AdminPromos from './admin/AdminPromos';
import AdminUsers from './admin/AdminUsers';
import AdminPromos from './admin/AdminPromos';
import AdminQueue from './admin/AdminQueue';
// ──────────────────────────────────────────────────────────────
// Sidebar navigation
@@ -15,6 +16,7 @@ const SECTIONS = [
{ id: 'engine', label: 'Движок', icon: Zap, desc: 'URL, Telegram, авто-черновики' },
{ id: 'payments', label: 'ЮKassa', icon: CreditCard, desc: 'Ключи для приёма оплат' },
{ id: 'spending', label: 'Расходы AI', icon: TrendingUp, desc: 'aiprimetech + routerai' },
{ id: 'queue', label: 'Очередь', icon: Zap, desc: 'Задачи генерации, ошибки' },
{ id: 'plans', label: 'Тарифы', icon: BarChart3, desc: 'Планы, кредиты, операции' },
{ id: 'promos', label: 'Промокоды', icon: Tag, desc: 'Коды для кредитов и скидок' },
{ id: 'billing', label: 'Пользователи', icon: Users, desc: 'Балансы и кредиты' },
@@ -65,6 +67,7 @@ export default function AdminPanel({ initialSection = 'settings' }) {
{section === 'engine' && <SettingsSection categories={['engine']} />}
{section === 'payments' && <SettingsSection categories={['payments']} />}
{section === 'spending' && <SpendingSection />}
{section === 'queue' && <AdminQueue />}
{section === 'plans' && <PlansSection />}
{section === 'promos' && <AdminPromos />}
{section === 'billing' && <AdminUsers />}
+182
View File
@@ -0,0 +1,182 @@
'use client';
import { useState, useEffect } from 'react';
import { RefreshCw, Loader2, RotateCcw, Trash2, AlertTriangle, CheckCircle, Clock, XCircle, Zap } from 'lucide-react';
const STATUS_CONFIG = {
done: { icon: CheckCircle, color: 'text-green-400', bg: 'bg-green-500/10', label: 'Готово' },
processing: { icon: Clock, color: 'text-yellow-400', bg: 'bg-yellow-500/10', label: 'В процессе' },
pending: { icon: Zap, color: 'text-blue-400', bg: 'bg-blue-500/10', label: 'В очереди' },
failed: { icon: XCircle, color: 'text-red-400', bg: 'bg-red-500/10', label: 'Ошибка' },
};
const TYPE_ICONS = { post: '✍️', article: '📝', topics: '💡' };
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).toLocaleDateString('ru-RU');
}
export default function AdminQueue() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState({});
const [filter, setFilter] = useState('all');
const [msg, setMsg] = useState('');
async function load() {
setLoading(true);
try {
const res = await fetch('/api/admin/queue').then(r => r.json());
setData(res);
} catch {}
setLoading(false);
}
useEffect(() => { load(); }, []);
async function retry(id) {
setBusy(b => ({ ...b, [id]: 'retry' }));
const res = await fetch(`/api/admin/queue/${id}/retry`, { method: 'POST' }).then(r => r.json());
setBusy(b => ({ ...b, [id]: null }));
setMsg(res.ok ? '✓ Задача добавлена в очередь' : 'Ошибка: ' + res.error);
setTimeout(() => setMsg(''), 3000);
load();
}
async function clearStuck() {
setBusy(b => ({ ...b, stuck: true }));
const res = await fetch('/api/admin/queue/stuck', { method: 'DELETE' }).then(r => r.json());
setBusy(b => ({ ...b, stuck: false }));
setMsg(`✓ Сброшено застрявших: ${res.cleared}`);
setTimeout(() => setMsg(''), 3000);
load();
}
const stats = data?.stats || [];
const stuck = data?.stuck || [];
const jobs = (data?.recent || []).filter(j => filter === 'all' || j.status === filter);
const totals = Object.fromEntries(stats.map(s => [s.status, s]));
return (
<div className="space-y-5">
{/* Header */}
<div className="flex items-center justify-between">
<h2 className="font-semibold">Очередь генерации</h2>
<div className="flex items-center gap-2">
{msg && <span className="text-sm text-green-400">{msg}</span>}
{stuck.length > 0 && (
<button onClick={clearStuck} disabled={busy.stuck}
className="btn-ghost text-sm px-3 py-1.5 text-orange-400 flex items-center gap-1.5">
{busy.stuck ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
Сбросить {stuck.length} застрявших
</button>
)}
<button onClick={load} className="btn-ghost p-2">
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
{loading && !data && <div className="py-8 text-center"><Loader2 className="w-5 h-5 animate-spin mx-auto text-accent" /></div>}
{data && (<>
{/* Статистика */}
<div className="grid grid-cols-4 gap-3">
{['done','processing','pending','failed'].map(s => {
const cfg = STATUS_CONFIG[s];
const stat = totals[s];
const Icon = cfg.icon;
return (
<div key={s} className={`card p-3 ${cfg.bg}`}>
<div className="flex items-center gap-2 mb-1">
<Icon className={`w-4 h-4 ${cfg.color}`} />
<span className="text-xs text-gray-400">{cfg.label}</span>
</div>
<div className="text-2xl font-bold">{stat?.cnt || 0}</div>
{stat?.avg_sec && (
<div className="text-xs text-gray-500 mt-0.5">ср. {stat.avg_sec}с</div>
)}
</div>
);
})}
</div>
{/* Застрявшие alert */}
{stuck.length > 0 && (
<div className="card p-3 border-orange-500/30 bg-orange-500/5 flex items-center gap-3">
<AlertTriangle className="w-5 h-5 text-orange-400 shrink-0" />
<div>
<div className="font-medium text-sm">{stuck.length} задач застряли (processing &gt; 5 мин)</div>
<div className="text-xs text-gray-400">
{stuck.map(j => `#${j.id} ${j.type}`).join(', ')}
</div>
</div>
</div>
)}
{/* Фильтр */}
<div className="flex gap-1">
{[['all','Все'], ...Object.entries(STATUS_CONFIG).map(([k,v]) => [k, v.label])].map(([v,l]) => (
<button key={v} onClick={() => setFilter(v)}
className={`px-2.5 py-1 rounded-lg text-xs transition-colors ${
filter === v ? 'bg-accent/10 text-accent font-medium' : 'text-gray-500 hover:text-gray-300'
}`}>
{l}
{v !== 'all' && totals[v] && <span className="ml-1 opacity-60">({totals[v].cnt})</span>}
</button>
))}
</div>
{/* Список задач */}
<div className="card overflow-hidden">
{jobs.length === 0 && (
<div className="py-8 text-center text-gray-500 text-sm">Задач не найдено</div>
)}
{jobs.map(job => {
const cfg = STATUS_CONFIG[job.status] || STATUS_CONFIG.pending;
const Icon = cfg.icon;
return (
<div key={job.id} className="flex items-start gap-3 px-4 py-3 border-b border-border last:border-0 hover:bg-surface2/50">
<Icon className={`w-4 h-4 mt-0.5 shrink-0 ${cfg.color}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="text-xs">{TYPE_ICONS[job.type] || '⚙️'}</span>
<span className="text-xs text-gray-400 font-medium">{job.type}</span>
<span className="text-xs text-gray-600">#{job.id}</span>
{job.channel_name && <span className="text-xs text-gray-500">· {job.channel_name}</span>}
{job.user_email && <span className="text-xs text-gray-600">· {job.user_email}</span>}
</div>
{job.topic && (
<div className="text-sm text-gray-200 truncate">{job.topic}</div>
)}
{job.error && (
<div className="text-xs text-red-400 mt-0.5 truncate">{job.error}</div>
)}
{(job.tokens_in || job.tokens_out) && (
<div className="text-xs text-gray-600 mt-0.5">
{job.tokens_in ? `${job.tokens_in}` : ''} {job.tokens_out ? `${job.tokens_out}` : ''} токенов
</div>
)}
</div>
<div className="text-right shrink-0">
<div className="text-xs text-gray-500">{timeAgo(job.created_at)}</div>
{job.status === 'failed' && (
<button onClick={() => retry(job.id)} disabled={!!busy[job.id]}
className="mt-1 btn-ghost p-1 text-xs flex items-center gap-1 text-gray-400 hover:text-accent">
{busy[job.id] === 'retry' ? <Loader2 className="w-3 h-3 animate-spin" /> : <RotateCcw className="w-3 h-3" />}
Retry
</button>
)}
</div>
</div>
);
})}
</div>
</>)}
</div>
);
}