Files
telemtPanel/apps/web/src/components/runtime/me-pool-panel.tsx
T

551 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use no memo'
import { Badge } from '@/components/reui/badge'
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
import { MetricListFrame, MetricRow, StatusBadge } from '@/components/metric-list'
import { cn } from '@telemt/ui/lib/utils'
import { Progress } from '@telemt/ui/components/progress'
import {
formatEpoch,
formatNumber,
formatRttMs,
type DcStatusData,
type MeWritersData,
type RuntimeMePoolStateData,
type RuntimeMeQualityData,
} from '@/lib/telemt'
function rttTone(ms: number | null | undefined): 'success-light' | 'warning-light' | 'destructive-light' | 'secondary' {
if (ms == null || !Number.isFinite(ms)) return 'secondary'
if (ms < 80) return 'success-light'
if (ms < 180) return 'warning-light'
return 'destructive-light'
}
function coverageTone(pct: number | null | undefined): string {
const n = Number(pct)
if (!Number.isFinite(n)) return '**:data-[slot=progress-indicator]:bg-muted-foreground'
if (n >= 90) return '**:data-[slot=progress-indicator]:bg-emerald-500'
if (n >= 60) return '**:data-[slot=progress-indicator]:bg-amber-500'
return '**:data-[slot=progress-indicator]:bg-red-500'
}
function WriterStateBadge({
state,
degraded,
draining,
}: {
state?: string
degraded?: boolean
draining?: boolean
}) {
if (draining) return <Badge variant="warning-light" size="sm">draining</Badge>
if (degraded) return <Badge variant="destructive-light" size="sm">degraded</Badge>
if (state === 'active') return <Badge variant="success-light" size="sm">active</Badge>
if (state === 'warm') return <Badge variant="outline" size="sm">warm</Badge>
return <Badge variant="secondary" size="sm">{state ?? '—'}</Badge>
}
/**
* ME Telemt status — stats-7 + DC/endpoint RTT tables (telemt_panel SoT).
* Preview: https://reui.io/preview/base/stats-7 · https://reui.io/preview/base/list-11
* Docs: https://reui.io/blocks · https://reui.io/docs/components/base/frame
*/
export function MePoolPanel({
pool,
quality,
dcs,
writers,
isLoading,
}: {
pool: RuntimeMePoolStateData
quality: RuntimeMeQualityData
dcs: DcStatusData
writers: MeWritersData
isLoading?: boolean
}) {
const enabled = pool.enabled !== false
const poolWriters = pool.data?.writers
const generations = pool.data?.generations
const hardswap = pool.data?.hardswap
const refill = pool.data?.refill
const q = quality.data
const dcRows =
(dcs.dcs?.length ? dcs.dcs : null) ??
(q?.dc_rtt ?? []).map((row) => ({
dc: row.dc,
rtt_ms: row.rtt_ema_ms,
alive_writers: row.alive_writers,
required_writers: row.required_writers,
coverage_pct: row.coverage_pct,
endpoints: [] as string[],
endpoint_writers: [] as Array<{ endpoint?: string; active_writers?: number }>,
available_endpoints: undefined as number | undefined,
available_pct: undefined as number | undefined,
load: undefined as number | undefined,
fresh_alive_writers: undefined as number | undefined,
fresh_coverage_pct: undefined as number | undefined,
}))
const writerRows = [...(writers.writers ?? [])].sort((a, b) => {
const ra = a.rtt_ema_ms ?? Number.POSITIVE_INFINITY
const rb = b.rtt_ema_ms ?? Number.POSITIVE_INFINITY
return ra - rb
})
const writerCards = [
{
title: 'Живые writers',
subtitle: 'alive_non_draining',
value: formatNumber(poolWriters?.alive_non_draining ?? writers.summary?.alive_writers),
badge: { text: 'healthy', variant: 'success-light' as const },
subtext: `всего ${formatNumber(poolWriters?.total ?? writers.summary?.configured_endpoints)} · coverage ${formatNumber(writers.summary?.coverage_pct)}%`,
},
{
title: 'Draining',
subtitle: 'draining',
value: formatNumber(poolWriters?.draining),
badge: { text: 'drain', variant: 'warning-light' as const },
subtext: `contour draining ${formatNumber(poolWriters?.contour?.draining)}`,
},
{
title: 'Degraded',
subtitle: 'degraded',
value: formatNumber(poolWriters?.degraded),
badge: {
text: poolWriters && (poolWriters.degraded ?? 0) > 0 ? 'attention' : 'ok',
variant:
poolWriters && (poolWriters.degraded ?? 0) > 0
? ('destructive-light' as const)
: ('secondary' as const),
},
subtext: `endpoints available ${formatNumber(writers.summary?.available_endpoints)} / ${formatNumber(writers.summary?.configured_endpoints)}`,
},
]
return (
<div className="flex flex-col gap-4 md:gap-6">
<Frame className="w-full">
<FrameHeader className="flex flex-row items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-1">
<FrameTitle>ME Pool</FrameTitle>
<FrameDescription>
Middle-End writers, DC latency и endpoints ·{' '}
{pool.generated_at_epoch_secs
? formatEpoch(pool.generated_at_epoch_secs)
: isLoading
? 'Загрузка…'
: 'нет снимка'}
</FrameDescription>
</div>
<StatusBadge
ok={enabled && Boolean(pool.data)}
okLabel="Снимок доступен"
failLabel={pool.reason ? String(pool.reason) : 'Недоступен'}
/>
</FrameHeader>
<FramePanel className="flex flex-col gap-3">
{!enabled || !pool.data ? (
<p className="text-muted-foreground text-sm">
{pool.reason
? `ME pool: ${pool.reason}`
: 'Снимок ME pool пока недоступен.'}
</p>
) : (
<p className="text-muted-foreground text-xs">
Writers health: healthy {formatNumber(poolWriters?.health?.healthy)} ·
degraded {formatNumber(poolWriters?.health?.degraded)} · draining{' '}
{formatNumber(poolWriters?.health?.draining)}
</p>
)}
</FramePanel>
</Frame>
{/* stats-7 DNA — Preview: https://reui.io/preview/base/stats-7 */}
<Frame className="@container w-full">
<FramePanel className="bg-background border-border grid grid-cols-1 overflow-hidden rounded-xl border p-0! @3xl:grid-cols-3">
{writerCards.map((card) => (
<div
key={card.subtitle}
className="border-border rounded-none border-0 border-y p-6 shadow-none first:border-0 last:border-0 @3xl:border-x @3xl:border-y-0"
>
<div className="flex h-full flex-col justify-between gap-6">
<div className="flex flex-col gap-0.5">
<div className="text-foreground text-lg font-semibold">{card.title}</div>
<div className="text-muted-foreground font-mono text-sm">{card.subtitle}</div>
</div>
<div className="flex flex-1 grow flex-col justify-between gap-1.5">
<div className="flex items-center gap-2">
<span className="text-3xl font-bold tracking-tight tabular-nums">
{card.value}
</span>
<Badge variant={card.badge.variant}>{card.badge.text}</Badge>
</div>
<div className="text-muted-foreground text-sm">{card.subtext}</div>
</div>
</div>
</div>
))}
</FramePanel>
</Frame>
{/* Per-DC servers + RTT — list-11 DNA. Preview: https://reui.io/preview/base/list-11 */}
<MetricListFrame
title="DC в пуле"
description="Серверы (endpoints) Telegram DC, RTT и покрытие writers"
className="w-full"
trailing={
<StatusBadge
ok={dcs.middle_proxy_enabled !== false || dcRows.length > 0}
okLabel={`${dcRows.length} DC`}
failLabel={dcs.reason ? String(dcs.reason) : 'Нет DC'}
/>
}
>
{dcRows.length === 0 ? (
<p className="text-muted-foreground text-sm">
{dcs.reason ??
'Нет данных /v1/stats/dcs (нужен minimal_runtime_enabled) и нет dc_rtt из me_quality.'}
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left">
<th className="py-2 pr-3 font-medium">DC</th>
<th className="py-2 pr-3 font-medium">RTT</th>
<th className="py-2 pr-3 font-medium">Writers</th>
<th className="py-2 pr-3 font-medium">Coverage</th>
<th className="py-2 pr-3 font-medium">Load</th>
<th className="py-2 font-medium">Endpoints</th>
</tr>
</thead>
<tbody>
{dcRows.map((row) => {
const coverage = Number(row.coverage_pct ?? 0)
const endpoints =
row.endpoints?.length
? row.endpoints
: (row.endpoint_writers ?? []).map((e) => e.endpoint).filter(Boolean)
return (
<tr key={`dc-${row.dc}`} className="border-b border-border/50 align-top">
<td className="py-2.5 pr-3 font-medium tabular-nums">
DC {formatNumber(row.dc)}
</td>
<td className="py-2.5 pr-3">
<Badge variant={rttTone(row.rtt_ms ?? null)} size="sm">
{formatRttMs(row.rtt_ms)}
</Badge>
</td>
<td className="py-2.5 pr-3 tabular-nums">
{formatNumber(row.alive_writers)} / {formatNumber(row.required_writers)}
</td>
<td className="py-2.5 pr-3 min-w-28">
<div className="flex flex-col gap-1">
<Progress
value={Math.min(100, Math.max(0, coverage))}
className={cn('h-1.5', coverageTone(coverage))}
/>
<span className="text-muted-foreground text-[10px] tabular-nums">
{Number.isFinite(coverage) ? `${Math.round(coverage)}%` : '—'}
</span>
</div>
</td>
<td className="py-2.5 pr-3 tabular-nums">
{row.load != null ? formatNumber(row.load) : '—'}
</td>
<td className="py-2.5">
{endpoints.length === 0 ? (
<span className="text-muted-foreground"></span>
) : (
<ul className="flex flex-col gap-1">
{endpoints.slice(0, 4).map((ep) => {
const writersOnEp = (row.endpoint_writers ?? []).find(
(w) => w.endpoint === ep,
)
return (
<li
key={String(ep)}
className="flex flex-wrap items-center gap-2 font-mono text-xs"
>
<span>{ep}</span>
{writersOnEp?.active_writers != null ? (
<Badge variant="outline" size="sm">
{formatNumber(writersOnEp.active_writers)} w
</Badge>
) : null}
</li>
)
})}
{endpoints.length > 4 ? (
<li className="text-muted-foreground text-xs">
+{endpoints.length - 4} ещё
</li>
) : null}
</ul>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</MetricListFrame>
{/* Per-writer / endpoint latency */}
<MetricListFrame
title="Writers / endpoints"
description="Каждый endpoint в пуле ME и его задержка (RTT EMA)"
className="w-full"
trailing={
<StatusBadge
ok={writers.middle_proxy_enabled !== false || writerRows.length > 0}
okLabel={`${writerRows.length} writers`}
failLabel={writers.reason ? String(writers.reason) : 'Нет writers'}
/>
}
>
{writerRows.length === 0 ? (
<p className="text-muted-foreground text-sm">
{writers.reason ??
'Нет данных /v1/stats/me-writers. Включите minimal_runtime_enabled в Telemt API.'}
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left">
<th className="py-2 pr-3 font-medium">Endpoint</th>
<th className="py-2 pr-3 font-medium">DC</th>
<th className="py-2 pr-3 font-medium">Состояние</th>
<th className="py-2 pr-3 font-medium">RTT</th>
<th className="py-2 pr-3 font-medium">Клиенты</th>
<th className="py-2 font-medium">Gen</th>
</tr>
</thead>
<tbody>
{writerRows.slice(0, 40).map((w) => (
<tr
key={`${w.writer_id}-${w.endpoint}`}
className="border-b border-border/50"
>
<td className="py-2 pr-3 font-mono text-xs">{w.endpoint ?? '—'}</td>
<td className="py-2 pr-3 tabular-nums">
{w.dc != null ? formatNumber(w.dc) : '—'}
</td>
<td className="py-2 pr-3">
<WriterStateBadge
state={w.state}
degraded={w.degraded}
draining={w.draining}
/>
</td>
<td className="py-2 pr-3">
<Badge variant={rttTone(w.rtt_ema_ms ?? null)} size="sm">
{formatRttMs(w.rtt_ema_ms)}
</Badge>
</td>
<td className="py-2 pr-3 tabular-nums">
{formatNumber(w.bound_clients)}
</td>
<td className="py-2 tabular-nums text-muted-foreground">
{formatNumber(w.generation)}
</td>
</tr>
))}
</tbody>
</table>
{writerRows.length > 40 ? (
<p className="text-muted-foreground pt-2 text-xs">
Показаны 40 из {writerRows.length} writers (сортировка по RTT).
</p>
) : null}
</div>
)}
</MetricListFrame>
{(q?.family_states ?? []).length > 0 ? (
<MetricListFrame
title="Family states"
description="Состояние маршрутов IPv4 / IPv6"
className="w-full"
>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left">
<th className="py-2 pr-3 font-medium">Family</th>
<th className="py-2 pr-3 font-medium">State</th>
<th className="py-2 pr-3 font-medium">Fail streak</th>
<th className="py-2 font-medium">Recover streak</th>
</tr>
</thead>
<tbody>
{(q?.family_states ?? []).map((f) => (
<tr key={f.family} className="border-b border-border/50">
<td className="py-2 pr-3 font-medium">{f.family ?? '—'}</td>
<td className="py-2 pr-3">
<Badge variant="outline" size="sm">
{f.state ?? '—'}
</Badge>
</td>
<td className="py-2 pr-3 tabular-nums">{formatNumber(f.fail_streak)}</td>
<td className="py-2 tabular-nums">
{formatNumber(f.recover_success_streak)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</MetricListFrame>
) : null}
<div className="grid gap-4 md:grid-cols-2 md:gap-6">
<MetricListFrame title="Generations" description="Поколения пула ME">
<MetricRow
label="Active"
value={formatNumber(generations?.active_generation)}
/>
<MetricRow
label="Warm"
value={formatNumber(generations?.warm_generation)}
/>
<MetricRow
label="Pending hardswap"
value={
generations?.pending_hardswap_generation
? formatNumber(generations.pending_hardswap_generation)
: '—'
}
hint={
generations?.pending_hardswap_age_secs != null
? `возраст ${formatNumber(generations.pending_hardswap_age_secs)} с`
: undefined
}
/>
<MetricRow
label="Draining IDs"
value={
(generations?.draining_generations ?? []).length > 0
? generations!.draining_generations!.join(', ')
: '—'
}
/>
</MetricListFrame>
<MetricListFrame title="Hardswap" description="Переключение поколения">
<MetricRow
label="Feature"
value={
<Badge
variant={hardswap?.enabled ? 'success-light' : 'secondary'}
size="sm"
>
{hardswap?.enabled ? 'Вкл' : 'Выкл'}
</Badge>
}
/>
<MetricRow
label="Pending"
value={
<Badge
variant={hardswap?.pending ? 'warning-light' : 'secondary'}
size="sm"
>
{hardswap?.pending ? 'Да' : 'Нет'}
</Badge>
}
/>
</MetricListFrame>
<MetricListFrame
title="Refill"
description="Дозагрузка endpoints по DC"
className="md:col-span-2"
>
<MetricRow
label="Inflight endpoints"
value={formatNumber(refill?.inflight_endpoints_total)}
/>
<MetricRow
label="Inflight DC keys"
value={formatNumber(refill?.inflight_dc_total)}
/>
{(refill?.by_dc ?? []).length === 0 ? (
<p className="text-muted-foreground py-2 text-sm">Нет inflight по DC</p>
) : (
<div className="flex flex-col gap-2 pt-2">
{(refill?.by_dc ?? []).slice(0, 8).map((row) => (
<div
key={`${row.dc}-${row.family}`}
className="flex items-center justify-between gap-3 border-b border-border/50 py-2 text-sm last:border-b-0"
>
<span>
DC {formatNumber(row.dc)} · {row.family ?? '—'}
</span>
<span className="tabular-nums">{formatNumber(row.inflight)}</span>
</div>
))}
</div>
)}
</MetricListFrame>
<MetricListFrame
title="ME Quality"
description="Lifecycle и route drops"
className="md:col-span-2"
trailing={
<StatusBadge
ok={quality.enabled !== false && Boolean(quality.data)}
okLabel="OK"
failLabel={quality.reason ? String(quality.reason) : 'Нет данных'}
/>
}
>
{!quality.data ? (
<p className="text-muted-foreground text-sm">
{quality.reason ?? 'Снимок me_quality недоступен.'}
</p>
) : (
<>
<MetricRow
label="Reconnect success / attempts"
value={`${formatNumber(q?.counters?.reconnect_success_total)} / ${formatNumber(q?.counters?.reconnect_attempt_total)}`}
/>
<MetricRow
label="Idle close by peer"
value={formatNumber(q?.counters?.idle_close_by_peer_total)}
/>
<MetricRow
label="Reader EOF"
value={formatNumber(q?.counters?.reader_eof_total)}
/>
<MetricRow
label="Route drops (queue full)"
value={formatNumber(q?.route_drops?.queue_full_total)}
/>
<MetricRow
label="Drain gate"
value={
q?.drain_gate?.block_reason
? String(q.drain_gate.block_reason)
: '—'
}
hint={
q?.drain_gate
? `quorum ${q.drain_gate.route_quorum_ok ? 'ok' : 'no'} · redundancy ${q.drain_gate.redundancy_ok ? 'ok' : 'no'}`
: undefined
}
/>
</>
)}
</MetricListFrame>
</div>
</div>
)
}