forked from admin/zeropost-tool
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:
@@ -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());
|
||||
}
|
||||
@@ -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,6 +5,7 @@ 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';
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// 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 />}
|
||||
|
||||
@@ -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 > 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user