diff --git a/app/api/admin/queue/[id]/retry/route.js b/app/api/admin/queue/[id]/retry/route.js
new file mode 100644
index 0000000..cdad282
--- /dev/null
+++ b/app/api/admin/queue/[id]/retry/route.js
@@ -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());
+}
diff --git a/app/api/admin/queue/route.js b/app/api/admin/queue/route.js
new file mode 100644
index 0000000..d1db5c4
--- /dev/null
+++ b/app/api/admin/queue/route.js
@@ -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());
+}
diff --git a/components/AdminPanel.js b/components/AdminPanel.js
index 5f95893..406827d 100644
--- a/components/AdminPanel.js
+++ b/components/AdminPanel.js
@@ -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' && }
{section === 'payments' && }
{section === 'spending' && }
+ {section === 'queue' && }
{section === 'plans' && }
{section === 'promos' && }
{section === 'billing' && }
diff --git a/components/admin/AdminQueue.js b/components/admin/AdminQueue.js
new file mode 100644
index 0000000..2904310
--- /dev/null
+++ b/components/admin/AdminQueue.js
@@ -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 (
+
+ {/* Header */}
+
+
Очередь генерации
+
+ {msg && {msg}}
+ {stuck.length > 0 && (
+
+ )}
+
+
+
+
+ {loading && !data &&
}
+
+ {data && (<>
+ {/* Статистика */}
+
+ {['done','processing','pending','failed'].map(s => {
+ const cfg = STATUS_CONFIG[s];
+ const stat = totals[s];
+ const Icon = cfg.icon;
+ return (
+
+
+
+ {cfg.label}
+
+
{stat?.cnt || 0}
+ {stat?.avg_sec && (
+
ср. {stat.avg_sec}с
+ )}
+
+ );
+ })}
+
+
+ {/* Застрявшие alert */}
+ {stuck.length > 0 && (
+
+
+
+
{stuck.length} задач застряли (processing > 5 мин)
+
+ {stuck.map(j => `#${j.id} ${j.type}`).join(', ')}
+
+
+
+ )}
+
+ {/* Фильтр */}
+
+ {[['all','Все'], ...Object.entries(STATUS_CONFIG).map(([k,v]) => [k, v.label])].map(([v,l]) => (
+
+ ))}
+
+
+ {/* Список задач */}
+
+ {jobs.length === 0 && (
+
Задач не найдено
+ )}
+ {jobs.map(job => {
+ const cfg = STATUS_CONFIG[job.status] || STATUS_CONFIG.pending;
+ const Icon = cfg.icon;
+ return (
+
+
+
+
+ {TYPE_ICONS[job.type] || '⚙️'}
+ {job.type}
+ #{job.id}
+ {job.channel_name && · {job.channel_name}}
+ {job.user_email && · {job.user_email}}
+
+ {job.topic && (
+
{job.topic}
+ )}
+ {job.error && (
+
{job.error}
+ )}
+ {(job.tokens_in || job.tokens_out) && (
+
+ {job.tokens_in ? `↑${job.tokens_in}` : ''} {job.tokens_out ? `↓${job.tokens_out}` : ''} токенов
+
+ )}
+
+
+
{timeAgo(job.created_at)}
+ {job.status === 'failed' && (
+
+ )}
+
+
+ );
+ })}
+
+ >)}
+
+ );
+}