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.
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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="Пользователи с наибольшим числом сессий"
|
||||
|
||||
@@ -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 />
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user