diff --git a/apps/web/src/components/blocks/stats-7/components/data.tsx b/apps/web/src/components/blocks/stats-7/components/data.tsx
new file mode 100644
index 0000000..6766214
--- /dev/null
+++ b/apps/web/src/components/blocks/stats-7/components/data.tsx
@@ -0,0 +1,86 @@
+import { ReactNode } from "react"
+import { BadgeProps } from "@/components/reui/badge"
+import { TrendingUp, UserPlusIcon, TrendingDown } from "lucide-react"
+
+// ── Types ──
+
+export interface CardData {
+ title: string
+ subtitle: string
+ value: string
+ valueColor: string
+ badge: {
+ color: BadgeProps["variant"]
+ icon: ReactNode
+ text: string
+ }
+ subtext: ReactNode
+}
+
+// ── Data ──
+
+export const cards: CardData[] = [
+ {
+ title: "Total Sales & Cost",
+ subtitle: "Last 60 days",
+ value: "$956.82k",
+ valueColor: "text-emerald-600",
+ badge: {
+ color: "success-light",
+ icon: (
+
+ ),
+ text: "+5.4%",
+ },
+ subtext: (
+
+ +8.20k{" "}
+
+ vs prev. 60 days
+
+
+ ),
+ },
+ {
+ title: "New Customers",
+ subtitle: "This quarter",
+ value: "1,245",
+ valueColor: "text-blue-600",
+ badge: {
+ color: "info-light",
+ icon: (
+
+ ),
+ text: "+3.2%",
+ },
+ subtext: (
+
+ +39{" "}
+
+ vs last quarter
+
+
+ ),
+ },
+ {
+ title: "Churn Rate",
+ subtitle: "Last 30 days",
+ value: "2.8%",
+ valueColor: "text-destructive",
+ badge: {
+ color: "destructive-light",
+ icon: (
+
+ ),
+ text: "-1.1%",
+ },
+ subtext: (
+
+ -0.3%{" "}
+
+ vs prev. 30 days
+
+
+ ),
+ },
+]
\ No newline at end of file
diff --git a/apps/web/src/components/blocks/stats-7/components/stats.tsx b/apps/web/src/components/blocks/stats-7/components/stats.tsx
new file mode 100644
index 0000000..84adb3a
--- /dev/null
+++ b/apps/web/src/components/blocks/stats-7/components/stats.tsx
@@ -0,0 +1,46 @@
+"use client"
+
+import { Badge } from "@/components/reui/badge"
+import { Frame, FramePanel } from "@/components/reui/frame"
+
+import { cards } from "./data"
+
+export function Stats() {
+ return (
+
+ {/* Content */}
+
+ {cards.map((card, i) => (
+
+
+
+
+ {card.title}
+
+
+ {card.subtitle}
+
+
+
+
+
+
+ {card.value}
+
+
+ {card.badge.icon}
+ {card.badge.text}
+
+
+
{card.subtext}
+
+
+
+ ))}
+
+
+ )
+}
\ No newline at end of file
diff --git a/apps/web/src/components/blocks/stats-7/page.tsx b/apps/web/src/components/blocks/stats-7/page.tsx
new file mode 100644
index 0000000..2261885
--- /dev/null
+++ b/apps/web/src/components/blocks/stats-7/page.tsx
@@ -0,0 +1,9 @@
+import { Stats } from "./components/stats"
+
+export function Page() {
+ return (
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/apps/web/src/components/metric-list.tsx b/apps/web/src/components/metric-list.tsx
index dace555..b47b87f 100644
--- a/apps/web/src/components/metric-list.tsx
+++ b/apps/web/src/components/metric-list.tsx
@@ -75,35 +75,54 @@ export function StatusBadge({
}
interface RankedBarListProps {
- items: Array<{ label: string; value: number }>
+ items: Array<{ label: string; value: number; title?: string; subtitle?: string }>
emptyLabel?: string
}
+/** Ranked bars — list-3 DNA: primary title + secondary code. Preview: https://reui.io/preview/base/list-3 */
export function RankedBarList({ items, emptyLabel = 'Нет данных' }: RankedBarListProps) {
if (items.length === 0) {
return {emptyLabel}
}
const max = Math.max(...items.map((i) => i.value), 1)
+ const total = items.reduce((sum, i) => sum + i.value, 0) || 1
return (
- {items.map((item) => (
-
-
-
{item.label}
-
- {new Intl.NumberFormat('ru-RU').format(item.value)}
-
+ {items.map((item) => {
+ const title = item.title ?? item.label
+ const subtitle = item.subtitle
+ const share = Math.round((item.value / total) * 100)
+ return (
+
+
+
+ {title}
+ {subtitle ? (
+
+ {subtitle}
+
+ ) : null}
+
+
+
+ {new Intl.NumberFormat('ru-RU').format(item.value)}
+
+
+ {share}%
+
+
+
+
-
-
- ))}
+ )
+ })}
)
}
diff --git a/apps/web/src/components/runtime/me-pool-panel.tsx b/apps/web/src/components/runtime/me-pool-panel.tsx
new file mode 100644
index 0000000..b8f352b
--- /dev/null
+++ b/apps/web/src/components/runtime/me-pool-panel.tsx
@@ -0,0 +1,279 @@
+'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 {
+ formatEpoch,
+ formatNumber,
+ type RuntimeMePoolStateData,
+ type RuntimeMeQualityData,
+} from '@/lib/telemt'
+
+/**
+ * ME Telemt status — stats-7 writers strip + Frame detail panels.
+ * Preview: https://reui.io/preview/base/stats-7
+ * Docs: https://reui.io/blocks · https://reui.io/docs/components/base/frame
+ */
+export function MePoolPanel({
+ pool,
+ quality,
+ isLoading,
+}: {
+ pool: RuntimeMePoolStateData
+ quality: RuntimeMeQualityData
+ isLoading?: boolean
+}) {
+ const enabled = pool.enabled !== false
+ const writers = pool.data?.writers
+ const generations = pool.data?.generations
+ const hardswap = pool.data?.hardswap
+ const refill = pool.data?.refill
+ const q = quality.data
+
+ 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)}`,
+ },
+ {
+ title: 'Draining',
+ subtitle: 'draining',
+ value: formatNumber(writers?.draining),
+ badge: {
+ text: 'drain',
+ variant: 'warning-light' as const,
+ },
+ subtext: `contour draining ${formatNumber(writers?.contour?.draining)}`,
+ },
+ {
+ title: 'Degraded',
+ subtitle: 'degraded',
+ value: formatNumber(writers?.degraded),
+ badge: {
+ text: writers && (writers.degraded ?? 0) > 0 ? 'attention' : 'ok',
+ variant:
+ writers && (writers.degraded ?? 0) > 0
+ ? ('destructive-light' as const)
+ : ('secondary' as const),
+ },
+ subtext: `health healthy ${formatNumber(writers?.health?.healthy)}`,
+ },
+ ]
+
+ return (
+
+
+
+
+ ME Pool
+
+ Middle-End writers, generations и refill ·{' '}
+ {pool.generated_at_epoch_secs
+ ? formatEpoch(pool.generated_at_epoch_secs)
+ : isLoading
+ ? 'Загрузка…'
+ : 'нет снимка'}
+
+
+
+
+
+ {!enabled || !pool.data ? (
+
+ {pool.reason
+ ? `ME pool: ${pool.reason}`
+ : 'Снимок ME pool пока недоступен.'}
+
+ ) : (
+
+ Writers health: healthy {formatNumber(writers?.health?.healthy)} ·
+ degraded {formatNumber(writers?.health?.degraded)} · draining{' '}
+ {formatNumber(writers?.health?.draining)}
+
+ )}
+
+
+
+ {/* stats-7 DNA — Preview: https://reui.io/preview/base/stats-7 */}
+
+
+ {writerCards.map((card) => (
+
+
+
+
{card.title}
+
{card.subtitle}
+
+
+
+
+ {card.value}
+
+ {card.badge.text}
+
+
{card.subtext}
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+ 0
+ ? generations!.draining_generations!.join(', ')
+ : '—'
+ }
+ />
+
+
+
+
+ {hardswap?.enabled ? 'Вкл' : 'Выкл'}
+
+ }
+ />
+
+ {hardswap?.pending ? 'Да' : 'Нет'}
+
+ }
+ />
+
+
+
+
+
+ {(refill?.by_dc ?? []).length === 0 ? (
+ Нет inflight по DC
+ ) : (
+
+ {(refill?.by_dc ?? []).slice(0, 8).map((row) => (
+
+
+ DC {formatNumber(row.dc)} · {row.family ?? '—'}
+
+ {formatNumber(row.inflight)}
+
+ ))}
+
+ )}
+
+
+
+ }
+ >
+ {!quality.data ? (
+
+ {quality.reason ?? 'Снимок me_quality недоступен.'}
+
+ ) : (
+ <>
+
+
+
+
+
+ >
+ )}
+
+
+
+ )
+}
diff --git a/apps/web/src/components/users/user-detail-sheet.tsx b/apps/web/src/components/users/user-detail-sheet.tsx
index 203663b..253fe65 100644
--- a/apps/web/src/components/users/user-detail-sheet.tsx
+++ b/apps/web/src/components/users/user-detail-sheet.tsx
@@ -23,9 +23,11 @@ import {
import { ScrollArea } from '@telemt/ui/components/scroll-area'
import { Skeleton } from '@telemt/ui/components/skeleton'
import { api } from '@/lib/api-client'
+import { UserShareLinks } from '@/components/users/user-share-links'
import {
asRecord,
buildUserTimeline,
+ collectUserShareLinks,
formatBytes,
formatEpoch,
formatNumber,
@@ -96,6 +98,7 @@ export function UserDetailSheet({ username, open, onOpenChange }: UserDetailShee
const activeIps = user?.active_unique_ips_list ?? []
const recentIps = user?.recent_unique_ips_list ?? []
+ const shareLinks = collectUserShareLinks(user?.links)
return (
@@ -131,6 +134,10 @@ export function UserDetailSheet({ username, open, onOpenChange }: UserDetailShee
Соединения
{formatNumber(user.current_connections)}
+ {' / '}
+ {user.max_tcp_conns != null
+ ? formatNumber(user.max_tcp_conns)
+ : '∞'}
@@ -156,6 +163,47 @@ export function UserDetailSheet({ username, open, onOpenChange }: UserDetailShee
+
+ Лимиты
+
+
+
- Одновременные TCP
+ -
+ {formatNumber(user.current_connections)}
+ {' / '}
+ {user.max_tcp_conns != null
+ ? formatNumber(user.max_tcp_conns)
+ : 'без лимита'}
+
+
+
+
- Квота трафика
+ -
+ {user.data_quota_bytes != null && user.data_quota_bytes > 0
+ ? `${formatBytes(user.total_octets)} / ${formatBytes(user.data_quota_bytes)}`
+ : `${formatBytes(user.total_octets)} · без квоты`}
+
+
+
+
- Истекает
+ -
+ {user.expiration_rfc3339
+ ? new Date(user.expiration_rfc3339).toLocaleString('ru-RU')
+ : '—'}
+
+
+
+
+
+ {shareLinks.length > 0 ? (
+ <>
+
+
+ >
+ ) : null}
+
+
+
diff --git a/apps/web/src/components/users/user-share-links.tsx b/apps/web/src/components/users/user-share-links.tsx
new file mode 100644
index 0000000..7a74332
--- /dev/null
+++ b/apps/web/src/components/users/user-share-links.tsx
@@ -0,0 +1,98 @@
+'use no memo'
+
+import { CopyIcon, ExternalLinkIcon } from 'lucide-react'
+import { toast } from 'sonner'
+
+import { Badge } from '@/components/reui/badge'
+import { Button } from '@telemt/ui/components/button'
+import type { UserShareLink } from '@/lib/telemt'
+
+interface UserShareLinksProps {
+ links: UserShareLink[]
+}
+
+/** Proxy share links after create — tg:// and https://t.me/proxy. */
+export function UserShareLinks({ links }: UserShareLinksProps) {
+ if (links.length === 0) {
+ return (
+
+ Ссылки подключения не вернулись из API. Откройте карточку пользователя
+ позже или проверьте конфиг Telemt.
+
+ )
+ }
+
+ return (
+
+
Ссылки для Telegram
+
+
+ )
+}
diff --git a/apps/web/src/components/users/users-columns.tsx b/apps/web/src/components/users/users-columns.tsx
index f98632d..d8c56f7 100644
--- a/apps/web/src/components/users/users-columns.tsx
+++ b/apps/web/src/components/users/users-columns.tsx
@@ -1,142 +1,548 @@
-import { useMemo } from 'react'
+'use no memo'
+
+import { memo, useMemo, useState } from 'react'
import { type ColumnDef, type Row } from '@tanstack/react-table'
-import { EyeIcon, MoreHorizontalIcon } from 'lucide-react'
+import {
+ CopyIcon,
+ EyeIcon,
+ MoreHorizontalIcon,
+ Pin,
+ PinOffIcon,
+ Trash2Icon,
+} from 'lucide-react'
+import { toast } from 'sonner'
import { Badge } from '@/components/reui/badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
+import { DataGridTableRowPin } from '@/components/reui/data-grid/data-grid-table'
+import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
+import { cn } from '@telemt/ui/lib/utils'
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@telemt/ui/components/alert-dialog'
+import { Avatar, AvatarFallback } from '@telemt/ui/components/avatar'
import { Button } from '@telemt/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
+ DropdownMenuGroup,
DropdownMenuItem,
+ DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@telemt/ui/components/dropdown-menu'
-import { formatBytes, formatNumber, type UserInfo } from '@/lib/telemt'
+import {
+ Progress,
+ ProgressLabel,
+ ProgressValue,
+} from '@telemt/ui/components/progress'
+import { Skeleton } from '@telemt/ui/components/skeleton'
+import {
+ formatBytes,
+ formatNumber,
+ type UserInfo,
+} from '@/lib/telemt'
+
+export function EnabledBadge({ enabled }: { enabled: boolean }) {
+ return (
+
+
+ {enabled ? 'Вкл' : 'Выкл'}
+
+ )
+}
+
+function ConnectionsCell({ user }: { user: UserInfo }) {
+ const current = Number(user.current_connections ?? 0)
+ const hasMax =
+ typeof user.max_tcp_conns === 'number' && user.max_tcp_conns > 0
+ const cap = hasMax ? user.max_tcp_conns! : Math.max(current, 10)
+ const score = Math.min(100, Math.round((current / cap) * 100))
+ const indicatorClass =
+ score >= 75
+ ? '**:data-[slot=progress-indicator]:bg-emerald-500'
+ : score >= 40
+ ? '**:data-[slot=progress-indicator]:bg-amber-500'
+ : '**:data-[slot=progress-indicator]:bg-red-500'
+ const label = hasMax
+ ? `${formatNumber(current)} / ${formatNumber(user.max_tcp_conns)}`
+ : `${formatNumber(current)} / ∞`
+
+ return (
+
+
+
+ )
+}
+
+function IpListCell({ user }: { user: UserInfo }) {
+ const ips = user.active_unique_ips_list ?? []
+ if (ips.length === 0) {
+ return
—
+ }
+ const shown = ips.slice(0, 2)
+ const rest = ips.length - shown.length
+ return (
+
+ {shown.map((ip) => (
+
+ {ip}
+
+ ))}
+ {rest > 0 ? (
+
+ +{rest}
+
+ ) : null}
+
+ )
+}
+
+function QuotaCell({ user }: { user: UserInfo }) {
+ const used = Number(user.total_octets ?? 0)
+ const quota =
+ typeof user.data_quota_bytes === 'number' && user.data_quota_bytes > 0
+ ? user.data_quota_bytes
+ : null
+ if (quota == null) {
+ return (
+
+ {formatBytes(used)} · без квоты
+
+ )
+ }
+ const pct = Math.min(100, Math.round((used / quota) * 100))
+ const indicatorClass =
+ pct >= 90
+ ? '**:data-[slot=progress-indicator]:bg-red-500'
+ : pct >= 70
+ ? '**:data-[slot=progress-indicator]:bg-amber-500'
+ : '**:data-[slot=progress-indicator]:bg-emerald-500'
+
+ return (
+
+
+
+ {formatBytes(used)} / {formatBytes(quota)}
+
+
+ )
+}
+
+const UserCell = memo(function UserCell({ row }: { row: Row
}) {
+ const o = row.original
+ const enabled = o.enabled !== false
+ const initials = o.username.slice(0, 2).toUpperCase()
+
+ return (
+
+
+
+
{o.username}
+
+ {formatBytes(o.total_octets)} · {formatNumber(o.active_unique_ips)} IP
+
+
+
+ )
+})
+
+export function ActionsCell({
+ row,
+ onOpen,
+ onDelete,
+}: {
+ row: Row
+ onOpen: (user: UserInfo) => void
+ onDelete: (user: UserInfo) => void
+}) {
+ const { copyToClipboard } = useCopyToClipboard()
+ const [deleteOpen, setDeleteOpen] = useState(false)
+ const isPinned = row.getIsPinned()
+
+ return (
+ <>
+
+
+ }
+ >
+
+
+
+
+ row.pin(isPinned ? false : 'top')}>
+ {isPinned ? (
+
+ ) : (
+
+ )}
+ {isPinned ? 'Открепить' : 'Закрепить'}
+
+ onOpen(row.original)}>
+
+ Открыть
+
+ {
+ copyToClipboard(row.original.username)
+ toast.success('Имя скопировано', {
+ description: row.original.username,
+ })
+ }}
+ >
+
+ Копировать имя
+
+
+ setDeleteOpen(true)}
+ >
+
+ Удалить
+
+
+
+
+
+
+
+
+ Удалить пользователя?
+
+ Будет удалён{' '}
+
+ {row.original.username}
+
+ .
+
+
+
+ Отмена
+ {
+ setDeleteOpen(false)
+ onDelete(row.original)
+ }}
+ >
+ Удалить
+
+
+
+
+ >
+ )
+}
export function createUsersColumns(opts: {
onOpen: (user: UserInfo) => void
-}): ColumnDef[] {
+ onDelete: (user: UserInfo) => void
+}): ColumnDef[] {
return [
{
- id: 'username',
+ id: 'pin',
+ header: '',
+ cell: ({ row }) => ,
+ enableSorting: false,
+ size: 40,
+ enableResizing: false,
+ enableHiding: false,
+ meta: {
+ skeleton: ,
+ },
+ },
+ {
accessorKey: 'username',
+ id: 'username',
header: ({ column }) => (
-
+
),
- cell: ({ row }) => (
-
- ),
- size: 180,
+ cell: ({ row }) => ,
+ enableSorting: true,
+ enableHiding: false,
+ enableResizing: true,
+ minSize: 200,
+ meta: {
+ autoSize: true,
+ skeleton: (
+
+ ),
+ },
},
{
id: 'enabled',
accessorFn: (row) => (row.enabled === false ? 'off' : 'on'),
header: ({ column }) => (
-
+
),
cell: ({ row }) => (
-
- {row.original.enabled === false ? 'Выкл' : 'Вкл'}
-
+
),
- size: 100,
+ size: 110,
+ enableSorting: true,
+ enableHiding: true,
+ enableResizing: true,
+ meta: {
+ skeleton: ,
+ },
},
{
id: 'current_connections',
accessorKey: 'current_connections',
header: ({ column }) => (
-
+
),
- cell: ({ row }) => (
-
- {formatNumber(row.original.current_connections)}
-
+ cell: ({ row }) => ,
+ size: 168,
+ enableSorting: true,
+ enableHiding: true,
+ enableResizing: true,
+ meta: {
+ skeleton: (
+
+
+
+
+ ),
+ },
+ },
+ {
+ id: 'ips',
+ accessorFn: (row) =>
+ [...(row.active_unique_ips_list ?? []), ...(row.recent_unique_ips_list ?? [])].join(
+ ' ',
+ ),
+ header: ({ column }) => (
+
),
- size: 120,
+ cell: ({ row }) => ,
+ size: 200,
+ enableSorting: false,
+ enableHiding: true,
+ enableResizing: true,
+ meta: {
+ skeleton: (
+
+
+
+
+ ),
+ },
},
{
id: 'active_unique_ips',
accessorKey: 'active_unique_ips',
header: ({ column }) => (
-
+
),
cell: ({ row }) => (
-
+
{formatNumber(row.original.active_unique_ips)}
),
size: 120,
+ enableSorting: true,
+ enableHiding: true,
+ enableResizing: true,
+ meta: {
+ skeleton: ,
+ },
},
{
id: 'recent_unique_ips',
accessorKey: 'recent_unique_ips',
header: ({ column }) => (
-
+
),
cell: ({ row }) => (
-
+
{formatNumber(row.original.recent_unique_ips)}
),
size: 120,
+ enableSorting: true,
+ enableHiding: true,
+ enableResizing: true,
+ meta: {
+ skeleton: ,
+ },
+ },
+ {
+ id: 'data_quota_bytes',
+ accessorKey: 'data_quota_bytes',
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => ,
+ size: 160,
+ enableSorting: true,
+ enableHiding: true,
+ enableResizing: true,
+ meta: {
+ skeleton: (
+
+
+
+
+ ),
+ },
},
{
id: 'total_octets',
accessorKey: 'total_octets',
header: ({ column }) => (
-
+
),
cell: ({ row }) => (
- {formatBytes(row.original.total_octets)}
+
+ {formatBytes(row.original.total_octets)}
+
),
size: 120,
+ enableSorting: true,
+ enableHiding: true,
+ enableResizing: true,
+ meta: {
+ skeleton: ,
+ },
+ },
+ {
+ id: 'max_tcp_conns',
+ accessorKey: 'max_tcp_conns',
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => (
+
+ {row.original.max_tcp_conns != null
+ ? formatNumber(row.original.max_tcp_conns)
+ : '∞'}
+
+ ),
+ size: 110,
+ enableSorting: true,
+ enableHiding: true,
+ enableResizing: true,
+ meta: {
+ skeleton: ,
+ },
+ },
+ {
+ id: 'expiration_rfc3339',
+ accessorKey: 'expiration_rfc3339',
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => (
+
+ {row.original.expiration_rfc3339
+ ? new Date(row.original.expiration_rfc3339).toLocaleString('ru-RU')
+ : '—'}
+
+ ),
+ size: 160,
+ enableSorting: true,
+ enableHiding: true,
+ enableResizing: true,
+ meta: {
+ skeleton: ,
+ },
},
{
id: 'actions',
+ header: '',
+ cell: ({ row }) => (
+
+ ),
enableSorting: false,
enableHiding: false,
- header: () => Действия,
- cell: ({ row }) => ,
- size: 56,
+ enableResizing: false,
+ size: 52,
+ meta: {
+ skeleton: ,
+ },
},
]
}
-function UserRowActions({
- row,
- onOpen,
-}: {
- row: Row
- onOpen: (user: UserInfo) => void
-}) {
- return (
-
-
- }
- >
-
-
-
- onOpen(row.original)}>
-
- Открыть карточку
-
-
-
- )
-}
-
export function useUsersFilterFields() {
return useMemo(
() => [
@@ -144,18 +550,31 @@ export function useUsersFilterFields() {
key: 'username',
label: 'Имя',
type: 'text' as const,
- className: 'w-44',
+ className: 'w-40',
placeholder: 'Поиск…',
},
+ {
+ key: 'ip',
+ label: 'IP',
+ type: 'text' as const,
+ className: 'w-44',
+ placeholder: '1.2.3.4…',
+ },
{
key: 'enabled',
label: 'Статус',
type: 'select' as const,
- className: 'w-36',
+ searchable: false,
+ className: 'w-[140px]',
options: [
{ value: 'on', label: 'Вкл' },
{ value: 'off', label: 'Выкл' },
],
+ customValueRenderer: (values: unknown[]) => {
+ if (values.length === 0) return 'Выберите…'
+ if (values.length > 1) return `${values.length} выбрано`
+ return
+ },
},
],
[],
diff --git a/apps/web/src/components/users/users-grid-view.tsx b/apps/web/src/components/users/users-grid-view.tsx
new file mode 100644
index 0000000..1433ab7
--- /dev/null
+++ b/apps/web/src/components/users/users-grid-view.tsx
@@ -0,0 +1,605 @@
+'use no memo'
+
+import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from 'react'
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import {
+ getCoreRowModel,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getSortedRowModel,
+ useReactTable,
+ type PaginationState,
+ type RowPinningState,
+ type SortingState,
+ type VisibilityState,
+} from '@tanstack/react-table'
+import {
+ CircleDotIcon,
+ FileDownIcon,
+ FilterIcon,
+ FilterXIcon,
+ MoreHorizontalIcon,
+ PinOffIcon,
+ SettingsIcon,
+ UserIcon,
+ UserPlusIcon,
+} from 'lucide-react'
+import { toast } from 'sonner'
+
+import { DataGrid } from '@/components/reui/data-grid/data-grid'
+import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
+import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
+import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
+import {
+ createFilter,
+ Filters,
+ type Filter,
+} from '@/components/reui/filters'
+import {
+ Frame,
+ FrameDescription,
+ FrameFooter,
+ FrameHeader,
+ FramePanel,
+ FrameTitle,
+} from '@/components/reui/frame'
+import {
+ applyFiltersToData,
+ getActiveFilters,
+} from '@/components/reui-kit/filter-utils'
+import { UserDetailSheet } from '@/components/users/user-detail-sheet'
+import { UserShareLinks } from '@/components/users/user-share-links'
+import {
+ createUsersColumns,
+ useUsersFilterFields,
+} from '@/components/users/users-columns'
+import { Button } from '@telemt/ui/components/button'
+import {
+ ButtonGroup,
+ ButtonGroupText,
+} from '@telemt/ui/components/button-group'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@telemt/ui/components/dialog'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@telemt/ui/components/dropdown-menu'
+import { Field, FieldGroup, FieldLabel } from '@telemt/ui/components/field'
+import { Input } from '@telemt/ui/components/input'
+import { Separator } from '@telemt/ui/components/separator'
+import { TooltipProvider } from '@telemt/ui/components/tooltip'
+import { api, ApiError } from '@/lib/api-client'
+import {
+ collectUserShareLinks,
+ normalizeUsers,
+ unwrapData,
+ type CreateUserResponse,
+ type UserInfo,
+ type UserShareLink,
+} from '@/lib/telemt'
+
+function serializeActiveFiltersKey(active: Filter[]) {
+ return JSON.stringify(
+ active.map((f) => ({
+ field: f.field,
+ operator: f.operator,
+ values: f.values,
+ })),
+ )
+}
+
+function createDefaultUserFilters(): Filter[] {
+ return [createFilter('username', 'contains', [''])]
+}
+
+/**
+ * Users grid — 1:1 DNA of ReUI data-grid-base-2.
+ * Preview: https://reui.io/preview/base/data-grid-base-2
+ * Docs: https://reui.io/blocks
+ */
+export function UsersGridView() {
+ const qc = useQueryClient()
+ const [selectedUsername, setSelectedUsername] = useState(null)
+ const [sheetOpen, setSheetOpen] = useState(false)
+ const [createOpen, setCreateOpen] = useState(false)
+ const [username, setUsername] = useState('')
+ const [secret, setSecret] = useState('')
+ const [createdSecret, setCreatedSecret] = useState(null)
+ const [createdLinks, setCreatedLinks] = useState([])
+
+ const [pagination, setPagination] = useState({
+ pageIndex: 0,
+ pageSize: 5,
+ })
+ const [sorting, setSorting] = useState([
+ { id: 'username', desc: false },
+ ])
+ const [columnVisibility, setColumnVisibility] = useState({
+ max_tcp_conns: false,
+ expiration_rfc3339: false,
+ recent_unique_ips: false,
+ active_unique_ips: false,
+ total_octets: false,
+ })
+ const [rowPinning, setRowPinning] = useState({
+ top: [],
+ bottom: [],
+ })
+ const [filters, setFilters] = useState(createDefaultUserFilters)
+
+ const usersQuery = useQuery({
+ queryKey: ['telemt', 'users'],
+ queryFn: () => api('/api/telemt/users'),
+ refetchInterval: 10_000,
+ })
+
+ const rows = useMemo(
+ () => normalizeUsers(usersQuery.data),
+ [usersQuery.data],
+ )
+
+ const [filteredData, setFilteredData] = useState([])
+ const isInitialLoad = useRef(true)
+ const lastAppliedActiveKey = useRef(
+ serializeActiveFiltersKey(getActiveFilters(createDefaultUserFilters())),
+ )
+
+ const resolvedRowPinning = useMemo(() => {
+ const availableIds = new Set(filteredData.map((u) => u.username))
+ return {
+ top: (rowPinning.top ?? []).filter((id) => availableIds.has(id)),
+ bottom: (rowPinning.bottom ?? []).filter((id) => availableIds.has(id)),
+ }
+ }, [filteredData, rowPinning.bottom, rowPinning.top])
+
+ const filterFields = useUsersFilterFields()
+
+ const applyFilters = useCallback(
+ (source: UserInfo[], nextFilters: Filter[]) => {
+ return applyFiltersToData(source, nextFilters, (item, field) => {
+ if (field === 'enabled') return item.enabled === false ? 'off' : 'on'
+ if (field === 'ip') {
+ return [
+ ...(item.active_unique_ips_list ?? []),
+ ...(item.recent_unique_ips_list ?? []),
+ ].join(' ')
+ }
+ return (item as unknown as Record)[field]
+ })
+ },
+ [],
+ )
+
+ const handleFiltersChange = useCallback(
+ (newFilters: Filter[]) => {
+ setFilters(newFilters)
+ const newActive = getActiveFilters(newFilters)
+ const nextKey = serializeActiveFiltersKey(newActive)
+ if (nextKey === lastAppliedActiveKey.current) return
+ lastAppliedActiveKey.current = nextKey
+ setPagination((prev) => ({ ...prev, pageIndex: 0 }))
+ setFilteredData(applyFilters(rows, newFilters))
+ },
+ [applyFilters, rows],
+ )
+
+ useEffect(() => {
+ setFilteredData(applyFilters(rows, filters))
+ if (isInitialLoad.current) isInitialLoad.current = false
+ }, [rows, filters, applyFilters])
+
+ const handleOpen = useCallback((user: UserInfo) => {
+ setSelectedUsername(user.username)
+ setSheetOpen(true)
+ }, [])
+
+ const removeUser = useMutation({
+ mutationFn: async (user: UserInfo) => {
+ await api(`/api/telemt/users/${encodeURIComponent(user.username)}`, {
+ method: 'DELETE',
+ })
+ },
+ onSuccess: () => {
+ void qc.invalidateQueries({ queryKey: ['telemt', 'users'] })
+ toast.success('Пользователь удалён')
+ },
+ onError: (err) => {
+ toast.error(err instanceof ApiError ? err.message : 'Не удалось удалить')
+ },
+ })
+
+ const columns = useMemo(
+ () =>
+ createUsersColumns({
+ onOpen: handleOpen,
+ onDelete: (user) => removeUser.mutate(user),
+ }),
+ [handleOpen, removeUser],
+ )
+
+ const [columnOrder, setColumnOrder] = useState(() =>
+ columns.map((c) => c.id as string),
+ )
+
+ const table = useReactTable({
+ columns,
+ data: filteredData,
+ pageCount: Math.ceil(filteredData.length / pagination.pageSize) || 1,
+ getRowId: (row) => row.username,
+ state: {
+ pagination,
+ sorting,
+ columnOrder,
+ columnVisibility,
+ rowPinning: resolvedRowPinning,
+ },
+ enableRowPinning: true,
+ keepPinnedRows: true,
+ onColumnOrderChange: setColumnOrder,
+ onColumnVisibilityChange: setColumnVisibility,
+ onPaginationChange: setPagination,
+ onSortingChange: setSorting,
+ onRowPinningChange: setRowPinning,
+ getCoreRowModel: getCoreRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ })
+
+ const create = useMutation({
+ mutationFn: async () => {
+ const body: { username: string; secret?: string } = {
+ username: username.trim(),
+ }
+ if (secret.trim()) body.secret = secret.trim()
+ return api('/api/telemt/users', {
+ method: 'POST',
+ body: JSON.stringify(body),
+ })
+ },
+ onSuccess: async (res) => {
+ const nested = unwrapData(res)
+ const data =
+ nested ??
+ (res && typeof res === 'object' && 'secret' in res
+ ? (res as CreateUserResponse)
+ : null)
+ const createdName = data?.user?.username ?? username.trim()
+ let links = collectUserShareLinks(data?.user?.links)
+ if (links.length === 0 && createdName) {
+ try {
+ const detail = await api(
+ `/api/telemt/users/${encodeURIComponent(createdName)}`,
+ )
+ const user =
+ unwrapData(detail) ?? (detail as UserInfo | undefined)
+ links = collectUserShareLinks(user?.links)
+ } catch {
+ // links optional if Telemt omits them
+ }
+ }
+ setCreatedSecret(data?.secret ?? null)
+ setCreatedLinks(links)
+ setUsername('')
+ setSecret('')
+ void qc.invalidateQueries({ queryKey: ['telemt', 'users'] })
+ toast.success('Пользователь создан')
+ if (!data?.secret && links.length === 0) {
+ setCreateOpen(false)
+ }
+ },
+ onError: (err) => {
+ toast.error(err instanceof ApiError ? err.message : 'Не удалось создать')
+ },
+ })
+
+ function handleCreateSubmit(e: FormEvent) {
+ e.preventDefault()
+ if (!username.trim()) return
+ create.mutate()
+ }
+
+ function handleCreateOpenChange(next: boolean) {
+ setCreateOpen(next)
+ if (!next) {
+ setCreatedSecret(null)
+ setCreatedLinks([])
+ setUsername('')
+ setSecret('')
+ }
+ }
+
+ const showClearButton = filters.length > 0
+ const showCreated = Boolean(createdSecret) || createdLinks.length > 0
+
+ return (
+ <>
+
+ handleOpen(row)}
+ emptyMessage={
+ !usersQuery.isLoading && filteredData.length === 0
+ ? 'Нет пользователей по фильтрам. Сбросьте фильтры или создайте аккаунт.'
+ : undefined
+ }
+ tableLayout={{
+ rowsPinnable: true,
+ columnsPinnable: true,
+ columnsResizable: true,
+ columnsMovable: true,
+ columnsVisibility: true,
+ dense: true,
+ }}
+ >
+
+
+
+
+ Пользователи
+
+
+ {usersQuery.isLoading
+ ? 'Загрузка…'
+ : `${rows.length} аккаунтов Telemt`}
+
+
+
+
+
+
+
+
+ Filters
+
+ }
+ />
+
+ {(resolvedRowPinning.top?.length ?? 0) > 0 && (
+
+
+
+ {resolvedRowPinning.top?.length}{' '}
+ {resolvedRowPinning.top?.length === 1
+ ? 'user'
+ : 'users'}{' '}
+ pinned
+
+
+ )}
+ {showClearButton && (
+
+ )}
+
+
+
+ Actions
+
+ }
+ />
+
+
+ {
+ const lines = [
+ 'username,enabled,connections,active_ips,traffic',
+ ...filteredData.map(
+ (u) =>
+ `${u.username},${u.enabled !== false},${u.current_connections ?? 0},${u.active_unique_ips ?? 0},${u.total_octets ?? 0}`,
+ ),
+ ]
+ const blob = new Blob([lines.join('\n')], {
+ type: 'text/csv;charset=utf-8',
+ })
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = 'telemt-users.csv'
+ a.click()
+ URL.revokeObjectURL(url)
+ toast.success('CSV экспортирован')
+ }}
+ >
+
+ Export CSV
+
+
+ toast.message('Фильтр по статусу', {
+ description: 'Используйте Filters → Статус.',
+ })
+ }
+ >
+
+ По статусу
+
+
+
+ toast.info('Настройки колонок', {
+ description:
+ 'В заголовках таблицы можно показать или скрыть поля.',
+ })
+ }
+ >
+
+ Column settings
+
+
+
+
+
+
+
+ {usersQuery.isError ? (
+
+
+
+ Не удалось загрузить пользователей.
+
+
+ ) : (
+ <>
+
+
+
+
+
+
+
+ >
+ )}
+
+
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/apps/web/src/lib/telemt-error-classes.ts b/apps/web/src/lib/telemt-error-classes.ts
new file mode 100644
index 0000000..aac45f2
--- /dev/null
+++ b/apps/web/src/lib/telemt-error-classes.ts
@@ -0,0 +1,80 @@
+/** Human-readable labels for Telemt connection / handshake error classes. */
+
+export interface TelemtErrorClassLabel {
+ /** API / English technical name */
+ en: string
+ /** Human-friendly Russian */
+ ru: string
+}
+
+const ERROR_CLASS_LABELS: Record = {
+ tls_handshake_bad_client: {
+ en: 'Bad TLS client handshake',
+ ru: 'Клиент сорвал или испортил TLS-рукопожатие',
+ },
+ direct_modes_disabled: {
+ en: 'Direct modes disabled',
+ ru: 'Прямые режимы (classic/secure) отключены',
+ },
+ unknown_tls_sni: {
+ en: 'Unknown TLS SNI',
+ ru: 'Неизвестный TLS SNI / домен',
+ },
+ tls_clienthello_len_out_of_bounds: {
+ en: 'ClientHello length out of bounds',
+ ru: 'Некорректная длина ClientHello',
+ },
+ tls_handshake_timeout: {
+ en: 'TLS handshake timeout',
+ ru: 'Таймаут TLS-рукопожатия',
+ },
+ auth_key_invalid: {
+ en: 'Invalid auth key',
+ ru: 'Неверный ключ авторизации',
+ },
+ user_disabled: {
+ en: 'User disabled',
+ ru: 'Пользователь отключён',
+ },
+ user_expired: {
+ en: 'User expired',
+ ru: 'Срок действия пользователя истёк',
+ },
+ quota_exceeded: {
+ en: 'Quota exceeded',
+ ru: 'Превышена квота трафика',
+ },
+ max_connections: {
+ en: 'Max connections reached',
+ ru: 'Достигнут лимит одновременных соединений',
+ },
+ probe_or_scanner: {
+ en: 'Probe or scanner',
+ ru: 'Похоже на сканер / пробу',
+ },
+ bad_packet: {
+ en: 'Bad packet',
+ ru: 'Повреждённый или неожиданный пакет',
+ },
+ upstream_unavailable: {
+ en: 'Upstream unavailable',
+ ru: 'Upstream недоступен',
+ },
+ me_pool_unavailable: {
+ en: 'ME pool unavailable',
+ ru: 'Пул Middle-End недоступен',
+ },
+}
+
+export function resolveErrorClassLabel(rawClass: string): TelemtErrorClassLabel & {
+ code: string
+} {
+ const code = rawClass.trim()
+ const known = ERROR_CLASS_LABELS[code]
+ if (known) return { code, ...known }
+ return {
+ code,
+ en: code.replaceAll('_', ' '),
+ ru: `Неизвестная ошибка: ${code}`,
+ }
+}
diff --git a/apps/web/src/lib/telemt.ts b/apps/web/src/lib/telemt.ts
index d0a5e49..98c0741 100644
--- a/apps/web/src/lib/telemt.ts
+++ b/apps/web/src/lib/telemt.ts
@@ -37,6 +37,155 @@ export interface UserInfo {
classic?: string[]
secure?: string[]
tls?: string[]
+ tls_domains?: Array<{ domain?: string; link?: string }>
+ }
+}
+
+export function asRecord(value: unknown): Record {
+ return value && typeof value === 'object' && !Array.isArray(value)
+ ? (value as Record)
+ : {}
+}
+
+/** 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 RuntimeMeQualityPayload {
+ counters?: RuntimeMeQualityCountersData
+ route_drops?: RuntimeMeQualityRouteDropData
+ drain_gate?: RuntimeMeQualityDrainGateData
+}
+
+export interface RuntimeMeQualityData {
+ enabled?: boolean
+ reason?: string | null
+ generated_at_epoch_secs?: number
+ data?: RuntimeMeQualityPayload | null
+}
+
+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,
}
}
@@ -196,8 +345,43 @@ export function formatBytes(octets: unknown): string {
return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}`
}
-export function asRecord(value: unknown): Record {
- return value && typeof value === 'object' && !Array.isArray(value)
- ? (value as Record)
- : {}
+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
}
diff --git a/apps/web/src/routes/dashboard.tsx b/apps/web/src/routes/dashboard.tsx
index 8fe187c..e1688be 100644
--- a/apps/web/src/routes/dashboard.tsx
+++ b/apps/web/src/routes/dashboard.tsx
@@ -13,6 +13,7 @@ import { PageHeader } from '@/components/page-header'
import { MetricListFrame, MetricRow, RankedBarList, StatusBadge } from '@/components/metric-list'
import { UI_SURFACE } from '@/lib/ui-surface'
import { api } from '@/lib/api-client'
+import { resolveErrorClassLabel } from '@/lib/telemt-error-classes'
import {
formatNumber,
formatUptime,
@@ -73,11 +74,27 @@ function DashboardPage() {
const telemtOk = summary.isSuccess && !summary.isError
const badClasses = (data.connections_bad_by_class ?? [])
- .map((c) => ({ label: c.class, value: Number(c.total) || 0 }))
+ .map((c) => {
+ const labels = resolveErrorClassLabel(c.class)
+ return {
+ label: labels.code,
+ title: labels.ru,
+ subtitle: `${labels.en} · ${labels.code}`,
+ value: Number(c.total) || 0,
+ }
+ })
.sort((a, b) => b.value - a.value)
.slice(0, 6)
const hsClasses = (data.handshake_failures_by_class ?? [])
- .map((c) => ({ label: c.class, value: Number(c.total) || 0 }))
+ .map((c) => {
+ const labels = resolveErrorClassLabel(c.class)
+ return {
+ label: labels.code,
+ title: labels.ru,
+ subtitle: `${labels.en} · ${labels.code}`,
+ value: Number(c.total) || 0,
+ }
+ })
.sort((a, b) => b.value - a.value)
.slice(0, 6)
diff --git a/apps/web/src/routes/runtime.tsx b/apps/web/src/routes/runtime.tsx
index 0ecc925..60108f8 100644
--- a/apps/web/src/routes/runtime.tsx
+++ b/apps/web/src/routes/runtime.tsx
@@ -3,11 +3,22 @@ import { useQuery } from '@tanstack/react-query'
import { PageHeader } from '@/components/page-header'
import { MetricListFrame, MetricRow, StatusBadge } from '@/components/metric-list'
+import { MePoolPanel } from '@/components/runtime/me-pool-panel'
import { Badge } from '@/components/reui/badge'
import { api } from '@/lib/api-client'
-import { asRecord, formatNumber, unwrapData } from '@/lib/telemt'
+import {
+ asRecord,
+ formatNumber,
+ parseMePoolState,
+ parseMeQuality,
+ unwrapData,
+} from '@/lib/telemt'
-/** Runtime overview — Frame metrics, no raw JSON. Docs: https://reui.io/docs/components/base/frame */
+/**
+ * Runtime — gates, connections, ME pool/quality.
+ * ME: https://reui.io/preview/base/stats-7 · https://reui.io/blocks
+ * Frame: https://reui.io/docs/components/base/frame
+ */
export const Route = createFileRoute('/runtime')({
component: RuntimePage,
})
@@ -18,6 +29,11 @@ function RuntimePage() {
queryFn: () => api('/api/telemt/runtime/me_pool_state').catch(() => null),
refetchInterval: 10_000,
})
+ const meQuality = useQuery({
+ queryKey: ['telemt', 'me_quality'],
+ queryFn: () => api('/api/telemt/runtime/me_quality').catch(() => null),
+ refetchInterval: 10_000,
+ })
const gates = useQuery({
queryKey: ['telemt', 'gates'],
queryFn: () => api('/api/telemt/runtime/gates').catch(() => null),
@@ -36,7 +52,8 @@ function RuntimePage() {
refetchInterval: 15_000,
})
- const pool = asRecord(unwrapData(mePool.data) ?? mePool.data)
+ const pool = parseMePoolState(mePool.data)
+ const quality = parseMeQuality(meQuality.data)
const gateData = asRecord(unwrapData(gates.data) ?? gates.data)
const conn = asRecord(unwrapData(connSummary.data) ?? connSummary.data)
const connPayload = asRecord(conn.data ?? conn)
@@ -55,13 +72,16 @@ function RuntimePage() {
: []
const accepting = gateData.accepting_new_connections === true
- const meReady = gateData.me_runtime_ready === true || pool.status === 'ready'
+ const meReady =
+ gateData.me_runtime_ready === true ||
+ pool.status === 'ready' ||
+ (pool.enabled !== false && Boolean(pool.data))
return (
@@ -78,7 +98,7 @@ function RuntimePage() {
label="ME runtime"
value={
- {String(gateData.me_runtime_ready ?? pool.status ?? '—')}
+ {String(gateData.me_runtime_ready ?? pool.status ?? (meReady ? 'ready' : '—'))}
}
/>
@@ -102,7 +122,15 @@ function RuntimePage() {
/>
+
+
+
+
(null)
- const [sheetOpen, setSheetOpen] = useState(false)
- const [createOpen, setCreateOpen] = useState(false)
- const [username, setUsername] = useState('')
- const [secret, setSecret] = useState('')
- const [createdSecret, setCreatedSecret] = useState(null)
-
- const [pagination, setPagination] = useState({
- pageIndex: 0,
- pageSize: 10,
- })
- const [sorting, setSorting] = useState([
- { id: 'username', desc: false },
- ])
- const [filters, setFilters] = useState(() => [
- createFilter('username', 'contains', ['']),
- ])
-
- const usersQuery = useQuery({
- queryKey: ['telemt', 'users'],
- queryFn: () => api('/api/telemt/users'),
- refetchInterval: 10_000,
- })
-
- const rows = useMemo(
- () => normalizeUsers(usersQuery.data),
- [usersQuery.data],
- )
-
- const filteredData = useMemo(() => {
- return applyFiltersToData(rows, filters, (item, field) => {
- if (field === 'enabled') return item.enabled === false ? 'off' : 'on'
- return (item as unknown as Record)[field]
- })
- }, [rows, filters])
-
- useEffect(() => {
- setPagination((prev) => ({ ...prev, pageIndex: 0 }))
- }, [filters])
-
- const handleOpen = (user: UserInfo) => {
- setSelectedUsername(user.username)
- setSheetOpen(true)
- }
-
- const columns = useMemo(
- () => createUsersColumns({ onOpen: handleOpen }),
- [],
- )
- const filterFields = useUsersFilterFields()
- const activeFilters = getActiveFilters(filters)
-
- const table = useReactTable({
- data: filteredData,
- columns,
- state: { pagination, sorting },
- onPaginationChange: setPagination,
- onSortingChange: setSorting,
- getRowId: (row) => row.username,
- getCoreRowModel: getCoreRowModel(),
- getFilteredRowModel: getFilteredRowModel(),
- getSortedRowModel: getSortedRowModel(),
- getPaginationRowModel: getPaginationRowModel(),
- })
-
- const create = useMutation({
- mutationFn: async () => {
- const body: { username: string; secret?: string } = {
- username: username.trim(),
- }
- if (secret.trim()) body.secret = secret.trim()
- return api('/api/telemt/users', {
- method: 'POST',
- body: JSON.stringify(body),
- })
- },
- onSuccess: (res) => {
- const nested = unwrapData(res)
- const data =
- nested ??
- (res && typeof res === 'object' && 'secret' in res
- ? (res as CreateUserResponse)
- : null)
- setCreatedSecret(data?.secret ?? null)
- setUsername('')
- setSecret('')
- void qc.invalidateQueries({ queryKey: ['telemt', 'users'] })
- toast.success('Пользователь создан')
- if (!data?.secret) setCreateOpen(false)
- },
- onError: (err) => {
- toast.error(err instanceof ApiError ? err.message : 'Не удалось создать')
- },
- })
-
- function handleCreateSubmit(e: FormEvent) {
- e.preventDefault()
- if (!username.trim()) return
- create.mutate()
- }
-
- function handleCreateOpenChange(next: boolean) {
- setCreateOpen(next)
- if (!next) {
- setCreatedSecret(null)
- setUsername('')
- setSecret('')
- }
- }
-
- return (
-
-
setCreateOpen(true)}>
-
- Создать
-
- }
- />
-
-
- handleOpen(row)}
- tableLayout={{
- dense: true,
- width: 'auto',
- headerSticky: true,
- }}
- >
-
-
-
- Список пользователей
-
- {usersQuery.isLoading
- ? 'Загрузка…'
- : `${filteredData.length} из ${rows.length}`}
-
-
- {activeFilters.length > 0 ? (
-
- ) : null}
-
-
-
-
-
-
-
-
- {usersQuery.isError ? (
-
-
- Не удалось загрузить пользователей.
-
-
- ) : filteredData.length === 0 && !usersQuery.isLoading ? (
-
- setCreateOpen(true)}>
- Создать пользователя
-
- }
- />
-
- ) : (
- <>
-
-
-
-
-
-
- Клик по строке открывает карточку
-
-
-
- >
- )}
-
-
-
-
-
-
-
-
-
- )
+ return
}
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
index ccaea44..5eaaefa 100644
--- a/apps/web/tsconfig.json
+++ b/apps/web/tsconfig.json
@@ -13,6 +13,7 @@
},
"include": ["src", "vite.config.ts"],
"exclude": [
- "src/components/blocks/data-grid-base-2"
+ "src/components/blocks/data-grid-base-2",
+ "src/components/blocks/stats-7"
]
}