Проверка на живых данных вскрыла, что панель отладки врала в мою же пользу: показывала 154 046 «документов с эмбеддингом» (87%), тогда как в FAISS реально 93 053 вектора (52.5%). Колонка documents.faiss_id для этого непригодна — отметка остаётся после пересоздания индекса (смена модели: 768 → 1024) и после сбоев worker-gpu. Выборочная проверка: у 8 из 20 «отмеченных» вектора нет. - gpu.index_stats — новая задача, отдаёт реальное содержимое активного векторного бэкенда; API спрашивает её для панели отладки; - панель показывает «векторов в индексе» и отдельно предупреждает о ложных отметках (их 60 993), потому что такие документы молча выпадают из L3: в индексе их нет, а на пересчёт они не попадут — reembed ищет faiss_id IS NULL; - scripts/ops/faiss_reconcile.py — сверяет отметки с индексом и обнуляет ложные, после чего reembed_missing.py отправляет их на пересчёт. Проверен вживую (dry-run на проде: 93 053 в индексе, 60 993 ложных отметок). Документация: зафиксирована реальная глубина корпуса — полный текст только у 27.5% документов, у 79% меньше 500 отпечатков (уровень аннотации). Система ловит списывание из того, что есть целиком; это граница, а не поломка. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
330 lines
16 KiB
TypeScript
330 lines
16 KiB
TypeScript
import React from 'react';
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import {
|
||
Activity, AlertTriangle, CheckCircle2, Cpu, Database, Layers, RefreshCw,
|
||
Server, Settings2, ListTree, XCircle,
|
||
} from 'lucide-react';
|
||
import { adminApi } from '../../api/client';
|
||
import { ProgressBar } from '../../components/ProgressBar';
|
||
|
||
interface Health { name: string; ok: boolean; detail?: string }
|
||
interface ActiveTask { name: string; id: string; args: string; started_ago_s: number | null }
|
||
interface Worker { name: string; concurrency: number | null; reserved: number; active: ActiveTask[] }
|
||
interface Run {
|
||
id: number; source_id: number; status: string; stage: string; target: number;
|
||
fetched: number; processed: number; added: number; duplicates: number;
|
||
skipped: number; failed: number; error: string | null; percent: number;
|
||
started_at: string; run_started_at: string | null; heartbeat_at: string | null;
|
||
queued_s: number | null; duration_s: number | null;
|
||
}
|
||
|
||
interface DebugData {
|
||
generated_at: string;
|
||
celery: { workers: Worker[]; error?: string };
|
||
queues: Record<string, { messages: number; unacked: number; consumers: number }> | { error: string };
|
||
corpus: {
|
||
documents: number; documents_marked_embedded: number;
|
||
vector_index: { backend?: string; vectors?: number; dim?: number; embed_model?: string; error?: string };
|
||
fingerprints_estimate: number; elasticsearch_documents: number | null;
|
||
};
|
||
sources: {
|
||
by_status: Record<string, number>;
|
||
active_runs: Run[];
|
||
stale_run_ids: number[];
|
||
recent_problem_runs: Run[];
|
||
};
|
||
tasks_24h: Record<string, number>;
|
||
recent_failed_tasks: { public_id: string; type: string; error: string; created_at: string }[];
|
||
config: Record<string, string>;
|
||
}
|
||
|
||
const STAGE_LABELS: Record<string, string> = {
|
||
queued: 'в очереди', fetch: 'выборка', index: 'индексация', finished: 'завершено',
|
||
};
|
||
|
||
function fmtNum(n: number | null | undefined): string {
|
||
return n == null ? '—' : n.toLocaleString('ru-RU');
|
||
}
|
||
|
||
/** Сколько идущий прогон уже работает — по нему видно застрявший. */
|
||
function runningFor(run: Run): string {
|
||
if (!run.run_started_at) return 'ещё не начат';
|
||
const sec = Math.max(0, (Date.now() - new Date(run.run_started_at + 'Z').getTime()) / 1000);
|
||
return sec < 90 ? `${Math.round(sec)}с` : `${Math.round(sec / 60)} мин`;
|
||
}
|
||
|
||
export function Debug() {
|
||
const { data, isFetching, error } = useQuery({
|
||
queryKey: ['admin-debug'],
|
||
queryFn: () => adminApi.debug().then((r) => r.data as DebugData),
|
||
refetchInterval: 5000,
|
||
});
|
||
// Отдельным запросом: когда отваливается инфраструктура (брокер, Ollama),
|
||
// отладка должна первой показывать ЧТО именно недоступно, а не только следствия
|
||
const { data: health } = useQuery({
|
||
queryKey: ['admin-health'],
|
||
queryFn: () => adminApi.health().then((r) => r.data as Health[]),
|
||
refetchInterval: 10000,
|
||
});
|
||
|
||
if (error || !data) {
|
||
return (
|
||
<div className="space-y-4">
|
||
{error
|
||
? <div className="text-red-600 text-sm">Не удалось получить срез состояния: {String(error)}</div>
|
||
: <div className="text-gray-400 text-sm">Сбор данных…</div>}
|
||
<HealthCard health={health} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const queuesErr = 'error' in data.queues ? (data.queues as { error: string }).error : null;
|
||
const queues = queuesErr ? {} : (data.queues as Record<string, { messages: number; unacked: number; consumers: number }>);
|
||
// Покрытие L3 считаем по РЕАЛЬНОМУ содержимому индекса: пометка faiss_id в БД
|
||
// остаётся и когда вектор туда не попал, и завышает картину в разы
|
||
const vectors = data.corpus.vector_index?.vectors ?? null;
|
||
const embedPercent = data.corpus.documents && vectors != null
|
||
? (vectors / data.corpus.documents) * 100
|
||
: 0;
|
||
const staleMarks = vectors != null
|
||
? Math.max(0, data.corpus.documents_marked_embedded - vectors)
|
||
: 0;
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="flex items-center justify-between">
|
||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||
Отладка
|
||
{isFetching && <RefreshCw className="w-4 h-4 text-gray-300 animate-spin" />}
|
||
</h1>
|
||
<span className="text-xs text-gray-400">срез от {new Date(data.generated_at).toLocaleTimeString('ru-RU')}</span>
|
||
</div>
|
||
|
||
<HealthCard health={health} />
|
||
|
||
{/* Активные прогоны заливки */}
|
||
<Card icon={Layers} title={`Заливка источников — активных прогонов: ${data.sources.active_runs.length}`}>
|
||
{data.sources.stale_run_ids.length > 0 && (
|
||
<div className="mb-3 flex items-start gap-2 text-sm text-amber-700 bg-amber-50 border border-amber-100 rounded-lg p-3">
|
||
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
|
||
<span>
|
||
Прогоны без признаков жизни больше 10 минут: {data.sources.stale_run_ids.join(', ')}.
|
||
Обычно это упавший или перезапущенный worker-indexer — проверьте его логи и очередь queue.index.
|
||
</span>
|
||
</div>
|
||
)}
|
||
{data.sources.active_runs.length ? (
|
||
<div className="space-y-3">
|
||
{data.sources.active_runs.map((r) => (
|
||
<div key={r.id} className="flex items-center gap-4">
|
||
<div className="w-52 shrink-0 text-sm text-gray-600 truncate">
|
||
#{r.id} · источник {r.source_id}
|
||
<span className="text-gray-400"> · {runningFor(r)}</span>
|
||
</div>
|
||
<ProgressBar
|
||
percent={r.percent}
|
||
status={r.status}
|
||
label={`${STAGE_LABELS[r.stage] || r.stage} · получено ${r.fetched}/${r.target} · +${r.added}`}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p className="text-sm text-gray-400">Сейчас ничего не заливается.</p>
|
||
)}
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
{Object.entries(data.sources.by_status).map(([s, c]) => (
|
||
<span key={s} className="px-3 py-1 bg-gray-100 rounded-lg text-sm text-gray-700">{s}: <b>{c}</b></span>
|
||
))}
|
||
</div>
|
||
</Card>
|
||
|
||
{/* Воркеры */}
|
||
<Card icon={Cpu} title="Воркеры Celery">
|
||
{data.celery.error && <p className="text-sm text-red-600 mb-2">{data.celery.error}</p>}
|
||
{data.celery.workers.length ? (
|
||
<div className="space-y-4">
|
||
{data.celery.workers.map((w) => (
|
||
<div key={w.name}>
|
||
<div className="flex items-baseline justify-between mb-1">
|
||
<span className="font-medium text-gray-800 text-sm">{w.name}</span>
|
||
<span className="text-xs text-gray-500">
|
||
параллельно: {w.concurrency ?? '—'} · в работе: {w.active.length} · зарезервировано: {w.reserved}
|
||
</span>
|
||
</div>
|
||
{w.active.length ? (
|
||
<div className="border border-gray-100 rounded-lg divide-y divide-gray-50 text-xs font-mono">
|
||
{w.active.map((t) => (
|
||
<div key={t.id} className="px-3 py-1.5 flex gap-3">
|
||
<span className="text-gray-400 tabular-nums shrink-0">
|
||
{t.started_ago_s != null ? `${t.started_ago_s}с` : '—'}
|
||
</span>
|
||
<span className="text-gray-800 shrink-0">{t.name}</span>
|
||
<span className="text-gray-400 truncate">{t.args}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : <p className="text-xs text-gray-400">простаивает</p>}
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : <p className="text-sm text-gray-400">Воркеры не отвечают на ping.</p>}
|
||
</Card>
|
||
|
||
{/* Очереди */}
|
||
<Card icon={Activity} title="Очереди RabbitMQ">
|
||
{queuesErr ? (
|
||
<p className="text-sm text-red-600">{queuesErr}</p>
|
||
) : (
|
||
<table className="w-full text-sm">
|
||
<thead className="text-gray-500 text-left">
|
||
<tr><th className="py-1">Очередь</th><th className="py-1">Ждут</th><th className="py-1">В работе</th><th className="py-1">Потребителей</th></tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-50">
|
||
{Object.entries(queues).map(([name, q]) => (
|
||
<tr key={name}>
|
||
<td className="py-1.5 text-gray-800">{name}</td>
|
||
<td className="py-1.5 text-gray-600 tabular-nums">{fmtNum(q.messages)}</td>
|
||
<td className="py-1.5 text-gray-600 tabular-nums">{fmtNum(q.unacked)}</td>
|
||
<td className={`py-1.5 tabular-nums ${q.consumers ? 'text-gray-600' : 'text-red-600'}`}>{q.consumers}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</Card>
|
||
|
||
{/* Корпус */}
|
||
<Card icon={Database} title="Корпус сравнения">
|
||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
|
||
<Metric label="Документов" value={fmtNum(data.corpus.documents)} />
|
||
<Metric label="Векторов в индексе" value={fmtNum(vectors)} />
|
||
<Metric label="Отпечатков (оценка)" value={fmtNum(data.corpus.fingerprints_estimate)} />
|
||
<Metric label="В Elasticsearch" value={fmtNum(data.corpus.elasticsearch_documents)} />
|
||
</div>
|
||
{data.corpus.vector_index?.error && (
|
||
<p className="text-xs text-red-600 mb-2">
|
||
индекс недоступен: {data.corpus.vector_index.error}
|
||
</p>
|
||
)}
|
||
<ProgressBar
|
||
percent={embedPercent}
|
||
status={embedPercent >= 99 ? 'done' : 'partial'}
|
||
label={`покрытие L3 (реально в индексе ${data.corpus.vector_index?.backend || '—'}): ` +
|
||
`без вектора ${fmtNum(Math.max(0, data.corpus.documents - (vectors ?? 0)))} документов`}
|
||
/>
|
||
{staleMarks > 0 && (
|
||
<div className="mt-3 flex items-start gap-2 text-sm text-amber-700 bg-amber-50 border border-amber-100 rounded-lg p-3">
|
||
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
|
||
<span>
|
||
У {fmtNum(staleMarks)} документов в базе стоит отметка faiss_id, но вектора в индексе нет —
|
||
обычно это след пересоздания индекса после смены модели эмбеддингов.
|
||
Такие документы не участвуют в семантическом поиске и не будут пересчитаны,
|
||
пока отметку не сбросить: <code>scripts/ops/faiss_reconcile.py</code>.
|
||
</span>
|
||
</div>
|
||
)}
|
||
</Card>
|
||
|
||
{/* Проблемные прогоны */}
|
||
{data.sources.recent_problem_runs.length > 0 && (
|
||
<Card icon={AlertTriangle} title="Последние проблемные прогоны">
|
||
<div className="space-y-2 text-sm">
|
||
{data.sources.recent_problem_runs.map((r) => (
|
||
<div key={r.id} className="flex flex-wrap items-baseline gap-x-3 gap-y-1 border-b border-gray-50 pb-2 last:border-0">
|
||
<span className="text-gray-800">#{r.id}</span>
|
||
<span className="text-gray-500">источник {r.source_id}</span>
|
||
<span className={r.status === 'error' ? 'text-red-600' : 'text-amber-600'}>{r.status}</span>
|
||
<span className="text-gray-500">
|
||
получено {r.fetched}/{r.target}, добавлено {r.added}, ошибок {r.failed}
|
||
{r.duration_s != null && ` · работал ${Math.round(r.duration_s)}с`}
|
||
</span>
|
||
{r.error && <span className="w-full text-xs font-mono text-red-600 break-all">{r.error}</span>}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Задачи пользователей */}
|
||
<Card icon={ListTree} title="Проверки за 24 часа">
|
||
<div className="flex flex-wrap gap-2 mb-3">
|
||
{Object.entries(data.tasks_24h).map(([s, c]) => (
|
||
<span key={s} className="px-3 py-1 bg-gray-100 rounded-lg text-sm text-gray-700">{s}: <b>{c}</b></span>
|
||
))}
|
||
{!Object.keys(data.tasks_24h).length && <span className="text-sm text-gray-400">задач не было</span>}
|
||
</div>
|
||
{data.recent_failed_tasks.length > 0 && (
|
||
<div className="text-xs space-y-1">
|
||
<div className="text-gray-500 mb-1">Последние упавшие:</div>
|
||
{data.recent_failed_tasks.map((t) => (
|
||
<div key={t.public_id} className="flex gap-3">
|
||
<span className="text-gray-400 shrink-0">{new Date(t.created_at).toLocaleString('ru-RU')}</span>
|
||
<span className="text-gray-700 shrink-0">{t.type}</span>
|
||
<span className="text-red-600 font-mono truncate">{t.error || '—'}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</Card>
|
||
|
||
{/* Конфигурация */}
|
||
<Card icon={Settings2} title="Конфигурация бэкендов">
|
||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-2 text-sm">
|
||
{Object.entries(data.config).map(([k, v]) => (
|
||
<div key={k} className="flex justify-between gap-3 border-b border-gray-50 py-1">
|
||
<span className="text-gray-500">{k}</span>
|
||
<span className="text-gray-800 font-mono truncate" title={v}>{v}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Что из инфраструктуры доступно прямо сейчас — первый вопрос при разборе аварии. */
|
||
function HealthCard({ health }: { health?: Health[] }) {
|
||
return (
|
||
<Card icon={Server} title="Инфраструктура">
|
||
{health?.length ? (
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-1">
|
||
{health.map((h) => (
|
||
<div key={h.name} className="flex items-center justify-between gap-3 py-1 border-b border-gray-50">
|
||
<span className="flex items-center gap-2 text-sm text-gray-700">
|
||
{h.ok
|
||
? <CheckCircle2 className="w-4 h-4 text-emerald-500 shrink-0" />
|
||
: <XCircle className="w-4 h-4 text-red-500 shrink-0" />}
|
||
{h.name}
|
||
</span>
|
||
<span className={`text-xs truncate ${h.ok ? 'text-gray-400' : 'text-red-500'}`} title={h.detail}>
|
||
{h.detail || (h.ok ? 'OK' : 'недоступен')}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : <p className="text-sm text-gray-400">Опрос сервисов…</p>}
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function Card({ icon: Icon, title, children }: { icon: React.ElementType; title: string; children: React.ReactNode }) {
|
||
return (
|
||
<div className="bg-white rounded-xl border border-gray-100 p-5">
|
||
<h2 className="font-semibold text-gray-800 mb-4 flex items-center gap-2">
|
||
<Icon className="w-4 h-4 text-gray-400" /> {title}
|
||
</h2>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Metric({ label, value }: { label: string; value: string }) {
|
||
return (
|
||
<div>
|
||
<div className="text-xl font-bold text-gray-900 tabular-nums">{value}</div>
|
||
<div className="text-xs text-gray-500">{label}</div>
|
||
</div>
|
||
);
|
||
}
|