Files
telemtPanel/apps/web/src/lib/telemt.ts
T

523 lines
15 KiB
TypeScript

/** Helpers for Telemt Control API envelopes — ops UI, not raw JSON dumps. */
export interface TelemtEnvelope<T = unknown> {
ok?: boolean
data?: T
revision?: string
error?: { code?: string; message?: string }
}
export interface SummaryData {
uptime_seconds?: number
connections_total?: number
connections_current?: number
connections_bad_total?: number
configured_users?: number
connections_bad_by_class?: Array<{ class: string; total: number }>
handshake_failures_by_class?: Array<{ class: string; total: number }>
handshake_failures_by_stage?: Array<{ stage: string; total: number }>
handshake_timeouts_total?: number
}
export interface UserInfo {
username: string
enabled?: boolean
in_runtime?: boolean
current_connections?: number
active_unique_ips?: number
active_unique_ips_list?: string[]
recent_unique_ips?: number
recent_unique_ips_list?: string[]
total_octets?: number
expiration_rfc3339?: string | null
max_tcp_conns?: number | null
user_ad_tag?: string | null
data_quota_bytes?: number | null
links?: {
classic?: string[]
secure?: string[]
tls?: string[]
tls_domains?: Array<{ domain?: string; link?: string }>
}
}
export function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
}
/** ME pool snapshot — GET /v1/runtime/me_pool_state */
export interface RuntimeMePoolStateGenerationData {
active_generation?: number
warm_generation?: number
pending_hardswap_generation?: number
pending_hardswap_age_secs?: number | null
draining_generations?: number[]
}
export interface RuntimeMePoolStateHardswapData {
enabled?: boolean
pending?: boolean
}
export interface RuntimeMePoolStateWriterContourData {
warm?: number
active?: number
draining?: number
}
export interface RuntimeMePoolStateWriterHealthData {
healthy?: number
degraded?: number
draining?: number
}
export interface RuntimeMePoolStateWriterData {
total?: number
alive_non_draining?: number
draining?: number
degraded?: number
contour?: RuntimeMePoolStateWriterContourData
health?: RuntimeMePoolStateWriterHealthData
}
export interface RuntimeMePoolStateRefillDcData {
dc?: number
family?: string
inflight?: number
}
export interface RuntimeMePoolStateRefillData {
inflight_endpoints_total?: number
inflight_dc_total?: number
by_dc?: RuntimeMePoolStateRefillDcData[]
}
export interface RuntimeMePoolStatePayload {
generations?: RuntimeMePoolStateGenerationData
hardswap?: RuntimeMePoolStateHardswapData
writers?: RuntimeMePoolStateWriterData
refill?: RuntimeMePoolStateRefillData
}
export interface RuntimeMePoolStateData {
enabled?: boolean
reason?: string | null
generated_at_epoch_secs?: number
data?: RuntimeMePoolStatePayload | null
/** Legacy flat status some proxies expose */
status?: string
}
export interface RuntimeMeQualityCountersData {
idle_close_by_peer_total?: number
reader_eof_total?: number
kdf_drift_total?: number
kdf_port_only_drift_total?: number
reconnect_attempt_total?: number
reconnect_success_total?: number
}
export interface RuntimeMeQualityRouteDropData {
no_conn_total?: number
channel_closed_total?: number
queue_full_total?: number
queue_full_base_total?: number
queue_full_high_total?: number
}
export interface RuntimeMeQualityDrainGateData {
route_quorum_ok?: boolean
redundancy_ok?: boolean
block_reason?: string
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 {
enabled?: boolean
reason?: string | null
generated_at_epoch_secs?: number
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)
// Envelope may be { enabled, data: payload } or already the pool payload
if ('enabled' in root || 'generated_at_epoch_secs' in root || 'reason' in root) {
const inner =
nested.generations || nested.writers || nested.hardswap || nested.refill
? (nested as unknown as RuntimeMePoolStatePayload)
: asRecord(nested.data).generations || asRecord(nested.data).writers
? (asRecord(nested.data) as unknown as RuntimeMePoolStatePayload)
: (root.data as RuntimeMePoolStatePayload | null | undefined)
return {
enabled: root.enabled as boolean | undefined,
reason: (root.reason as string | null | undefined) ?? null,
generated_at_epoch_secs: root.generated_at_epoch_secs as number | undefined,
data: inner ?? null,
status: root.status as string | undefined,
}
}
return {
enabled: true,
data: root as unknown as RuntimeMePoolStatePayload,
status: root.status as string | undefined,
}
}
export function parseMeQuality(payload: unknown): RuntimeMeQualityData {
const root = asRecord(unwrapData(payload) ?? payload)
if ('enabled' in root || 'generated_at_epoch_secs' in root || 'reason' in root) {
return {
enabled: root.enabled as boolean | undefined,
reason: (root.reason as string | null | undefined) ?? null,
generated_at_epoch_secs: root.generated_at_epoch_secs as number | undefined,
data: (root.data as RuntimeMeQualityPayload | null | undefined) ?? null,
}
}
return {
enabled: true,
data: root as unknown as RuntimeMeQualityPayload,
}
}
export interface ApiEventRecord {
seq?: number
ts_epoch_secs?: number
event_type?: string
context?: string
}
export interface TlsFingerprintRow {
scope?: string
ja3?: string
ja4?: string
total?: number
auth_success?: number
bad_or_probe?: number
first_seen_epoch_secs?: number
last_seen_epoch_secs?: number
}
export interface UserTimelineItem {
id: string
at: number | null
title: string
detail: string
kind: 'active_ip' | 'recent_ip' | 'tls' | 'event'
}
export interface CreateUserResponse {
user: UserInfo
secret: string
}
export function formatEpoch(secs: unknown): string {
const n = typeof secs === 'number' ? secs : Number(secs)
if (!Number.isFinite(n) || n <= 0) return '—'
return new Date(n * 1000).toLocaleString('ru-RU')
}
export function buildUserTimeline(opts: {
user: UserInfo
events?: ApiEventRecord[]
tlsRows?: TlsFingerprintRow[]
}): UserTimelineItem[] {
const username = opts.user.username
const active = new Set(opts.user.active_unique_ips_list ?? [])
const items: UserTimelineItem[] = []
for (const ip of opts.user.active_unique_ips_list ?? []) {
items.push({
id: `active-${ip}`,
at: Date.now() / 1000,
title: 'Активный IP',
detail: ip,
kind: 'active_ip',
})
}
for (const ip of opts.user.recent_unique_ips_list ?? []) {
if (active.has(ip)) continue
items.push({
id: `recent-${ip}`,
at: null,
title: 'Недавний IP',
detail: `${ip} · в окне recent`,
kind: 'recent_ip',
})
}
for (const row of opts.tlsRows ?? []) {
if (row.scope && row.scope !== username) continue
const when = row.last_seen_epoch_secs ?? row.first_seen_epoch_secs ?? null
items.push({
id: `tls-${row.ja4 ?? row.ja3 ?? when}`,
at: when,
title: 'TLS fingerprint',
detail: [
row.scope ? `scope ${row.scope}` : null,
row.ja4 ? `JA4 ${row.ja4.slice(0, 18)}…` : null,
`наблюдений ${row.total ?? 0}`,
row.first_seen_epoch_secs
? `первый ${formatEpoch(row.first_seen_epoch_secs)}`
: null,
]
.filter(Boolean)
.join(' · '),
kind: 'tls',
})
}
for (const ev of opts.events ?? []) {
const ctx = String(ev.context ?? '')
if (!ctx.toLowerCase().includes(username.toLowerCase())) continue
items.push({
id: `ev-${ev.seq ?? ev.ts_epoch_secs}-${ev.event_type}`,
at: ev.ts_epoch_secs ?? null,
title: String(ev.event_type ?? 'Событие'),
detail: ctx,
kind: 'event',
})
}
return items.sort((a, b) => {
if (a.at == null && b.at == null) return 0
if (a.at == null) return 1
if (b.at == null) return -1
return b.at - a.at
})
}
export function unwrapData<T>(payload: unknown): T | undefined {
if (payload == null) return undefined
if (typeof payload === 'object' && 'data' in (payload as object)) {
return (payload as TelemtEnvelope<T>).data
}
return payload as T
}
export function normalizeUsers(payload: unknown): UserInfo[] {
const data = unwrapData<unknown>(payload)
if (Array.isArray(data)) return data as UserInfo[]
if (data && typeof data === 'object' && Array.isArray((data as { users?: unknown }).users)) {
return (data as { users: UserInfo[] }).users
}
return []
}
export function formatNumber(value: unknown): string {
const n = typeof value === 'number' ? value : Number(value)
if (!Number.isFinite(n)) return '—'
return new Intl.NumberFormat('ru-RU').format(n)
}
export function formatUptime(seconds: unknown): string {
const n = typeof seconds === 'number' ? seconds : Number(seconds)
if (!Number.isFinite(n) || n < 0) return '—'
const total = Math.floor(n)
const d = Math.floor(total / 86400)
const h = Math.floor((total % 86400) / 3600)
const m = Math.floor((total % 3600) / 60)
if (d > 0) return `${d}д ${h}ч`
if (h > 0) return `${h}ч ${m}м`
return `${m}м`
}
export function formatBytes(octets: unknown): string {
const n = typeof octets === 'number' ? octets : Number(octets)
if (!Number.isFinite(n) || n < 0) return '—'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let v = n
let i = 0
while (v >= 1024 && i < units.length - 1) {
v /= 1024
i += 1
}
return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}`
}
export function telegramHttpLink(link: string): string {
if (link.startsWith('tg://proxy?')) {
return `https://t.me/proxy?${link.slice('tg://proxy?'.length)}`
}
if (link.startsWith('tg://socks?')) {
return `https://t.me/socks?${link.slice('tg://socks?'.length)}`
}
return link
}
export interface UserShareLink {
mode: 'classic' | 'secure' | 'tls'
label: string
tg: string
http: string
}
export function collectUserShareLinks(links?: UserInfo['links']): UserShareLink[] {
if (!links) return []
const out: UserShareLink[] = []
for (const tg of links.classic ?? []) {
out.push({ mode: 'classic', label: 'Classic', tg, http: telegramHttpLink(tg) })
}
for (const tg of links.secure ?? []) {
out.push({ mode: 'secure', label: 'Secure', tg, http: telegramHttpLink(tg) })
}
for (const tg of links.tls ?? []) {
out.push({ mode: 'tls', label: 'TLS', tg, http: telegramHttpLink(tg) })
}
for (const row of links.tls_domains ?? []) {
if (!row.link) continue
out.push({
mode: 'tls',
label: row.domain ? `TLS · ${row.domain}` : 'TLS',
tg: row.link,
http: telegramHttpLink(row.link),
})
}
return out
}