feat(mcp): подготовить сервер и доступ к приёмке

This commit is contained in:
Denozordec committed 2026-11-03 03:12:03 +07:00
commit bf3d50e1ff
3835 files changed
+537122

No files matched your search

@@ -0,0 +1,49 @@
"use client"
import { createContext, useContext, useId, type ComponentProps } from "react"
import {
Stepper as RegistryStepper,
StepperNav as RegistryNav,
StepperContent as RegistryContent,
StepperTrigger as RegistryTrigger,
useStepItem,
type StepperProps,
} from "@/components/reui/stepper"
export { StepperItem, StepperIndicator, StepperSeparator, StepperTitle, StepperPanel } from "@/components/reui/stepper"
// The registry Nav/Content do not forward HTML attributes. Keep its layout,
// focus handling and styling; supply tab semantics in transparent wrappers.
const Context = createContext<{ prefix: string; orientation: "horizontal" | "vertical" } | null>(null)
function useScope() {
const context = useContext(Context)
if (!context) throw new Error("Шаги должны находиться внутри Stepper")
return context
}
export function Stepper({ orientation = "horizontal", ...props }: StepperProps) {
const prefix = useId()
return <Context.Provider value={{ prefix, orientation }}>
<RegistryStepper {...props} orientation={orientation} role="group" aria-orientation={undefined} />
</Context.Provider>
}
export function StepperNav({ children, className }: Pick<ComponentProps<typeof RegistryNav>, "children" | "className">) {
const { orientation } = useScope()
return <RegistryNav className={className}>
<div role="tablist" aria-label="Шаги формы" aria-orientation={orientation} className="contents">{children}</div>
</RegistryNav>
}
export function StepperTrigger(props: ComponentProps<typeof RegistryTrigger>) {
const { prefix } = useScope()
const { step } = useStepItem()
return <RegistryTrigger {...props} id={`${prefix}-tab-${step}`} aria-controls={`${prefix}-panel-${step}`} />
}
export function StepperContent({ value, children, className }: Pick<ComponentProps<typeof RegistryContent>, "value" | "children" | "className">) {
const { prefix } = useScope()
return <RegistryContent value={value} forceMount className={className}>
<div role="tabpanel" id={`${prefix}-panel-${value}`} aria-labelledby={`${prefix}-tab-${value}`} className="contents">{children}</div>
</RegistryContent>
}
+93
View File
@@ -0,0 +1,93 @@
"use client"
import type { ReactNode } from "react"
import type { LucideIcon } from "lucide-react"
import { Badge } from "@/components/reui/badge"
import {
Frame,
FrameHeader,
FramePanel,
FrameTitle,
} from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile"
import { cn } from "@/lib/utils"
/**
* Sibling Frame columns for dashboard attention queue.
* Preview: https://reui.io/preview/base/dashboard-1 · https://reui.io/preview/base/stats-12
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/icon-tile · https://reui.io/docs/components/base/badge
*/
export interface AttentionQueueColumn {
id: string
title: string
icon: LucideIcon
iconClassName?: string
count: number
countVariant?: "destructive" | "warning" | "secondary" | "destructive-light" | "warning-light"
emptyTitle: string
emptyDescription: string
emptyAction?: ReactNode
children: ReactNode
}
interface AttentionQueueProps {
columns: AttentionQueueColumn[]
className?: string
}
const DEFAULT_ICON_CLASS = "text-muted-foreground [&_svg]:text-current"
export function AttentionQueue({ columns, className }: AttentionQueueProps) {
return (
<div
className={cn(
"@container grid min-w-0 items-start gap-2 @3xl:grid-cols-3",
className,
)}
>
{columns.map((column) => {
const Icon = column.icon
const isEmpty = column.count === 0
return (
<Frame key={column.id} dense spacing="sm" className="min-w-0 w-full">
<FrameHeader>
<div className="flex min-w-0 items-center gap-2">
<IconTile
variant="elevated"
size="sm"
className={cn(DEFAULT_ICON_CLASS, column.iconClassName)}
aria-hidden="true"
>
<Icon />
</IconTile>
<FrameTitle className="min-w-0 truncate">{column.title}</FrameTitle>
{column.count > 0 ? (
<Badge
size="sm"
variant={column.countVariant ?? "secondary"}
className="tabular-nums"
>
{column.count}
</Badge>
) : null}
</div>
</FrameHeader>
<FramePanel className="min-w-0">
{isEmpty ? (
<div className="flex min-h-24 flex-col items-start justify-center gap-1 py-3">
<p className="text-sm font-medium">{column.emptyTitle}</p>
<p className="text-muted-foreground text-xs leading-relaxed">
{column.emptyDescription}
</p>
{column.emptyAction ? <div className="pt-1">{column.emptyAction}</div> : null}
</div>
) : (
column.children
)}
</FramePanel>
</Frame>
)
})}
</div>
)
}
+287
View File
@@ -0,0 +1,287 @@
"use client"
import { useMemo, useState, type ReactNode } from "react"
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/components/reui/alert"
import { Badge } from "@/components/reui/badge"
import { Frame, FramePanel } from "@/components/reui/frame"
import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
CheckIcon,
CopyIcon,
DownloadIcon,
LoaderCircleIcon,
RefreshCwIcon,
TriangleAlertIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
export type CodeExportFormat = {
id: string
label: string
filename: string
code: string
/** Empty / unavailable — show warning instead of code */
emptyMessage?: string
/** Content came from live device fetch */
isLive?: boolean
}
function downloadText(filename: string, content: string) {
const blob = new Blob([content], { type: "text/plain;charset=utf-8" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
function highlightLine(line: string): string {
const trimmed = line.trimStart()
if (line.startsWith("#") || trimmed.startsWith(";")) return "text-muted-foreground"
if (trimmed.startsWith("[")) return "text-info"
if (trimmed.startsWith("/")) return "text-info"
if (/^\s+[a-zA-Z]/.test(line) || /^[A-Za-z][\w-]*=/.test(line)) return "text-foreground"
return "text-foreground/90"
}
function CodeBlock({ code, className }: { code: string; className?: string }) {
const lines = code.length ? code.split("\n") : [""]
return (
<pre className={cn("px-4 py-3.5 text-[12px] font-mono leading-[1.65] whitespace-pre-wrap break-all select-all", className)}>
{lines.map((line, i) => (
<span key={i} className={cn("block", highlightLine(line))}>
{line || " "}
</span>
))}
</pre>
)
}
export interface CodeExportSheetProps {
open: boolean
onClose: () => void
title: string
description?: ReactNode
formats: CodeExportFormat[]
/** Initial format id when sheet opens */
initialFormatId?: string
/** Optional live fetch from device */
onLiveFetch?: (formatId: string) => void
liveBusy?: boolean
/** Short CTA — keep laconic */
liveLabel?: string
className?: string
/** Extra content under format tabs (e.g. meta strip) */
returnFocus?: () => HTMLElement | null
beforeCode?: ReactNode
}
/**
* Shared code export Sheet — ReUI Frame + sticky footer.
* Preview DNA: https://reui.io/preview/base/sheet-8 · Frame https://reui.io/docs/components/base/frame
* Alert: https://reui.io/docs/components/base/alert
*/
function CodeExportSheet({
open,
onClose,
title,
description,
formats,
initialFormatId,
onLiveFetch,
liveBusy,
liveLabel = "С роутера",
className,
beforeCode,
returnFocus,
}: CodeExportSheetProps) {
const firstId = formats[0]?.id ?? "default"
const initialTab = initialFormatId && formats.some(format => format.id === initialFormatId) ? initialFormatId : firstId
const resetKey = JSON.stringify([open, initialTab])
const [previousKey, setPreviousKey] = useState(resetKey)
const [tab, setTab] = useState(initialTab)
const [copiedCode, setCopiedCode] = useState<string | null>(null)
if (previousKey !== resetKey) {
setPreviousKey(resetKey)
if (open) { setTab(initialTab); setCopiedCode(null) }
}
const active = useMemo(
() => formats.find((f) => f.id === tab) ?? formats[0],
[formats, tab],
)
const code = active?.code ?? ""
const copied = !!code && copiedCode === code
const emptyMessage = active?.emptyMessage
const filename = active?.filename ?? "export.txt"
const canCopy = Boolean(code) && !emptyMessage
const isLive = Boolean(active?.isLive)
const showTabs = formats.length > 1
async function handleCopy() {
if (!canCopy) return
setCopiedCode(null)
try {
await navigator.clipboard.writeText(code)
setCopiedCode(code)
setTimeout(() => setCopiedCode(null), 1800)
} catch { setCopiedCode(null); toast.error("Не удалось скопировать. Повторите действие или скачайте файл.") }
}
return (
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<SheetContent finalFocus={returnFocus}
className={cn(
"flex w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-xl",
className,
)}
>
<SheetHeader className="shrink-0 gap-1 border-b px-5 pt-5 pb-4 pr-12">
<SheetTitle className="text-base font-semibold tracking-tight">
{title}
</SheetTitle>
{description ? (
<SheetDescription className="font-mono text-xs">
{description}
</SheetDescription>
) : null}
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col gap-3 px-5 py-4">
{showTabs ? (
<Tabs
value={tab}
onValueChange={(v) => setTab(String(v))}
className="shrink-0 gap-0"
>
<TabsList className="h-9 w-full">
{formats.map((f) => (
<TabsTrigger key={f.id} value={f.id} className="flex-1 px-2 text-xs sm:text-sm">
{f.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
) : null}
{onLiveFetch || beforeCode ? (
<div className="flex shrink-0 flex-wrap items-center justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-2">
{onLiveFetch ? (
<Badge
variant={isLive ? "success-light" : "secondary"}
size="sm"
radius="full"
>
{isLive ? "С роутера" : "Локально"}
</Badge>
) : null}
{beforeCode}
</div>
{onLiveFetch ? (
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0"
disabled={liveBusy}
onClick={() => onLiveFetch(tab)}
aria-label="Подтянуть конфиг с роутера с private-key"
>
{liveBusy ? (
<LoaderCircleIcon className="size-3.5 animate-spin" />
) : (
<RefreshCwIcon className="size-3.5" />
)}
{liveBusy ? "Загрузка…" : liveLabel}
</Button>
) : null}
</div>
) : null}
<Frame dense className="flex min-h-0 flex-1 flex-col">
<FramePanel className="relative flex min-h-0 flex-1 flex-col overflow-hidden p-0">
{emptyMessage ? (
<div className="flex flex-1 items-center p-4">
<Alert variant="warning" className="w-full">
<TriangleAlertIcon />
<AlertTitle>Нет данных</AlertTitle>
<AlertDescription>{emptyMessage}</AlertDescription>
</Alert>
</div>
) : (
<ScrollArea className="h-full min-h-0 flex-1">
<div className="min-h-[min(52vh,22rem)]">
<CodeBlock code={code} />
</div>
</ScrollArea>
)}
</FramePanel>
</Frame>
{onLiveFetch && !isLive ? (
<p className="text-muted-foreground shrink-0 text-[11px] leading-snug">
Локальный снимок. Подтяните с роутера, если нужен private-key с устройства.
</p>
) : null}
</div>
<SheetFooter className="shrink-0 flex-row items-center justify-between gap-3 border-t px-5 py-3.5 sm:flex-row">
<SheetClose
render={
<Button type="button" variant="ghost" className="shrink-0 px-3" />
}
>
Закрыть
</SheetClose>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="default"
disabled={!canCopy}
onClick={() => downloadText(filename, code)}
>
<DownloadIcon className="size-4" />
Файл
</Button>
<Button
type="button"
size="default"
disabled={!canCopy}
onClick={handleCopy}
className="min-w-28"
>
{copied ? (
<CheckIcon className="size-4 text-success" />
) : (
<CopyIcon className="size-4" />
)}
{copied ? "Скопировано" : "Копировать"}
</Button>
</div>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
export { CodeExportSheet, CodeBlock, downloadText }
+9
View File
@@ -0,0 +1,9 @@
export { CodeExportSheet, downloadText } from "./code-export-sheet"
export type { CodeExportFormat, CodeExportSheetProps } from "./code-export-sheet"
export { KpiStatGrid, KpiStatCardTile, kpiStatItemKey } from "./kpi-stat-grid"
export type { KpiStatItem, KpiStatCardData, KpiStatVariant } from "./kpi-stat-grid"
export { kpiCols } from "./kpi-cols"
export { QuickActionGrid } from "./quick-action-grid"
export type { QuickActionItem } from "./quick-action-grid"
export { AttentionQueue } from "./attention-queue"
export type { AttentionQueueColumn } from "./attention-queue"
+12
View File
@@ -0,0 +1,12 @@
/**
* Shared grid column classes for hybrid KPI tiles.
* @see https://reui.io/preview/base/stats-12
*/
export function kpiCols(count: number): string {
if (count <= 1) return "grid-cols-1"
if (count === 2) return "grid-cols-1 @xl:grid-cols-2"
if (count === 3) return "grid-cols-1 @3xl:grid-cols-3"
if (count === 4) return "grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4"
if (count <= 6) return "grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3"
return "grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3 @6xl:grid-cols-4"
}
+309
View File
@@ -0,0 +1,309 @@
"use client"
import type { KeyboardEvent, ReactNode } from "react"
import Link from "next/link"
import { Frame, FramePanel } from "@/components/reui/frame"
import { Badge } from "@/components/reui/badge"
import { IconTile } from "@/components/reui/icon-tile"
import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "@/lib/utils"
import { kpiCols } from "./kpi-cols"
export type KpiStatVariant = "default" | "warning" | "destructive"
/**
* KPI tile — horizontal compact hybrid (icon left + label/Badge + value).
* @see https://reui.io/preview/base/stats-12
*/
export type KpiStatItem = {
id?: string
label: ReactNode
value: ReactNode
hint?: ReactNode
href?: string
onSelect?: () => void
onClick?: () => void
selected?: boolean
active?: boolean
icon?: ReactNode
iconClassName?: string
variant?: KpiStatVariant
footer?: ReactNode
}
export type KpiStatCardData = KpiStatItem & { id: string }
const DEFAULT_ICON_CLASS = "text-muted-foreground [&_svg]:text-current"
const VALUE_VARIANT_CLASS: Record<KpiStatVariant, string> = {
default: "text-foreground",
warning: "text-warning",
destructive: "text-destructive",
}
function handleCardKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault()
onActivate()
}
}
function resolveActivate(item: KpiStatItem): (() => void) | undefined {
return item.onClick ?? item.onSelect
}
function isSelected(item: KpiStatItem): boolean {
return Boolean(item.selected ?? item.active)
}
function resolveFooter(item: KpiStatItem): ReactNode {
if (item.footer) return item.footer
if (typeof item.hint === "string") {
return (
<Badge variant="outline" size="sm" className="max-w-[min(100%,11rem)] truncate">
{item.hint}
</Badge>
)
}
if (item.hint) return item.hint
return null
}
function KpiStatCardBody({ item }: { item: KpiStatItem }) {
const footer = resolveFooter(item)
const valueVariant = item.variant ?? "default"
return (
<div className="@container relative z-10 flex h-full min-w-0 items-start gap-3">
{item.icon ? (
<IconTile
variant="elevated"
aria-hidden="true"
className={cn("size-10.5 shrink-0", item.iconClassName ?? DEFAULT_ICON_CLASS)}
>
{item.icon}
</IconTile>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex min-w-0 items-start justify-between gap-2">
<div className="text-muted-foreground min-w-0 truncate text-sm font-medium">
{item.label}
</div>
{footer ? (
<div className="hidden min-w-0 max-w-[min(100%,11rem)] shrink-0 @[20rem]:block">
{footer}
</div>
) : null}
</div>
<div
className={cn(
"min-w-0 break-all text-2xl leading-none font-bold tabular-nums",
VALUE_VARIANT_CLASS[valueVariant],
)}
>
{item.value}
</div>
{footer ? (
<div className="min-w-0 max-w-full @[20rem]:hidden">{footer}</div>
) : null}
</div>
</div>
)
}
function panelClassName(item: KpiStatItem, className?: string) {
const onActivate = resolveActivate(item)
const clickable = Boolean(item.href || onActivate)
const selected = isSelected(item)
return cn(
"relative isolate flex h-full min-w-0 flex-col",
clickable &&
"hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2",
selected && "ring-primary/30 bg-muted/30 ring-1",
className,
)
}
export function KpiStatCardTile({
item,
embedded = false,
className,
}: {
item: KpiStatItem
embedded?: boolean
className?: string
}) {
const onActivate = resolveActivate(item)
const panelClass = panelClassName(item, className)
let panel: ReactNode
if (item.href) {
panel = (
<FramePanel className={panelClass}>
<Link href={item.href} className="focus-visible:outline-none">
<KpiStatCardBody item={item} />
</Link>
</FramePanel>
)
} else if (onActivate) {
panel = (
<FramePanel
className={panelClass}
onClick={onActivate}
role="button"
tabIndex={0}
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
>
<KpiStatCardBody item={item} />
</FramePanel>
)
} else {
panel = (
<FramePanel className={panelClass}>
<KpiStatCardBody item={item} />
</FramePanel>
)
}
if (embedded) {
return <Frame className="h-full ring-1 ring-foreground/10">{panel}</Frame>
}
return <Frame className="h-full">{panel}</Frame>
}
function KpiStatGridSkeleton({ count }: { count: number }) {
return (
<Frame className="@container w-full">
<div className={cn("grid gap-2", kpiCols(count))}>
{Array.from({ length: count }).map((_, index) => (
<FramePanel key={index} className="flex items-start gap-3">
<Skeleton className="size-10.5 shrink-0 rounded-lg" />
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="flex items-center justify-between gap-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4.5 w-14 rounded-full" />
</div>
<Skeleton className="h-7 w-16" />
</div>
</FramePanel>
))}
</div>
</Frame>
)
}
interface KpiStatGridProps {
items?: KpiStatItem[]
cards?: KpiStatCardData[]
isLoading?: boolean
emptyMessage?: ReactNode
emptyIcon?: ReactNode
className?: string
gridClassName?: string
skeletonCount?: number
embedded?: boolean
"aria-label"?: string
}
function KpiStatCardItem({ item }: { item: KpiStatItem }) {
const onActivate = resolveActivate(item)
const panelClass = panelClassName(item)
if (item.href) {
return (
<FramePanel className={panelClass}>
<Link href={item.href} className="focus-visible:outline-none">
<KpiStatCardBody item={item} />
</Link>
</FramePanel>
)
}
if (onActivate) {
return (
<FramePanel
className={panelClass}
onClick={onActivate}
role="button"
tabIndex={0}
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
>
<KpiStatCardBody item={item} />
</FramePanel>
)
}
return (
<FramePanel className={panelClass}>
<KpiStatCardBody item={item} />
</FramePanel>
)
}
/**
* Hybrid KPI — horizontal compact layout (icon left).
* Preview: https://reui.io/preview/base/stats-12
*/
export function KpiStatGrid({
items,
cards,
isLoading = false,
emptyMessage,
emptyIcon,
className,
skeletonCount = 4,
gridClassName,
embedded = false,
"aria-label": ariaLabel,
}: KpiStatGridProps) {
if (isLoading) {
return <KpiStatGridSkeleton count={skeletonCount} />
}
const list = items ?? cards ?? []
if (list.length === 0 && (emptyMessage || emptyIcon)) {
return (
<Frame dense spacing="sm" className={cn("w-full", className)}>
<FramePanel className="flex items-center gap-3 p-4">
{emptyIcon}
{emptyMessage ? (
<p className="text-muted-foreground text-sm">{emptyMessage}</p>
) : null}
</FramePanel>
</Frame>
)
}
if (embedded) {
return (
<section aria-label={ariaLabel} className={cn("@container w-full", className)}>
<div className={cn("grid gap-2", kpiCols(list.length || 1), gridClassName)}>
{list.map((item, index) => (
<KpiStatCardTile key={kpiStatItemKey(item, index)} item={item} embedded />
))}
</div>
</section>
)
}
return (
<Frame className={cn("@container w-full min-w-0", className)} aria-label={ariaLabel}>
<div className={cn("grid gap-2", kpiCols(list.length || 1), gridClassName)}>
{list.map((item, index) => (
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
))}
</div>
</Frame>
)
}
export function kpiStatItemKey(item: KpiStatItem, index: number): string {
if (item.id) return item.id
if (typeof item.label === "string") return item.label
return `kpi-${index}`
}
+161
View File
@@ -0,0 +1,161 @@
"use client"
import type { KeyboardEvent, ReactNode } from "react"
import Link from "next/link"
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from "@/components/reui/frame"
import { Badge } from "@/components/reui/badge"
import { IconTile } from "@/components/reui/icon-tile"
import { cn } from "@/lib/utils"
import { kpiCols } from "./kpi-cols"
type QuickActionBase = {
id: string
title: string
description: string
icon?: ReactNode
iconClassName?: string
badge?: string
disabled?: boolean
}
export type QuickActionItem = QuickActionBase &
(
| { href: string; onClick?: never }
| { onClick: () => void; href?: never }
)
interface QuickActionGridProps {
actions: QuickActionItem[]
title?: string
description?: string
className?: string
}
const DEFAULT_ICON_CLASS = "text-muted-foreground [&_svg]:text-current"
function resolveBadge(action: QuickActionItem): string {
if (action.badge) return action.badge
return action.onClick ? "Выполнить" : "Перейти"
}
function handleActionKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault()
onActivate()
}
}
function QuickActionBody({ action }: { action: QuickActionItem }) {
return (
<div className="relative z-10 flex h-full items-start gap-3">
{action.icon ? (
<IconTile
variant="elevated"
aria-hidden="true"
className={cn("size-10.5", action.iconClassName ?? DEFAULT_ICON_CLASS)}
>
{action.icon}
</IconTile>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-start justify-between gap-2">
<span className="text-foreground text-sm font-medium">{action.title}</span>
<Badge variant="outline" size="sm" className="shrink-0">
{resolveBadge(action)}
</Badge>
</div>
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
{action.description}
</p>
</div>
</div>
)
}
function panelClassName(disabled?: boolean) {
return cn(
"relative isolate flex h-full flex-col transition-colors",
disabled
? "cursor-not-allowed opacity-60"
: "hover:bg-muted/40 focus-within:ring-ring cursor-pointer focus-within:ring-2",
)
}
/**
* KPI-like quick actions strip (horizontal Frame tiles).
* Preview: https://reui.io/preview/base/stats-12 · https://reui.io/preview/base/card-12
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/icon-tile
*/
export function QuickActionGrid({
actions,
title = "Быстрые действия",
description,
className,
}: QuickActionGridProps) {
if (actions.length === 0) return null
return (
<Frame dense spacing="sm" className={cn("@container w-full", className)}>
{(title || description) && (
<FrameHeader>
{title ? <FrameTitle>{title}</FrameTitle> : null}
{description ? <FrameDescription>{description}</FrameDescription> : null}
</FrameHeader>
)}
<div className={cn("grid gap-2", kpiCols(actions.length))}>
{actions.map((action) => {
const label = `${action.title}: ${action.description}`
if ("href" in action && action.href) {
return (
<FramePanel key={action.id} className={panelClassName(action.disabled)}>
{action.disabled ? (
<div aria-disabled aria-label={label}>
<QuickActionBody action={action} />
</div>
) : (
<>
<QuickActionBody action={action} />
<Link
href={action.href}
className="absolute inset-0 z-20 focus-visible:outline-none"
aria-label={label}
/>
</>
)}
</FramePanel>
)
}
const onClick = action.onClick
const onActivate = () => {
if (action.disabled || !onClick) return
onClick()
}
return (
<FramePanel
key={action.id}
className={panelClassName(action.disabled)}
role="button"
tabIndex={action.disabled ? -1 : 0}
aria-disabled={action.disabled || undefined}
aria-label={label}
onClick={onActivate}
onKeyDown={(e) => handleActionKeyDown(onActivate, e)}
>
<QuickActionBody action={action} />
</FramePanel>
)
})}
</div>
</Frame>
)
}
@@ -0,0 +1,37 @@
import { Fragment } from "react"
/** Compact RX/TX history shared by traffic cards. Gaps remain gaps. */
export function TrafficMiniChart({ rx, tx, height = 44 }: { rx: (number|null)[]; tx: (number|null)[]; height?: number }) {
const valid = (value: number|null): value is number => value !== null && Number.isFinite(value) && value >= 0
const values = [...rx, ...tx].filter(valid)
if (!values.length) return <p className="text-xs text-muted-foreground">Нет истории трафика</p>
const width = 300
const count = Math.max(rx.length, tx.length)
const max = Math.max(...values, 1) * 1.1
const segments = (series: (number|null)[]) => {
const result: Array<Array<{ x: number; y: number }>> = []
let current: Array<{ x: number; y: number }> = []
series.forEach((value, index) => {
if (!valid(value)) { current = []; return }
if (!current.length) result.push(current)
current.push({ x: count <= 1 ? width / 2 : index / (count - 1) * width, y: height - value / max * height })
})
return result
}
return (
<svg role="img" aria-label="Мини-график истории RX/TX" viewBox={`0 0 ${width} ${height}`} style={{ width: "100%", height, display: "block" }} preserveAspectRatio="none">
{([[rx, "var(--chart-rx)"], [tx, "var(--chart-tx)"]] as const).map(([series, color], direction) => (
<g key={color} data-direction={direction === 0 ? "rx" : "tx"}>
{segments(series).map((points, index) => {
if (points.length === 1) return <circle key={index} cx={points[0].x} cy={points[0].y} r="1.5" fill={color} />
const line = points.map(p => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ")
return <Fragment key={index}>
<path d={`M ${points[0].x},${height} L ${line} L ${points[points.length - 1].x},${height} Z`} fill={color} fillOpacity={0.12} />
<polyline points={line} fill="none" stroke={color} strokeWidth="1.4" strokeLinejoin="round" />
</Fragment>
})}
</g>
))}
</svg>
)
}
+152
View File
@@ -0,0 +1,152 @@
"use client"
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
import { Frame, FramePanel } from "@/components/reui/frame"
import { ChartContainer, ChartTooltip, type ChartConfig } from "@/components/ui/chart"
import { fmtRate, formatRangeAgo, TRAFFIC_RANGE_MINUTES } from "@/lib/fmt-rate"
/**
* История RX/TX — adapt ReUI PRO chart-23 (multi-line + tooltip).
* @see https://reui.io/preview/base/chart-23
* @see https://reui.io/blocks
*/
const chartConfig = {
rx: { label: "RX", color: "var(--chart-rx)" },
tx: { label: "TX", color: "var(--chart-tx)" },
} satisfies ChartConfig
function ChartLegendItem({ label, color }: { label: string; color: string }) {
return (
<div className="flex items-center gap-2">
<div className="size-2 rounded-full" style={{ backgroundColor: color }} />
<span className="text-muted-foreground text-xs font-medium">{label}</span>
</div>
)
}
function CustomTooltip({
active,
payload,
}: {
active?: boolean
payload?: {
dataKey: string
color: string
value: number
}[]
}) {
if (!active || !payload?.length) return null
return (
<div className="min-w-[120px] flex flex-col gap-1.5 rounded-lg bg-popover p-3 text-popover-foreground shadow-lg ring-1 ring-foreground/10">
{payload.map((item) => (
<div key={item.dataKey}>
<div className="text-[10px] font-medium tracking-wider uppercase opacity-70">
{item.dataKey === "rx" ? "RX" : "TX"}:
</div>
<div className="text-sm font-semibold tabular-nums">
{fmtRate(item.value)}
</div>
</div>
))}
</div>
)
}
function toChartData(rx: (number|null)[], tx: (number|null)[], rangeMinutes: number) {
const n = Math.max(rx.length, tx.length, 1)
const denom = Math.max(n - 1, 1)
return Array.from({ length: n }, (_, i) => {
const minutesAgo = Math.round((1 - i / denom) * rangeMinutes)
return {
time: formatRangeAgo(minutesAgo),
rx: typeof rx[i] === "number" && Number.isFinite(rx[i]) && rx[i] >= 0 ? rx[i] : null,
tx: typeof tx[i] === "number" && Number.isFinite(tx[i]) && tx[i] >= 0 ? tx[i] : null,
}
})
}
export function TrafficRxTxChart({
rx,
tx,
range = "1h",
embedded = false,
}: {
rx: (number|null)[]
tx: (number|null)[]
range?: string
/** Skip outer Frame when already inside OpsPanel / Frame. */
embedded?: boolean
}) {
const rangeMinutes = TRAFFIC_RANGE_MINUTES[range] ?? 60
const data = toChartData(rx, tx, rangeMinutes)
const tickEvery = Math.max(1, Math.ceil(data.length / 6))
const hasRx = data.some(point => point.rx !== null)
const hasTx = data.some(point => point.tx !== null)
const hasData = hasRx || hasTx
const isolatedDot = (direction: "rx" | "tx") => function IsolatedDot({ cx, cy, index }: { cx?: number; cy?: number; index?: number }) {
if (index == null || cx == null || cy == null || data[index]?.[direction] == null
|| data[index - 1]?.[direction] != null || data[index + 1]?.[direction] != null) return <g />
return <circle cx={cx} cy={cy} r={3} fill={`var(--chart-${direction})`} data-isolated-direction={direction} />
}
const chart = !hasData ? (
<p role="status" className="flex min-h-[220px] items-center justify-center text-sm text-muted-foreground">Нет исторических измерений за выбранный период</p>
) : (
<div role="region" aria-label="История RX/TX" className="flex flex-col gap-4">
{(!hasRx || !hasTx) && <p role="status" className="text-sm text-muted-foreground">{!hasRx ? "Нет измерений RX за выбранный период" : "Нет измерений TX за выбранный период"}</p>}
<ChartContainer config={chartConfig} className="-ms-4 aspect-auto h-[220px] w-full">
<LineChart data={data} margin={{ top: 5, right: 5, left: 5, bottom: 5 }}>
<CartesianGrid
strokeDasharray="4 8"
vertical={false}
stroke="var(--border)"
/>
<XAxis
dataKey="time"
axisLine={false}
tickLine={false}
tick={{ fontSize: 11 }}
tickMargin={10}
interval={tickEvery - 1}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{ fontSize: 11 }}
tickFormatter={(v: number) => fmtRate(Number(v))}
tickMargin={8}
width={72}
/>
<ChartTooltip content={<CustomTooltip />} />
{hasRx && <Line
dataKey="rx"
type="monotone"
stroke="var(--chart-rx)"
strokeWidth={2}
dot={isolatedDot("rx")}
/>}
{hasTx && <Line
dataKey="tx"
type="monotone"
stroke="var(--chart-tx)"
strokeWidth={2}
dot={isolatedDot("tx")}
/>}
</LineChart>
</ChartContainer>
<div className="mb-1 flex items-center justify-center gap-6">
<ChartLegendItem label="RX (входящий)" color="var(--chart-rx)" />
<ChartLegendItem label="TX (исходящий)" color="var(--chart-tx)" />
</div>
</div>
)
if (embedded) return chart
return (
<Frame className="w-full">
<FramePanel className="flex flex-col gap-6">{chart}</FramePanel>
</Frame>
)
}