feat(admin): состояние инфраструктуры на странице отладки

Сегодняшняя авария (упал гипервизор — вместе с ним брокер, Ollama, прокси)
проявлялась в отладке косвенно: пустой список воркеров и ошибка очередей.
Прямого ответа «что именно недоступно» страница не давала, хотя эндпоинт
/admin/health его уже знает.

Блок статусов рисуется и тогда, когда сам срез не собрался — именно в аварии
он нужен больше всего, а собирается в этот момент дольше обычного.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
jze9
2026-08-28 17:00:19 +05:00
parent 4008b5019f
commit ea62f10829
2 changed files with 50 additions and 7 deletions

View File

@@ -221,7 +221,10 @@ Identity (Universal Auth) и генерирует `.env` заново (`infisica
Кнопки «Запустить всё» / «Остановить всё» — массовый старт и кооперативная отмена Кнопки «Запустить всё» / «Остановить всё» — массовый старт и кооперативная отмена
(флаг `cancel_requested`, воркер останавливается сам на ближайшем тике; уже (флаг `cancel_requested`, воркер останавливается сам на ближайшем тике; уже
начатый прогон не рвём посреди записи в базу). начатый прогон не рвём посреди записи в базу).
- **Панель отладки** (админка → «Отладка», `GET /api/admin/debug`): один срез — - **Панель отладки** (админка → «Отладка», `GET /api/admin/debug` + `/health`):
один срез — доступность инфраструктуры (PostgreSQL/Redis/MinIO/ES/Ollama/
брокер: этот блок показывается даже когда сам срез не собирается, потому что
при аварии первый вопрос — что именно отвалилось),
живые воркеры Celery и что именно они крутят, глубина очередей RabbitMQ живые воркеры Celery и что именно они крутят, глубина очередей RabbitMQ
(в т.ч. `unacked` и число потребителей), покрытие корпуса эмбеддингами, (в т.ч. `unacked` и число потребителей), покрытие корпуса эмбеддингами,
активные и проблемные прогоны, зависшие прогоны (нет heartbeat >10 мин), активные и проблемные прогоны, зависшие прогоны (нет heartbeat >10 мин),

View File

@@ -1,11 +1,13 @@
import React from 'react'; import React from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { import {
Activity, AlertTriangle, Cpu, Database, Layers, RefreshCw, Settings2, ListTree, Activity, AlertTriangle, CheckCircle2, Cpu, Database, Layers, RefreshCw,
Server, Settings2, ListTree, XCircle,
} from 'lucide-react'; } from 'lucide-react';
import { adminApi } from '../../api/client'; import { adminApi } from '../../api/client';
import { ProgressBar } from '../../components/ProgressBar'; 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 ActiveTask { name: string; id: string; args: string; started_ago_s: number | null }
interface Worker { name: string; concurrency: number | null; reserved: number; active: ActiveTask[] } interface Worker { name: string; concurrency: number | null; reserved: number; active: ActiveTask[] }
interface Run { interface Run {
@@ -56,12 +58,23 @@ export function Debug() {
queryFn: () => adminApi.debug().then((r) => r.data as DebugData), queryFn: () => adminApi.debug().then((r) => r.data as DebugData),
refetchInterval: 5000, refetchInterval: 5000,
}); });
// Отдельным запросом: когда отваливается инфраструктура (брокер, Ollama),
// отладка должна первой показывать ЧТО именно недоступно, а не только следствия
const { data: health } = useQuery({
queryKey: ['admin-health'],
queryFn: () => adminApi.health().then((r) => r.data as Health[]),
refetchInterval: 10000,
});
if (error) { if (error || !data) {
return <div className="text-red-600 text-sm">Не удалось получить срез состояния: {String(error)}</div>; return (
} <div className="space-y-4">
if (!data) { {error
return <div className="text-gray-400 text-sm">Сбор данных…</div>; ? <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 queuesErr = 'error' in data.queues ? (data.queues as { error: string }).error : null;
@@ -80,6 +93,8 @@ export function Debug() {
<span className="text-xs text-gray-400">срез от {new Date(data.generated_at).toLocaleTimeString('ru-RU')}</span> <span className="text-xs text-gray-400">срез от {new Date(data.generated_at).toLocaleTimeString('ru-RU')}</span>
</div> </div>
<HealthCard health={health} />
{/* Активные прогоны заливки */} {/* Активные прогоны заливки */}
<Card icon={Layers} title={`Заливка источников — активных прогонов: ${data.sources.active_runs.length}`}> <Card icon={Layers} title={`Заливка источников — активных прогонов: ${data.sources.active_runs.length}`}>
{data.sources.stale_run_ids.length > 0 && ( {data.sources.stale_run_ids.length > 0 && (
@@ -244,6 +259,31 @@ export function Debug() {
); );
} }
/** Что из инфраструктуры доступно прямо сейчас — первый вопрос при разборе аварии. */
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 }) { function Card({ icon: Icon, title, children }: { icon: React.ElementType; title: string; children: React.ReactNode }) {
return ( return (
<div className="bg-white rounded-xl border border-gray-100 p-5"> <div className="bg-white rounded-xl border border-gray-100 p-5">