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' && ( + + )} +
+
+ ); + })} +
+ )} +
+ ); +}