Enhance user detail and metrics components by adding share links, improving RankedBarList with titles and subtitles, and updating TypeScript configuration to exclude additional directories. Refactor runtime page to include ME pool quality metrics and improve error class label resolution in dashboard.
Build and Push Telemt Panel Docker Image / build-and-push (push) Successful in 1m58s
Build and Push Telemt Panel Docker Image / create-release (push) Skipped

This commit is contained in:
Denozordec
2026-08-04 19:47:56 +07:00
parent d35fa5a285
commit 6481ee4f1a
15 changed files with 2019 additions and 451 deletions
@@ -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: (
<TrendingUp aria-hidden="true" />
),
text: "+5.4%",
},
subtext: (
<span className="text-sm font-medium">
+8.20k{" "}
<span className="text-muted-foreground font-normal">
vs prev. 60 days
</span>
</span>
),
},
{
title: "New Customers",
subtitle: "This quarter",
value: "1,245",
valueColor: "text-blue-600",
badge: {
color: "info-light",
icon: (
<UserPlusIcon aria-hidden="true" />
),
text: "+3.2%",
},
subtext: (
<span className="text-sm font-medium">
+39{" "}
<span className="text-muted-foreground font-normal">
vs last quarter
</span>
</span>
),
},
{
title: "Churn Rate",
subtitle: "Last 30 days",
value: "2.8%",
valueColor: "text-destructive",
badge: {
color: "destructive-light",
icon: (
<TrendingDown aria-hidden="true" />
),
text: "-1.1%",
},
subtext: (
<span className="text-sm font-medium">
-0.3%{" "}
<span className="text-muted-foreground font-normal">
vs prev. 30 days
</span>
</span>
),
},
]
@@ -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 (
<Frame className="@container w-full grow">
{/* Content */}
<FramePanel className="bg-background border-border grid grid-cols-1 overflow-hidden rounded-xl border p-0! @3xl:grid-cols-3">
{cards.map((card, i) => (
<div
key={i}
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 space-y-6">
<div className="space-y-0.25">
<div className="text-foreground text-lg font-semibold">
{card.title}
</div>
<div className="text-muted-foreground 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">
{card.value}
</span>
<Badge variant={card.badge.color}>
{card.badge.icon}
{card.badge.text}
</Badge>
</div>
<div className="text-sm">{card.subtext}</div>
</div>
</div>
</div>
))}
</FramePanel>
</Frame>
)
}
@@ -0,0 +1,9 @@
import { Stats } from "./components/stats"
export function Page() {
return (
<div className="flex w-full items-center justify-center p-10 md:p-20">
<Stats />
</div>
)
}
+35 -16
View File
@@ -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 <p className="text-muted-foreground text-sm">{emptyLabel}</p>
}
const max = Math.max(...items.map((i) => i.value), 1)
const total = items.reduce((sum, i) => sum + i.value, 0) || 1
return (
<div className="flex flex-col gap-3">
{items.map((item) => (
<div key={item.label} className="flex flex-col gap-1.5">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="truncate">{item.label}</span>
<span className="text-muted-foreground tabular-nums">
{new Intl.NumberFormat('ru-RU').format(item.value)}
</span>
{items.map((item) => {
const title = item.title ?? item.label
const subtitle = item.subtitle
const share = Math.round((item.value / total) * 100)
return (
<div key={item.label} className="flex flex-col gap-1.5">
<div className="flex items-start justify-between gap-3 text-sm">
<div className="min-w-0 flex flex-col gap-0.5">
<span className="truncate font-medium">{title}</span>
{subtitle ? (
<span className="text-muted-foreground truncate font-mono text-[11px]">
{subtitle}
</span>
) : null}
</div>
<div className="flex shrink-0 flex-col items-end gap-0.5">
<span className="text-muted-foreground tabular-nums">
{new Intl.NumberFormat('ru-RU').format(item.value)}
</span>
<span className="text-muted-foreground text-[10px] tabular-nums">
{share}%
</span>
</div>
</div>
<div className="bg-muted h-1.5 overflow-hidden rounded-full">
<div
className="bg-primary h-full rounded-full"
style={{ width: `${Math.max(4, (item.value / max) * 100)}%` }}
/>
</div>
</div>
<div className="bg-muted h-1.5 overflow-hidden rounded-full">
<div
className="bg-primary h-full rounded-full"
style={{ width: `${Math.max(4, (item.value / max) * 100)}%` }}
/>
</div>
</div>
))}
)
})}
</div>
)
}
@@ -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 (
<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, generations и refill ·{' '}
{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(writers?.health?.healthy)} ·
degraded {formatNumber(writers?.health?.degraded)} · draining{' '}
{formatNumber(writers?.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>
<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>
)
}
@@ -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 (
<Sheet open={open} onOpenChange={onOpenChange}>
@@ -131,6 +134,10 @@ export function UserDetailSheet({ username, open, onOpenChange }: UserDetailShee
<dt className="text-muted-foreground">Соединения</dt>
<dd className="font-medium tabular-nums">
{formatNumber(user.current_connections)}
{' / '}
{user.max_tcp_conns != null
? formatNumber(user.max_tcp_conns)
: '∞'}
</dd>
</div>
<div className="flex flex-col gap-0.5">
@@ -156,6 +163,47 @@ export function UserDetailSheet({ username, open, onOpenChange }: UserDetailShee
<Separator />
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium">Лимиты</h3>
<dl className="grid grid-cols-2 gap-3 text-sm">
<div className="flex flex-col gap-0.5">
<dt className="text-muted-foreground">Одновременные TCP</dt>
<dd className="font-medium tabular-nums">
{formatNumber(user.current_connections)}
{' / '}
{user.max_tcp_conns != null
? formatNumber(user.max_tcp_conns)
: 'без лимита'}
</dd>
</div>
<div className="flex flex-col gap-0.5">
<dt className="text-muted-foreground">Квота трафика</dt>
<dd className="font-medium tabular-nums">
{user.data_quota_bytes != null && user.data_quota_bytes > 0
? `${formatBytes(user.total_octets)} / ${formatBytes(user.data_quota_bytes)}`
: `${formatBytes(user.total_octets)} · без квоты`}
</dd>
</div>
<div className="flex flex-col gap-0.5 col-span-2">
<dt className="text-muted-foreground">Истекает</dt>
<dd className="font-medium">
{user.expiration_rfc3339
? new Date(user.expiration_rfc3339).toLocaleString('ru-RU')
: '—'}
</dd>
</div>
</dl>
</section>
{shareLinks.length > 0 ? (
<>
<Separator />
<UserShareLinks links={shareLinks} />
</>
) : null}
<Separator />
<section className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<NetworkIcon className="text-muted-foreground size-4" aria-hidden />
@@ -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 (
<p className="text-muted-foreground text-sm">
Ссылки подключения не вернулись из API. Откройте карточку пользователя
позже или проверьте конфиг Telemt.
</p>
)
}
return (
<div className="flex flex-col gap-3">
<p className="text-sm font-medium">Ссылки для Telegram</p>
<ul className="flex flex-col gap-2">
{links.map((link) => (
<li
key={`${link.mode}-${link.http}`}
className="bg-muted/40 flex flex-col gap-2 rounded-md border p-3"
>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline" size="sm">
{link.label}
</Badge>
</div>
<a
href={link.http}
target="_blank"
rel="noopener noreferrer"
className="text-primary break-all text-xs underline-offset-2 hover:underline"
>
{link.http}
</a>
{link.tg !== link.http ? (
<code className="text-muted-foreground break-all text-[11px]">
{link.tg}
</code>
) : null}
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={async () => {
await navigator.clipboard.writeText(link.http)
toast.success('t.me ссылка скопирована')
}}
>
<CopyIcon className="size-3.5" aria-hidden />
Копировать t.me
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={async () => {
await navigator.clipboard.writeText(link.tg)
toast.success('tg:// ссылка скопирована')
}}
>
<CopyIcon className="size-3.5" aria-hidden />
Копировать tg://
</Button>
<Button
type="button"
size="sm"
variant="ghost"
render={
<a
href={link.http}
target="_blank"
rel="noopener noreferrer"
/>
}
>
<ExternalLinkIcon className="size-3.5" aria-hidden />
Открыть
</Button>
</div>
</li>
))}
</ul>
</div>
)
}
+486 -67
View File
@@ -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 (
<Badge variant="outline">
<span
className={cn(
'size-1.5 shrink-0 rounded-full!',
enabled ? 'bg-emerald-500' : 'bg-muted-foreground',
)}
/>
{enabled ? 'Вкл' : 'Выкл'}
</Badge>
)
}
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 (
<div className="flex min-w-0 flex-col gap-1">
<Progress
value={hasMax ? score : Math.min(100, current > 0 ? 40 : 0)}
className={cn(
'min-w-0 flex-1 flex-row flex-nowrap items-center gap-2 **:data-[slot=progress-track]:order-1 **:data-[slot=progress-track]:min-w-12 **:data-[slot=progress-track]:flex-1 **:data-[slot=progress-value]:order-2',
indicatorClass,
)}
>
<ProgressLabel className="sr-only">Соединения</ProgressLabel>
<ProgressValue className="text-muted-foreground shrink-0 text-[10px] leading-none tabular-nums">
{() => label}
</ProgressValue>
</Progress>
</div>
)
}
function IpListCell({ user }: { user: UserInfo }) {
const ips = user.active_unique_ips_list ?? []
if (ips.length === 0) {
return <span className="text-muted-foreground text-xs"></span>
}
const shown = ips.slice(0, 2)
const rest = ips.length - shown.length
return (
<div className="flex min-w-0 flex-wrap items-center gap-1" title={ips.join(', ')}>
{shown.map((ip) => (
<Badge key={ip} variant="secondary" size="sm" className="font-mono text-[10px]">
{ip}
</Badge>
))}
{rest > 0 ? (
<Badge variant="outline" size="sm">
+{rest}
</Badge>
) : null}
</div>
)
}
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 (
<span className="text-muted-foreground text-xs">
{formatBytes(used)} · без квоты
</span>
)
}
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 (
<div className="flex min-w-0 flex-col gap-1">
<Progress
value={pct}
className={cn(
'min-w-0 flex-1 flex-row flex-nowrap items-center gap-2 **:data-[slot=progress-track]:order-1 **:data-[slot=progress-track]:min-w-12 **:data-[slot=progress-track]:flex-1 **:data-[slot=progress-value]:order-2',
indicatorClass,
)}
>
<ProgressLabel className="sr-only">Квота</ProgressLabel>
<ProgressValue className="text-muted-foreground shrink-0 text-[10px] leading-none tabular-nums">
{() => `${pct}%`}
</ProgressValue>
</Progress>
<span className="text-muted-foreground text-[10px] tabular-nums">
{formatBytes(used)} / {formatBytes(quota)}
</span>
</div>
)
}
const UserCell = memo(function UserCell({ row }: { row: Row<UserInfo> }) {
const o = row.original
const enabled = o.enabled !== false
const initials = o.username.slice(0, 2).toUpperCase()
return (
<div className="flex items-center gap-2">
<div className="relative shrink-0">
<Avatar className="size-8">
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<span
className={cn(
'ring-background absolute right-0 bottom-0.5 size-2 rounded-full ring-2',
enabled ? 'bg-green-500' : 'bg-gray-500',
)}
aria-hidden
/>
</div>
<div className="min-w-0">
<div className="text-foreground line-clamp-1 font-medium">{o.username}</div>
<div className="text-muted-foreground line-clamp-1 text-xs">
{formatBytes(o.total_octets)} · {formatNumber(o.active_unique_ips)} IP
</div>
</div>
</div>
)
})
export function ActionsCell({
row,
onOpen,
onDelete,
}: {
row: Row<UserInfo>
onOpen: (user: UserInfo) => void
onDelete: (user: UserInfo) => void
}) {
const { copyToClipboard } = useCopyToClipboard()
const [deleteOpen, setDeleteOpen] = useState(false)
const isPinned = row.getIsPinned()
return (
<>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
size="icon"
variant="ghost"
className="size-7"
aria-label="Действия строки"
/>
}
>
<MoreHorizontalIcon aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="start" className="w-44">
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => row.pin(isPinned ? false : 'top')}>
{isPinned ? (
<PinOffIcon className="size-4" aria-hidden="true" />
) : (
<Pin className="size-4" aria-hidden="true" />
)}
{isPinned ? 'Открепить' : 'Закрепить'}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onOpen(row.original)}>
<EyeIcon className="size-4" aria-hidden="true" />
Открыть
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
copyToClipboard(row.original.username)
toast.success('Имя скопировано', {
description: row.original.username,
})
}}
>
<CopyIcon className="size-4" aria-hidden="true" />
Копировать имя
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => setDeleteOpen(true)}
>
<Trash2Icon className="size-4" aria-hidden="true" />
Удалить
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Удалить пользователя?</AlertDialogTitle>
<AlertDialogDescription>
Будет удалён{' '}
<span className="text-foreground font-medium">
{row.original.username}
</span>
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
setDeleteOpen(false)
onDelete(row.original)
}}
>
Удалить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
export function createUsersColumns(opts: {
onOpen: (user: UserInfo) => void
}): ColumnDef<UserInfo, unknown>[] {
onDelete: (user: UserInfo) => void
}): ColumnDef<UserInfo>[] {
return [
{
id: 'username',
id: 'pin',
header: '',
cell: ({ row }) => <DataGridTableRowPin row={row} />,
enableSorting: false,
size: 40,
enableResizing: false,
enableHiding: false,
meta: {
skeleton: <Skeleton className="mx-auto size-7 rounded-md" />,
},
},
{
accessorKey: 'username',
id: 'username',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Пользователь" />
<DataGridColumnHeader
title="Пользователь"
visibility={true}
column={column}
/>
),
cell: ({ row }) => (
<button
type="button"
className="text-left font-medium hover:underline"
onClick={() => opts.onOpen(row.original)}
>
{row.original.username}
</button>
),
size: 180,
cell: ({ row }) => <UserCell row={row} />,
enableSorting: true,
enableHiding: false,
enableResizing: true,
minSize: 200,
meta: {
autoSize: true,
skeleton: (
<div className="flex min-w-0 items-center gap-2">
<Skeleton className="size-8 shrink-0 rounded-full" />
<div className="flex min-w-0 flex-col gap-0.5">
<Skeleton className="h-3.5 w-32" />
<Skeleton className="h-3 w-40" />
</div>
</div>
),
},
},
{
id: 'enabled',
accessorFn: (row) => (row.enabled === false ? 'off' : 'on'),
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Статус" />
<DataGridColumnHeader title="Статус" visibility={true} column={column} />
),
cell: ({ row }) => (
<Badge
variant={row.original.enabled === false ? 'warning-light' : 'success-light'}
size="sm"
>
{row.original.enabled === false ? 'Выкл' : 'Вкл'}
</Badge>
<EnabledBadge enabled={row.original.enabled !== false} />
),
size: 100,
size: 110,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
skeleton: <Skeleton className="h-5 w-16 rounded-full" />,
},
},
{
id: 'current_connections',
accessorKey: 'current_connections',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Соединения" />
<DataGridColumnHeader
title="Соединения"
visibility={true}
column={column}
/>
),
cell: ({ row }) => (
<span className="tabular-nums">
{formatNumber(row.original.current_connections)}
</span>
cell: ({ row }) => <ConnectionsCell user={row.original} />,
size: 168,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
skeleton: (
<div className="flex min-w-0 items-center gap-2">
<Skeleton className="h-2 min-w-12 flex-1 rounded-full" />
<Skeleton className="h-3 w-10 shrink-0" />
</div>
),
},
},
{
id: 'ips',
accessorFn: (row) =>
[...(row.active_unique_ips_list ?? []), ...(row.recent_unique_ips_list ?? [])].join(
' ',
),
header: ({ column }) => (
<DataGridColumnHeader title="IP" visibility={true} column={column} />
),
size: 120,
cell: ({ row }) => <IpListCell user={row.original} />,
size: 200,
enableSorting: false,
enableHiding: true,
enableResizing: true,
meta: {
skeleton: (
<div className="flex gap-1">
<Skeleton className="h-5 w-20 rounded-full" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
),
},
},
{
id: 'active_unique_ips',
accessorKey: 'active_unique_ips',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Активные IP" />
<DataGridColumnHeader
title="Активные IP"
visibility={true}
column={column}
/>
),
cell: ({ row }) => (
<span className="tabular-nums">
<span className="text-foreground tabular-nums">
{formatNumber(row.original.active_unique_ips)}
</span>
),
size: 120,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
skeleton: <Skeleton className="h-4 w-10" />,
},
},
{
id: 'recent_unique_ips',
accessorKey: 'recent_unique_ips',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Недавние IP" />
<DataGridColumnHeader
title="Недавние IP"
visibility={true}
column={column}
/>
),
cell: ({ row }) => (
<span className="tabular-nums">
<span className="text-foreground tabular-nums">
{formatNumber(row.original.recent_unique_ips)}
</span>
),
size: 120,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
skeleton: <Skeleton className="h-4 w-10" />,
},
},
{
id: 'data_quota_bytes',
accessorKey: 'data_quota_bytes',
header: ({ column }) => (
<DataGridColumnHeader title="Квота" visibility={true} column={column} />
),
cell: ({ row }) => <QuotaCell user={row.original} />,
size: 160,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
skeleton: (
<div className="flex min-w-0 flex-col gap-1">
<Skeleton className="h-2 w-full rounded-full" />
<Skeleton className="h-3 w-24" />
</div>
),
},
},
{
id: 'total_octets',
accessorKey: 'total_octets',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Трафик" />
<DataGridColumnHeader title="Трафик" visibility={true} column={column} />
),
cell: ({ row }) => (
<span className="tabular-nums">{formatBytes(row.original.total_octets)}</span>
<span className="text-foreground tabular-nums">
{formatBytes(row.original.total_octets)}
</span>
),
size: 120,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
skeleton: <Skeleton className="h-4 w-16" />,
},
},
{
id: 'max_tcp_conns',
accessorKey: 'max_tcp_conns',
header: ({ column }) => (
<DataGridColumnHeader title="Лимит TCP" visibility={true} column={column} />
),
cell: ({ row }) => (
<span className="text-muted-foreground tabular-nums">
{row.original.max_tcp_conns != null
? formatNumber(row.original.max_tcp_conns)
: '∞'}
</span>
),
size: 110,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
skeleton: <Skeleton className="h-4 w-12" />,
},
},
{
id: 'expiration_rfc3339',
accessorKey: 'expiration_rfc3339',
header: ({ column }) => (
<DataGridColumnHeader
title="Истекает"
visibility={true}
column={column}
/>
),
cell: ({ row }) => (
<span className="text-muted-foreground text-sm">
{row.original.expiration_rfc3339
? new Date(row.original.expiration_rfc3339).toLocaleString('ru-RU')
: '—'}
</span>
),
size: 160,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
skeleton: <Skeleton className="h-4 w-28" />,
},
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<ActionsCell row={row} onOpen={opts.onOpen} onDelete={opts.onDelete} />
),
enableSorting: false,
enableHiding: false,
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => <UserRowActions row={row} onOpen={opts.onOpen} />,
size: 56,
enableResizing: false,
size: 52,
meta: {
skeleton: <Skeleton className="size-7 rounded-md" />,
},
},
]
}
function UserRowActions({
row,
onOpen,
}: {
row: Row<UserInfo>
onOpen: (user: UserInfo) => void
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon" className="size-8" aria-label="Действия" />
}
>
<MoreHorizontalIcon className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onOpen(row.original)}>
<EyeIcon className="size-4" />
Открыть карточку
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
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 <EnabledBadge enabled={values[0] === 'on'} />
},
},
],
[],
@@ -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<string | null>(null)
const [sheetOpen, setSheetOpen] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [username, setUsername] = useState('')
const [secret, setSecret] = useState('')
const [createdSecret, setCreatedSecret] = useState<string | null>(null)
const [createdLinks, setCreatedLinks] = useState<UserShareLink[]>([])
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 5,
})
const [sorting, setSorting] = useState<SortingState>([
{ id: 'username', desc: false },
])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
max_tcp_conns: false,
expiration_rfc3339: false,
recent_unique_ips: false,
active_unique_ips: false,
total_octets: false,
})
const [rowPinning, setRowPinning] = useState<RowPinningState>({
top: [],
bottom: [],
})
const [filters, setFilters] = useState<Filter[]>(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<UserInfo[]>([])
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<string, unknown>)[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<string[]>(() =>
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<CreateUserResponse>(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<UserInfo>(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 (
<>
<TooltipProvider delay={200}>
<DataGrid
table={table}
isLoading={usersQuery.isLoading}
loadingMode="skeleton"
recordCount={filteredData.length}
onRowClick={(row) => handleOpen(row)}
emptyMessage={
!usersQuery.isLoading && filteredData.length === 0
? 'Нет пользователей по фильтрам. Сбросьте фильтры или создайте аккаунт.'
: undefined
}
tableLayout={{
rowsPinnable: true,
columnsPinnable: true,
columnsResizable: true,
columnsMovable: true,
columnsVisibility: true,
dense: true,
}}
>
<Frame spacing="sm" className="w-full">
<FrameHeader className="flex-row items-center justify-between gap-3">
<div className="flex flex-col gap-0.5">
<FrameTitle id="page-heading" className="text-balance">
Пользователи
</FrameTitle>
<FrameDescription className="text-xs text-pretty">
{usersQuery.isLoading
? 'Загрузка…'
: `${rows.length} аккаунтов Telemt`}
</FrameDescription>
</div>
<Button type="button" size="default" onClick={() => setCreateOpen(true)}>
<UserPlusIcon aria-hidden="true" />
Создать
</Button>
</FrameHeader>
<FramePanel className="p-0 shadow-none">
<div className="flex flex-wrap items-center justify-between gap-2 px-(--frame-panel-header-px) py-2.5">
<Filters
filters={filters}
fields={filterFields}
onChange={handleFiltersChange}
size="default"
trigger={
<Button
type="button"
size="default"
variant="outline"
aria-label="Фильтры"
>
<FilterIcon aria-hidden />
Filters
</Button>
}
/>
<div className="flex flex-wrap items-center gap-2">
{(resolvedRowPinning.top?.length ?? 0) > 0 && (
<ButtonGroup>
<Button
type="button"
size="default"
variant="outline"
onClick={() => setRowPinning({ top: [], bottom: [] })}
>
<PinOffIcon aria-hidden />
Unpin all
</Button>
<ButtonGroupText className="text-muted-foreground px-3 text-sm">
{resolvedRowPinning.top?.length}{' '}
{resolvedRowPinning.top?.length === 1
? 'user'
: 'users'}{' '}
pinned
</ButtonGroupText>
</ButtonGroup>
)}
{showClearButton && (
<Button
type="button"
size="default"
variant="outline"
className="shrink-0"
onClick={() => {
const next = createDefaultUserFilters()
lastAppliedActiveKey.current = serializeActiveFiltersKey(
getActiveFilters(next),
)
setFilters(next)
setFilteredData(applyFilters(rows, next))
}}
disabled={usersQuery.isLoading}
>
<FilterXIcon aria-hidden />
Clear
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
size="default"
variant="outline"
aria-label="Действия таблицы"
>
<MoreHorizontalIcon aria-hidden="true" />
Actions
</Button>
}
/>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() => {
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 экспортирован')
}}
>
<FileDownIcon aria-hidden="true" />
Export CSV
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
toast.message('Фильтр по статусу', {
description: 'Используйте Filters → Статус.',
})
}
>
<CircleDotIcon aria-hidden="true" />
По статусу
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() =>
toast.info('Настройки колонок', {
description:
'В заголовках таблицы можно показать или скрыть поля.',
})
}
>
<SettingsIcon aria-hidden="true" />
Column settings
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<Separator />
{usersQuery.isError ? (
<div className="flex flex-col items-center gap-2 px-6 py-10 text-center">
<UserIcon className="text-muted-foreground size-8" aria-hidden />
<p className="text-destructive text-sm">
Не удалось загрузить пользователей.
</p>
</div>
) : (
<>
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
<Separator />
<FrameFooter>
<DataGridPagination />
</FrameFooter>
</>
)}
</FramePanel>
</Frame>
</DataGrid>
</TooltipProvider>
<UserDetailSheet
username={selectedUsername}
open={sheetOpen}
onOpenChange={setSheetOpen}
/>
<Dialog open={createOpen} onOpenChange={handleCreateOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{showCreated ? 'Пользователь создан' : 'Новый пользователь'}
</DialogTitle>
<DialogDescription>
{showCreated
? 'Сохраните секрет и ссылки для подключения в Telegram.'
: 'Секрет можно задать (32 hex) или оставить пустым.'}
</DialogDescription>
</DialogHeader>
{showCreated ? (
<div className="flex flex-col gap-4">
{createdSecret ? (
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">Секрет</p>
<code className="bg-muted break-all rounded-md p-3 text-xs">
{createdSecret}
</code>
<Button
type="button"
variant="outline"
onClick={async () => {
await navigator.clipboard.writeText(createdSecret)
toast.success('Секрет скопирован')
}}
>
Копировать секрет
</Button>
</div>
) : null}
<UserShareLinks links={createdLinks} />
<DialogFooter>
<Button type="button" onClick={() => handleCreateOpenChange(false)}>
Готово
</Button>
</DialogFooter>
</div>
) : (
<form onSubmit={handleCreateSubmit} className="flex flex-col gap-4">
<FieldGroup>
<Field>
<FieldLabel htmlFor="new-username">Имя</FieldLabel>
<Input
id="new-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
pattern="[A-Za-z0-9_.\-]+"
maxLength={64}
required
autoFocus
/>
</Field>
<Field>
<FieldLabel htmlFor="new-secret">Секрет (опц.)</FieldLabel>
<Input
id="new-secret"
value={secret}
onChange={(e) => setSecret(e.target.value)}
spellCheck={false}
/>
</Field>
</FieldGroup>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => handleCreateOpenChange(false)}
>
Отмена
</Button>
<Button type="submit" disabled={create.isPending || !username.trim()}>
{create.isPending ? 'Создание…' : 'Создать'}
</Button>
</DialogFooter>
</form>
)}
</DialogContent>
</Dialog>
</>
)
}
+80
View File
@@ -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<string, TelemtErrorClassLabel> = {
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}`,
}
}
+188 -4
View File
@@ -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<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 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<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
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
}
+19 -2
View File
@@ -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)
+34 -6
View File
@@ -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 (
<div className="flex flex-col gap-4 md:gap-6">
<PageHeader
title="Runtime"
description="Живое состояние прокси: шлюзы, соединения и события."
description="Живое состояние прокси: шлюзы, ME pool, соединения и события."
/>
<div className="grid gap-4 md:grid-cols-2 md:gap-6">
@@ -78,7 +98,7 @@ function RuntimePage() {
label="ME runtime"
value={
<Badge variant={meReady ? 'success-light' : 'warning-light'} size="sm">
{String(gateData.me_runtime_ready ?? pool.status ?? '—')}
{String(gateData.me_runtime_ready ?? pool.status ?? (meReady ? 'ready' : '—'))}
</Badge>
}
/>
@@ -102,7 +122,15 @@ function RuntimePage() {
/>
<MetricRow label="Активные пользователи" value={formatNumber(totals.active_users)} />
</MetricListFrame>
</div>
<MePoolPanel
pool={pool}
quality={quality}
isLoading={mePool.isLoading || meQuality.isLoading}
/>
<div className="grid gap-4 md:grid-cols-2 md:gap-6">
<MetricListFrame
title="Топ по соединениям"
description="Пользователи с наибольшим числом сессий"
+4 -355
View File
@@ -1,65 +1,11 @@
"use no memo"
'use no memo'
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
type PaginationState,
type SortingState,
} from '@tanstack/react-table'
import { FilterIcon, FilterXIcon, PlusIcon, UsersIcon } from 'lucide-react'
import { useEffect, useMemo, useState, type FormEvent } from '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 { PageHeader } from '@/components/page-header'
import { EmptyState } from '@/components/empty-state'
import { createUsersColumns, useUsersFilterFields } from '@/components/users/users-columns'
import { UserDetailSheet } from '@/components/users/user-detail-sheet'
import { applyFiltersToData, getActiveFilters } from '@/components/reui-kit/filter-utils'
import { Button } from '@telemt/ui/components/button'
import { Input } from '@telemt/ui/components/input'
import { Field, FieldGroup, FieldLabel } from '@telemt/ui/components/field'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@telemt/ui/components/dialog'
import { Separator } from '@telemt/ui/components/separator'
import { TooltipProvider } from '@telemt/ui/components/tooltip'
import { api, ApiError } from '@/lib/api-client'
import {
normalizeUsers,
unwrapData,
type CreateUserResponse,
type UserInfo,
} from '@/lib/telemt'
import { UsersGridView } from '@/components/users/users-grid-view'
/**
* Users — ReUI data-grid-base-2 DNA + detail sheet timeline.
* Users — ReUI data-grid-base-2 1:1 + Telegram share links after create.
* Preview: https://reui.io/preview/base/data-grid-base-2
* Docs: https://reui.io/blocks · https://reui.io/docs/components/base/timeline
*/
@@ -68,302 +14,5 @@ export const Route = createFileRoute('/users')({
})
function UsersPage() {
const qc = useQueryClient()
const [selectedUsername, setSelectedUsername] = useState<string | null>(null)
const [sheetOpen, setSheetOpen] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [username, setUsername] = useState('')
const [secret, setSecret] = useState('')
const [createdSecret, setCreatedSecret] = useState<string | null>(null)
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
})
const [sorting, setSorting] = useState<SortingState>([
{ id: 'username', desc: false },
])
const [filters, setFilters] = useState<Filter[]>(() => [
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<string, unknown>)[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<CreateUserResponse>(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 (
<div className="flex flex-col gap-4 md:gap-6">
<PageHeader
title="Пользователи"
description="Учётные записи Telemt, фильтры и карточка с IP/таймлайном."
actions={
<Button type="button" onClick={() => setCreateOpen(true)}>
<PlusIcon className="size-4" />
Создать
</Button>
}
/>
<TooltipProvider delay={0}>
<DataGrid
table={table}
recordCount={filteredData.length}
isLoading={usersQuery.isLoading}
onRowClick={(row) => handleOpen(row)}
tableLayout={{
dense: true,
width: 'auto',
headerSticky: true,
}}
>
<Frame className="w-full">
<FrameHeader className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 flex-col gap-1">
<FrameTitle>Список пользователей</FrameTitle>
<FrameDescription>
{usersQuery.isLoading
? 'Загрузка…'
: `${filteredData.length} из ${rows.length}`}
</FrameDescription>
</div>
{activeFilters.length > 0 ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
setFilters([createFilter('username', 'contains', [''])])
}
>
<FilterXIcon className="size-3.5" />
Сбросить
</Button>
) : null}
</FrameHeader>
<FramePanel className="flex flex-col gap-0 p-0">
<div className="flex flex-wrap items-center gap-2 border-b px-4 py-3">
<FilterIcon className="text-muted-foreground size-3.5" aria-hidden />
<Filters
filters={filters}
fields={filterFields}
onChange={setFilters}
size="sm"
/>
</div>
{usersQuery.isError ? (
<div className="p-4">
<p className="text-destructive text-sm">
Не удалось загрузить пользователей.
</p>
</div>
) : filteredData.length === 0 && !usersQuery.isLoading ? (
<div className="p-4">
<EmptyState
icon={UsersIcon}
title="Нет пользователей"
description="Создайте первого или измените фильтры."
centered={false}
action={
<Button type="button" onClick={() => setCreateOpen(true)}>
Создать пользователя
</Button>
}
/>
</div>
) : (
<>
<DataGridScrollArea className="max-h-[min(70vh,720px)]">
<DataGridTable />
</DataGridScrollArea>
<Separator />
<FrameFooter className="flex items-center justify-between gap-3">
<span className="text-muted-foreground text-xs">
Клик по строке открывает карточку
</span>
<DataGridPagination
sizes={[10, 20, 50]}
info="{from}{to} из {count}"
rowsPerPageLabel="Строк"
/>
</FrameFooter>
</>
)}
</FramePanel>
</Frame>
</DataGrid>
</TooltipProvider>
<UserDetailSheet
username={selectedUsername}
open={sheetOpen}
onOpenChange={setSheetOpen}
/>
<Dialog open={createOpen} onOpenChange={handleCreateOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{createdSecret ? 'Пользователь создан' : 'Новый пользователь'}
</DialogTitle>
<DialogDescription>
{createdSecret
? 'Сохраните секрет — повторно он не показывается.'
: 'Секрет можно задать (32 hex) или оставить пустым.'}
</DialogDescription>
</DialogHeader>
{createdSecret ? (
<div className="flex flex-col gap-3">
<code className="bg-muted break-all rounded-md p-3 text-xs">
{createdSecret}
</code>
<Button
type="button"
variant="outline"
onClick={async () => {
await navigator.clipboard.writeText(createdSecret)
toast.success('Скопировано')
}}
>
Копировать секрет
</Button>
<DialogFooter>
<Button type="button" onClick={() => handleCreateOpenChange(false)}>
Готово
</Button>
</DialogFooter>
</div>
) : (
<form onSubmit={handleCreateSubmit} className="flex flex-col gap-4">
<FieldGroup>
<Field>
<FieldLabel htmlFor="new-username">Имя</FieldLabel>
<Input
id="new-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
pattern="[A-Za-z0-9_.\-]+"
maxLength={64}
required
autoFocus
/>
</Field>
<Field>
<FieldLabel htmlFor="new-secret">Секрет (опц.)</FieldLabel>
<Input
id="new-secret"
value={secret}
onChange={(e) => setSecret(e.target.value)}
spellCheck={false}
/>
</Field>
</FieldGroup>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => handleCreateOpenChange(false)}
>
Отмена
</Button>
<Button type="submit" disabled={create.isPending || !username.trim()}>
{create.isPending ? 'Создание…' : 'Создать'}
</Button>
</DialogFooter>
</form>
)}
</DialogContent>
</Dialog>
</div>
)
return <UsersGridView />
}
+2 -1
View File
@@ -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"
]
}