Refactor MePoolPanel component to integrate DC and writer metrics, enhancing data presentation with new RTT and coverage indicators. Update TypeScript interfaces for improved data handling and parsing of DC and writer statuses. Adjust runtime page to fetch and display new metrics, ensuring comprehensive monitoring of ME pool performance.
Build and Push Telemt Panel Docker Image / build-and-push (push) Successful in 2m21s
Build and Push Telemt Panel Docker Image / create-release (push) Skipped

This commit is contained in:
Denozordec
2026-08-04 19:57:18 +07:00
parent 6481ee4f1a
commit 33af19ba0c
3 changed files with 451 additions and 24 deletions
+294 -23
View File
@@ -3,67 +3,124 @@
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 writers strip + Frame detail panels.
* Preview: https://reui.io/preview/base/stats-7
* 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 writers = pool.data?.writers
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(writers?.alive_non_draining),
badge: {
text: 'healthy',
variant: 'success-light' as const,
},
subtext: `всего ${formatNumber(writers?.total)} · contour active ${formatNumber(writers?.contour?.active)}`,
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(writers?.draining),
badge: {
text: 'drain',
variant: 'warning-light' as const,
},
subtext: `contour draining ${formatNumber(writers?.contour?.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(writers?.degraded),
value: formatNumber(poolWriters?.degraded),
badge: {
text: writers && (writers.degraded ?? 0) > 0 ? 'attention' : 'ok',
text: poolWriters && (poolWriters.degraded ?? 0) > 0 ? 'attention' : 'ok',
variant:
writers && (writers.degraded ?? 0) > 0
poolWriters && (poolWriters.degraded ?? 0) > 0
? ('destructive-light' as const)
: ('secondary' as const),
},
subtext: `health healthy ${formatNumber(writers?.health?.healthy)}`,
subtext: `endpoints available ${formatNumber(writers.summary?.available_endpoints)} / ${formatNumber(writers.summary?.configured_endpoints)}`,
},
]
@@ -74,7 +131,7 @@ export function MePoolPanel({
<div className="flex min-w-0 flex-col gap-1">
<FrameTitle>ME Pool</FrameTitle>
<FrameDescription>
Middle-End writers, generations и refill ·{' '}
Middle-End writers, DC latency и endpoints ·{' '}
{pool.generated_at_epoch_secs
? formatEpoch(pool.generated_at_epoch_secs)
: isLoading
@@ -97,9 +154,9 @@ export function MePoolPanel({
</p>
) : (
<p className="text-muted-foreground text-xs">
Writers health: healthy {formatNumber(writers?.health?.healthy)} ·
degraded {formatNumber(writers?.health?.degraded)} · draining{' '}
{formatNumber(writers?.health?.draining)}
Writers health: healthy {formatNumber(poolWriters?.health?.healthy)} ·
degraded {formatNumber(poolWriters?.health?.degraded)} · draining{' '}
{formatNumber(poolWriters?.health?.draining)}
</p>
)}
</FramePanel>
@@ -133,6 +190,220 @@ export function MePoolPanel({
</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
+135
View File
@@ -134,10 +134,29 @@ export interface RuntimeMeQualityDrainGateData {
updated_at_epoch_secs?: number
}
export interface RuntimeMeQualityFamilyStateData {
family?: string
state?: string
state_since_epoch_secs?: number
suppressed_until_epoch_secs?: number | null
fail_streak?: number
recover_success_streak?: number
}
export interface RuntimeMeQualityDcRttData {
dc?: number
rtt_ema_ms?: number | null
alive_writers?: number
required_writers?: number
coverage_pct?: number
}
export interface RuntimeMeQualityPayload {
counters?: RuntimeMeQualityCountersData
route_drops?: RuntimeMeQualityRouteDropData
drain_gate?: RuntimeMeQualityDrainGateData
family_states?: RuntimeMeQualityFamilyStateData[]
dc_rtt?: RuntimeMeQualityDcRttData[]
}
export interface RuntimeMeQualityData {
@@ -147,6 +166,122 @@ export interface RuntimeMeQualityData {
data?: RuntimeMeQualityPayload | null
}
/** GET /v1/stats/dcs — per-DC endpoints + RTT */
export interface DcEndpointWriters {
endpoint?: string
active_writers?: number
}
export interface DcStatus {
dc?: number
endpoints?: string[]
endpoint_writers?: DcEndpointWriters[]
available_endpoints?: number
available_pct?: number
required_writers?: number
floor_min?: number
floor_target?: number
floor_max?: number
floor_capped?: boolean
alive_writers?: number
coverage_pct?: number
fresh_alive_writers?: number
fresh_coverage_pct?: number
rtt_ms?: number | null
load?: number
}
export interface DcStatusData {
middle_proxy_enabled?: boolean
reason?: string | null
generated_at_epoch_secs?: number
dcs?: DcStatus[]
}
/** GET /v1/stats/me-writers — per-writer/endpoint latency */
export interface MeWriterStatus {
writer_id?: number
dc?: number | null
endpoint?: string
generation?: number
state?: string
draining?: boolean
degraded?: boolean
bound_clients?: number
idle_for_secs?: number | null
rtt_ema_ms?: number | null
matches_active_generation?: boolean
in_desired_map?: boolean
}
export interface MeWritersSummary {
configured_dc_groups?: number
configured_endpoints?: number
available_endpoints?: number
available_pct?: number
required_writers?: number
alive_writers?: number
coverage_pct?: number
fresh_alive_writers?: number
fresh_coverage_pct?: number
}
export interface MeWritersData {
middle_proxy_enabled?: boolean
reason?: string | null
generated_at_epoch_secs?: number
summary?: MeWritersSummary
writers?: MeWriterStatus[]
}
export function parseDcStatus(payload: unknown): DcStatusData {
const root = asRecord(unwrapData(payload) ?? payload)
const nested = asRecord(root.data)
if (Array.isArray(root.dcs)) {
return root as unknown as DcStatusData
}
if (Array.isArray(nested.dcs)) {
return {
middle_proxy_enabled: (nested.middle_proxy_enabled ?? root.middle_proxy_enabled) as
| boolean
| undefined,
reason: (nested.reason ?? root.reason) as string | null | undefined,
generated_at_epoch_secs: (nested.generated_at_epoch_secs ??
root.generated_at_epoch_secs) as number | undefined,
dcs: nested.dcs as DcStatus[],
}
}
return root as unknown as DcStatusData
}
export function parseMeWriters(payload: unknown): MeWritersData {
const root = asRecord(unwrapData(payload) ?? payload)
const nested = asRecord(root.data)
if (Array.isArray(root.writers) || root.summary) {
return root as unknown as MeWritersData
}
if (Array.isArray(nested.writers) || nested.summary) {
return {
middle_proxy_enabled: (nested.middle_proxy_enabled ?? root.middle_proxy_enabled) as
| boolean
| undefined,
reason: (nested.reason ?? root.reason) as string | null | undefined,
generated_at_epoch_secs: (nested.generated_at_epoch_secs ??
root.generated_at_epoch_secs) as number | undefined,
summary: nested.summary as MeWritersSummary | undefined,
writers: nested.writers as MeWriterStatus[] | undefined,
}
}
return root as unknown as MeWritersData
}
export function formatRttMs(ms: unknown): string {
const n = typeof ms === 'number' ? ms : Number(ms)
if (!Number.isFinite(n) || n < 0) return '—'
if (n < 10) return `${n.toFixed(1)} ms`
return `${Math.round(n)} ms`
}
export function parseMePoolState(payload: unknown): RuntimeMePoolStateData {
const root = asRecord(unwrapData(payload) ?? payload)
const nested = asRecord(root.data)
+22 -1
View File
@@ -9,8 +9,10 @@ import { api } from '@/lib/api-client'
import {
asRecord,
formatNumber,
parseDcStatus,
parseMePoolState,
parseMeQuality,
parseMeWriters,
unwrapData,
} from '@/lib/telemt'
@@ -34,6 +36,16 @@ function RuntimePage() {
queryFn: () => api('/api/telemt/runtime/me_quality').catch(() => null),
refetchInterval: 10_000,
})
const meDcs = useQuery({
queryKey: ['telemt', 'stats_dcs'],
queryFn: () => api('/api/telemt/stats/dcs').catch(() => null),
refetchInterval: 10_000,
})
const meWriters = useQuery({
queryKey: ['telemt', 'stats_me_writers'],
queryFn: () => api('/api/telemt/stats/me-writers').catch(() => null),
refetchInterval: 10_000,
})
const gates = useQuery({
queryKey: ['telemt', 'gates'],
queryFn: () => api('/api/telemt/runtime/gates').catch(() => null),
@@ -54,6 +66,8 @@ function RuntimePage() {
const pool = parseMePoolState(mePool.data)
const quality = parseMeQuality(meQuality.data)
const dcs = parseDcStatus(meDcs.data)
const writers = parseMeWriters(meWriters.data)
const gateData = asRecord(unwrapData(gates.data) ?? gates.data)
const conn = asRecord(unwrapData(connSummary.data) ?? connSummary.data)
const connPayload = asRecord(conn.data ?? conn)
@@ -127,7 +141,14 @@ function RuntimePage() {
<MePoolPanel
pool={pool}
quality={quality}
isLoading={mePool.isLoading || meQuality.isLoading}
dcs={dcs}
writers={writers}
isLoading={
mePool.isLoading ||
meQuality.isLoading ||
meDcs.isLoading ||
meWriters.isLoading
}
/>
<div className="grid gap-4 md:grid-cols-2 md:gap-6">