551 lines
22 KiB
TypeScript
551 lines
22 KiB
TypeScript
'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>
|
||
)
|
||
}
|