Compare commits

..
3 Commits
Author SHA1 Message Date
Denozordec dfc33dcb99 refactor(web): streamline dashboard and tab components for improved layout
CI / changes (push) Successful in 8s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 40s
CI / web (push) Successful in 57s
CI / release (push) Successful in 4m24s
Refactored various dashboard components and tab structures to enhance layout and responsiveness. Adjusted class names to include 'min-w-0' for better overflow handling and improved alignment. Updated the ChartDonutMetric to include a tooltip for better data representation. Simplified the DashboardRecentJobsGrid and DashboardRecentRevisionsGrid by replacing DataGridSection with DataGridShell for consistency. Enhanced the OpsDashboard layout for better clarity and usability.
2026-08-18 16:03:52 +07:00
Denozordec 44f70443c3 refactor(web): improve layout and responsiveness of settings components
CI / changes (push) Successful in 8s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 53s
CI / web (push) Successful in 1m16s
CI / release (push) Successful in 4m24s
Enhanced various settings components by adding 'min-w-0' class for better overflow handling and adjusting flex properties for improved alignment. Updated card and grid structures to ensure a cohesive user experience across the settings interface. Refined descriptions and layout in several settings tabs for better clarity and usability.
2026-08-18 01:19:42 +07:00
DenozordecandCursor 357ed4ce7b refactor(web): enhance layout and styling for dashboard components
CI / changes (push) Successful in 12s
CI / commitlint (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / openapi (push) Successful in 1m18s
CI / web (push) Successful in 2m0s
CI / release (push) Successful in 6m51s
Updated various dashboard components to improve layout and responsiveness. Adjusted class names to include 'min-w-0' for better handling of overflow and added flex properties to ensure proper alignment. Refined grid structures and skeleton loading states for a more cohesive user experience.

Co-authored-by: Cursor <[email protected]>
2026-08-18 00:51:10 +07:00
31 changed files with 477 additions and 430 deletions
@@ -49,7 +49,8 @@ export function AccessApiKeysCard({
<> <>
<FrameDataGrid <FrameDataGrid
title="API-ключи" title="API-ключи"
description="Управление ключами tenant. Полный токен показывается только при создании и ротации." description="Полный токен показывается только при создании и ротации."
className="min-w-0"
actions={ actions={
<> <>
<Button size="sm" variant="outline" type="button" onClick={onRetry} disabled={isLoading}> <Button size="sm" variant="outline" type="button" onClick={onRetry} disabled={isLoading}>
@@ -11,6 +11,7 @@ import { ConfirmDialog } from '@/components/confirm-dialog'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { formatApiKeyDate } from '@/lib/access/api-key-labels' import { formatApiKeyDate } from '@/lib/access/api-key-labels'
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
import { useClientDataGrid } from '@/hooks/use-client-data-grid' import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { ApiKey } from '@/types/api' import type { ApiKey } from '@/types/api'
@@ -34,7 +35,14 @@ export function AccessApiKeysGrid({
{ {
accessorKey: 'name', accessorKey: 'name',
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
cell: ({ row }) => <DataGridPrimaryCell title={row.original.name} accent="primary" />, cell: ({ row }) => (
<DataGridPrimaryCell
title={row.original.name}
accent="primary"
subtitle={`${row.original.prefix}`}
/>
),
minSize: 160,
meta: { headerTitle: 'Имя' }, meta: { headerTitle: 'Имя' },
}, },
{ {
@@ -45,14 +53,6 @@ export function AccessApiKeysGrid({
), ),
meta: { headerTitle: 'Роль' }, meta: { headerTitle: 'Роль' },
}, },
{
accessorKey: 'prefix',
header: ({ column }) => <DataGridColumnHeader column={column} title="Префикс" />,
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground">{row.original.prefix}</span>
),
meta: { headerTitle: 'Префикс' },
},
{ {
id: 'status', id: 'status',
enableSorting: false, enableSorting: false,
@@ -77,11 +77,11 @@ export function AccessApiKeysGrid({
{ {
id: 'last_used_at', id: 'last_used_at',
accessorFn: (row) => row.last_used_at ?? '', accessorFn: (row) => row.last_used_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Последнее использование" />, header: ({ column }) => <DataGridColumnHeader column={column} title="Использован" />,
cell: ({ row }) => ( cell: ({ row }) => (
<DataGridMutedCell>{formatApiKeyDate(row.original.last_used_at)}</DataGridMutedCell> <DataGridMutedCell>{formatApiKeyDate(row.original.last_used_at)}</DataGridMutedCell>
), ),
meta: { headerTitle: 'Последнее использование' }, meta: { headerTitle: 'Использован' },
}, },
{ {
id: 'actions', id: 'actions',
@@ -90,7 +90,7 @@ export function AccessApiKeysGrid({
cell: ({ row }) => { cell: ({ row }) => {
const k = row.original const k = row.original
return ( return (
<div className="flex gap-1"> <div className="flex shrink-0 gap-1">
<ConfirmDialog <ConfirmDialog
trigger={ trigger={
<Button <Button
@@ -149,6 +149,7 @@ export function AccessApiKeysGrid({
recordCount={filteredCount} recordCount={filteredCount}
isLoading={isLoading} isLoading={isLoading}
emptyMessage="Нет ключей" emptyMessage="Нет ключей"
tableLayout={DATA_GRID_DENSE_LAYOUT}
searchValue={globalFilter} searchValue={globalFilter}
onSearchChange={setGlobalFilter} onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск API-ключей…" searchPlaceholder="Поиск API-ключей…"
@@ -2,6 +2,8 @@ import { Cell, Label, Pie, PieChart } from 'recharts'
import { import {
ChartContainer, ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig, type ChartConfig,
} from '@evobgp/ui/components/chart' } from '@evobgp/ui/components/chart'
import { cn } from '@evobgp/ui/lib/utils' import { cn } from '@evobgp/ui/lib/utils'
@@ -43,6 +45,7 @@ export function ChartDonutMetric({
<div className={cn('flex flex-col items-center justify-start gap-4 sm:flex-row sm:gap-6', className)}> <div className={cn('flex flex-col items-center justify-start gap-4 sm:flex-row sm:gap-6', className)}>
<ChartContainer config={chartConfig} className="mx-0 aspect-square h-44 w-44 shrink-0"> <ChartContainer config={chartConfig} className="mx-0 aspect-square h-44 w-44 shrink-0">
<PieChart> <PieChart>
<ChartTooltip content={<ChartTooltipContent nameKey="label" hideLabel />} />
<Pie <Pie
data={data} data={data}
dataKey="count" dataKey="count"
+6 -3
View File
@@ -41,14 +41,17 @@ export function BadgeTabs({
defaultValue={defaultValue} defaultValue={defaultValue}
onValueChange={onValueChange} onValueChange={onValueChange}
orientation="horizontal" orientation="horizontal"
className={cn('w-full', className)} className={cn('w-full min-w-0', className)}
> >
<TabsList <TabsList
variant="line" variant="line"
className={cn('mb-3.5 w-full justify-start gap-6', listClassName)} className={cn(
'mb-3.5 w-full min-w-0 justify-start gap-4',
listClassName,
)}
> >
{items.map((item) => ( {items.map((item) => (
<TabsTrigger key={item.value} value={item.value} className="gap-2"> <TabsTrigger key={item.value} value={item.value} className="shrink-0 gap-2">
{item.icon} {item.icon}
{item.label} {item.label}
{item.count !== undefined ? ( {item.count !== undefined ? (
@@ -50,7 +50,7 @@ export function DashboardActivityTimeline({
<DashboardFramePanel <DashboardFramePanel
title="Недавняя активность" title="Недавняя активность"
description="Задачи, ревизии и сетевые события" description="Задачи, ревизии и сетевые события"
className="h-full" className="h-full min-w-0"
> >
{loading ? ( {loading ? (
<p className="text-muted-foreground px-4 py-6 text-sm">Загрузка</p> <p className="text-muted-foreground px-4 py-6 text-sm">Загрузка</p>
@@ -82,7 +82,7 @@ export function DashboardActivityTimeline({
</TimelineIndicator> </TimelineIndicator>
</TimelineHeader> </TimelineHeader>
<TimelineContent className="min-w-0 pb-1 text-foreground"> <TimelineContent className="min-w-0 pb-1 text-foreground">
<TimelineTitle className="text-sm leading-snug font-normal"> <TimelineTitle className="text-sm leading-snug font-normal break-words">
{item.message} {item.message}
</TimelineTitle> </TimelineTitle>
<div className="mt-2"> <div className="mt-2">
@@ -119,9 +119,10 @@ export function DashboardModulesGrid({
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<BoxesIcon className="text-muted-foreground size-4 shrink-0" aria-hidden /> <BoxesIcon className="text-muted-foreground size-4 shrink-0" aria-hidden />
<DataGridPrimaryCell title={row.original.name} accent="primary" className="max-w-[240px]" /> <DataGridPrimaryCell title={row.original.name} accent="primary" />
</div> </div>
), ),
minSize: 180,
meta: { headerTitle: 'Модуль' }, meta: { headerTitle: 'Модуль' },
}, },
{ {
@@ -164,6 +165,7 @@ export function DashboardModulesGrid({
<DashboardFramePanel <DashboardFramePanel
title="Модули" title="Модули"
description="Поиск, сортировка и быстрый переход к настройке" description="Поиск, сортировка и быстрый переход к настройке"
className="min-w-0"
actions={ actions={
<Button variant="outline" size="sm" render={<Link to="/modules/new" />}> <Button variant="outline" size="sm" render={<Link to="/modules/new" />}>
<PlusIcon /> <PlusIcon />
@@ -183,14 +185,14 @@ export function DashboardModulesGrid({
columnsVisibility: false, columnsVisibility: false,
columnsResizable: false, columnsResizable: false,
columnsMovable: false, columnsMovable: false,
width: 'fixed', width: 'auto',
}} }}
tableClassNames={{ bodyRow: 'group/module-row cursor-pointer [&>td]:h-14' }} tableClassNames={{ bodyRow: 'group/module-row cursor-pointer [&>td]:h-14' }}
onRowClick={(row) => void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })} onRowClick={(row) => void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })}
> >
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex flex-col gap-3 border-b px-4 py-3 lg:flex-row lg:items-center lg:justify-between"> <div className="flex flex-col gap-3 border-b px-4 py-3 @3xl:flex-row @3xl:items-center @3xl:justify-between">
<InputGroup className="w-full min-w-40 sm:max-w-xs"> <InputGroup className="w-full min-w-0 @3xl:max-w-xs">
<InputGroupAddon align="inline-start"> <InputGroupAddon align="inline-start">
<SearchIcon className="text-muted-foreground size-4" aria-hidden /> <SearchIcon className="text-muted-foreground size-4" aria-hidden />
</InputGroupAddon> </InputGroupAddon>
@@ -3,13 +3,16 @@ import { useMemo, useState } from 'react'
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric' import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control' import { AnalyticsSegmentControl } from '@/components/analytics/analytics-segment-control'
import { PanelCard } from '@/components/panel-card' import { PanelCard } from '@/components/panel-card'
import { Separator } from '@evobgp/ui/components/separator'
import { jobStatusBreakdown, moduleTypeBreakdown } from '@/lib/metrics' import { jobStatusBreakdown, moduleTypeBreakdown } from '@/lib/metrics'
import type { JobRow, ModuleRow } from '@/types/api' import type { JobRow, ModuleRow } from '@/types/api'
type FlowMode = 'jobs' | 'modules' type FlowMode = 'jobs' | 'modules'
/** chart-13 inspired donut breakdown (Card surface). */ /**
* Donut distribution — chart-27 DNA: Frame + one legend (inside ChartDonutMetric).
* @see https://reui.io/preview/base/chart-27
* @see https://reui.io/docs/components/base/frame
*/
export function DashboardOperationsBreakdown({ export function DashboardOperationsBreakdown({
jobs, jobs,
modules, modules,
@@ -32,7 +35,7 @@ export function DashboardOperationsBreakdown({
return ( return (
<PanelCard <PanelCard
title="Поток операций" title="Поток операций"
description="Распределение задач и типов модулей" description="Доли статусов задач или типов модулей"
actions={ actions={
<AnalyticsSegmentControl <AnalyticsSegmentControl
value={mode} value={mode}
@@ -43,45 +46,16 @@ export function DashboardOperationsBreakdown({
]} ]}
/> />
} }
className="h-full" className="min-w-0"
contentClassName="px-5 py-4"
> >
<div className="p-4"> {loading ? (
{loading ? ( <div className="text-muted-foreground flex h-44 items-center justify-center text-sm">
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm"> Загрузка
Загрузка </div>
</div> ) : (
) : ( <ChartDonutMetric slices={slices} centerLabel={centerLabel} centerValue={total} />
<> )}
<ChartDonutMetric slices={slices} centerLabel={centerLabel} centerValue={total} />
{slices.length > 0 ? (
<ul className="mt-4 flex min-w-0 flex-col">
{slices.map((slice, index) => {
const pct = total > 0 ? ((slice.count / total) * 100).toFixed(1) : '0'
return (
<li key={slice.key}>
<div className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-3 py-2">
<div className="flex min-w-0 items-center gap-2.5">
<span
aria-hidden
className="border-background size-3 shrink-0 rounded-full border-2 shadow-sm"
style={{ backgroundColor: slice.color }}
/>
<span className="truncate text-sm font-medium">{slice.label}</span>
</div>
<span className="text-sm font-medium tabular-nums">{slice.count}</span>
<span className="text-muted-foreground/70 w-10 text-right text-xs tabular-nums">
{pct}%
</span>
</div>
{index < slices.length - 1 ? <Separator className="w-auto" /> : null}
</li>
)
})}
</ul>
) : null}
</>
)}
</div>
</PanelCard> </PanelCard>
) )
} }
@@ -2,7 +2,7 @@ import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react' import { useMemo } from 'react'
import { DataGridPrimaryCell } from '@/components/data-grid-cell' import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell' import { DataGridShell } from '@/components/data-grid-shell'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults' import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
@@ -49,7 +49,7 @@ export function DashboardRecentJobsGrid({
[nameById], [nameById],
) )
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({ const { table, filteredCount } = useClientDataGrid({
data, data,
columns, columns,
getSearchText: (row) => { getSearchText: (row) => {
@@ -63,16 +63,13 @@ export function DashboardRecentJobsGrid({
}) })
return ( return (
<DataGridSection <DataGridShell
table={table} table={table}
recordCount={filteredCount} recordCount={filteredCount}
isLoading={isLoading} isLoading={isLoading}
emptyMessage="Нет задач" emptyMessage="Нет задач"
showPagination={false} showPagination={false}
tableLayout={DATA_GRID_DENSE_LAYOUT} tableLayout={DATA_GRID_DENSE_LAYOUT}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск задач…"
/> />
) )
} }
@@ -2,7 +2,7 @@ import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react' import { useMemo } from 'react'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell' import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell' import { DataGridShell } from '@/components/data-grid-shell'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults' import { DATA_GRID_DENSE_LAYOUT } from '@/lib/data-grid-defaults'
import { useClientDataGrid } from '@/hooks/use-client-data-grid' import { useClientDataGrid } from '@/hooks/use-client-data-grid'
@@ -42,7 +42,7 @@ export function DashboardRecentRevisionsGrid({
[], [],
) )
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({ const { table, filteredCount } = useClientDataGrid({
data, data,
columns, columns,
getSearchText: (row) => row.id, getSearchText: (row) => row.id,
@@ -51,16 +51,13 @@ export function DashboardRecentRevisionsGrid({
}) })
return ( return (
<DataGridSection <DataGridShell
table={table} table={table}
recordCount={filteredCount} recordCount={filteredCount}
isLoading={isLoading} isLoading={isLoading}
emptyMessage="Нет ревизий" emptyMessage="Нет ревизий"
showPagination={false} showPagination={false}
tableLayout={DATA_GRID_DENSE_LAYOUT} tableLayout={DATA_GRID_DENSE_LAYOUT}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск ревизий…"
/> />
) )
} }
+1 -1
View File
@@ -50,7 +50,7 @@ export function DataGridShell<TData extends object>({
loadingMessage={DATA_GRID_MESSAGES_RU.loadingMessage} loadingMessage={DATA_GRID_MESSAGES_RU.loadingMessage}
tableLayout={tableLayout} tableLayout={tableLayout}
tableClassNames={DATA_GRID_TABLE_CLASS_NAMES} tableClassNames={DATA_GRID_TABLE_CLASS_NAMES}
className={className} className={cn('min-w-0', className)}
onRowClick={onRowClick} onRowClick={onRowClick}
> >
<DataGridContainer border={false}> <DataGridContainer border={false}>
+8 -4
View File
@@ -8,12 +8,16 @@ interface PageHeaderProps {
export function PageHeader({ title, description, actions }: PageHeaderProps) { export function PageHeader({ title, description, actions }: PageHeaderProps) {
return ( return (
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between"> <div className="flex min-w-0 flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div className="flex flex-col gap-1"> <div className="flex min-w-0 flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1> <h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null} {description ? (
<p className="text-muted-foreground max-w-prose text-sm break-words">{description}</p>
) : null}
</div> </div>
{actions ? <div className="flex items-center gap-2">{actions}</div> : null} {actions ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div>
) : null}
</div> </div>
) )
} }
+1 -1
View File
@@ -54,7 +54,7 @@ export function PanelCard({
<Frame <Frame
dense dense
spacing={spacing} spacing={spacing}
className={cn(panelCardClassName, className)} className={cn(panelCardClassName, 'min-w-0', className)}
> >
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs"> <FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
{hasHeader ? ( {hasHeader ? (
@@ -29,10 +29,10 @@ export function FrameDataGrid({
}) { }) {
const hasHeader = Boolean(title || description || actions) const hasHeader = Boolean(title || description || actions)
return ( return (
<Frame dense spacing="sm" className={cn('w-full gap-0 p-0', className)}> <Frame dense spacing="sm" className={cn('w-full min-w-0 gap-0 p-0', className)}>
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs"> <FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
{hasHeader ? ( {hasHeader ? (
<FrameHeader className="flex-row items-start justify-between gap-3 border-b"> <FrameHeader className="flex-col items-stretch gap-3 border-b sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 flex-1 flex-col gap-px"> <div className="flex min-w-0 flex-1 flex-col gap-px">
{title ? <FrameTitle>{title}</FrameTitle> : null} {title ? <FrameTitle>{title}</FrameTitle> : null}
{description ? ( {description ? (
+7 -4
View File
@@ -1,10 +1,13 @@
/** Shared grid column classes for hybrid KPI / Quick Actions tiles. */ /**
* Shared grid column classes for hybrid KPI / Quick Actions tiles.
* Container queries only — never 56 columns on laptop width (tiles overlap).
* @see https://reui.io/preview/base/stats-12
*/
export function kpiCols(count: number): string { export function kpiCols(count: number): string {
if (count <= 1) return 'grid-cols-1' if (count <= 1) return 'grid-cols-1'
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2' if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3' 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 === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5' if (count <= 6) return 'grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3'
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6' return 'grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3 @6xl:grid-cols-4'
return 'grid-cols-1 sm:grid-cols-2 xl:grid-cols-4'
} }
@@ -305,7 +305,7 @@ export function KpiStatGrid({
} }
return ( return (
<Frame className={cn('@container w-full', className)} aria-label={ariaLabel}> <Frame className={cn('@container w-full min-w-0', className)} aria-label={ariaLabel}>
<div className={cn('grid gap-2', kpiCols(list.length || 1))}> <div className={cn('grid gap-2', kpiCols(list.length || 1))}>
{list.map((item, index) => ( {list.map((item, index) => (
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} /> <KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
@@ -1,11 +1,4 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Skeleton } from '@evobgp/ui/components/skeleton' import { Skeleton } from '@evobgp/ui/components/skeleton'
import { PageHeader } from '@/components/page-header' import { PageHeader } from '@/components/page-header'
import { KpiStatGrid, type KpiStatCardData } from './kpi-stat-grid' import { KpiStatGrid, type KpiStatCardData } from './kpi-stat-grid'
@@ -27,7 +20,7 @@ interface OpsDashboardProps {
} }
const rootClassName = const rootClassName =
'text-foreground @container flex w-full flex-col gap-2 md:gap-3' 'text-foreground @container flex w-full min-w-0 flex-col gap-4 md:gap-6'
function OpsDashboardSkeleton() { function OpsDashboardSkeleton() {
return ( return (
@@ -37,7 +30,7 @@ function OpsDashboardSkeleton() {
<Skeleton className="mt-2 h-4 w-80 max-w-full" /> <Skeleton className="mt-2 h-4 w-80 max-w-full" />
</header> </header>
<KpiStatGrid cards={[]} isLoading skeletonCount={4} /> <KpiStatGrid cards={[]} isLoading skeletonCount={4} />
<div className="grid gap-2 @3xl:grid-cols-2"> <div className="flex min-w-0 flex-col gap-4">
<Skeleton className="h-64 w-full rounded-xl" /> <Skeleton className="h-64 w-full rounded-xl" />
<Skeleton className="h-64 w-full rounded-xl" /> <Skeleton className="h-64 w-full rounded-xl" />
</div> </div>
@@ -47,9 +40,11 @@ function OpsDashboardSkeleton() {
} }
/** /**
* Ops dashboard: KPI → optional Quick Actions → charts → attention queue. * Ops dashboard: KPI → optional Quick Actions → charts → queue.
* Queue is a sibling Frame grid — never wrap FrameDataGrid inside another Frame.
* @see https://reui.io/preview/base/dashboard-1 * @see https://reui.io/preview/base/dashboard-1
* @see https://reui.io/preview/base/stats-12 * @see https://reui.io/preview/base/stats-12
* @see https://reui.io/docs/components/base/frame
*/ */
export function OpsDashboard({ export function OpsDashboard({
title = 'Панель управления', title = 'Панель управления',
@@ -77,21 +72,18 @@ export function OpsDashboard({
{afterKpi ? <section aria-label="Быстрые действия">{afterKpi}</section> : null} {afterKpi ? <section aria-label="Быстрые действия">{afterKpi}</section> : null}
<section <section aria-label="Аналитика" className="flex min-w-0 flex-col gap-4">
aria-label="Аналитика"
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
>
{charts} {charts}
</section> </section>
<section aria-label={queueTitle}> <section aria-label={queueTitle} className="flex min-w-0 flex-col gap-4">
<Frame dense spacing="sm" className="w-full"> <div className="flex min-w-0 flex-col gap-1">
<FrameHeader> <h2 className="text-sm font-semibold tracking-tight">{queueTitle}</h2>
<FrameTitle>{queueTitle}</FrameTitle> {queueDescription ? (
<FrameDescription>{queueDescription}</FrameDescription> <p className="text-muted-foreground max-w-prose text-sm">{queueDescription}</p>
</FrameHeader> ) : null}
<FramePanel>{queue}</FramePanel> </div>
</Frame> {queue}
</section> </section>
</div> </div>
) )
@@ -2,7 +2,6 @@ import type { ReactNode } from 'react'
import { Link, Outlet, useRouterState } from '@tanstack/react-router' import { Link, Outlet, useRouterState } from '@tanstack/react-router'
import { ServerCogIcon, SettingsIcon } from 'lucide-react' import { ServerCogIcon, SettingsIcon } from 'lucide-react'
import { useIsMobile } from '@evobgp/ui/hooks/use-mobile'
import { cn } from '@evobgp/ui/lib/utils' import { cn } from '@evobgp/ui/lib/utils'
import { PageShell } from '@/components/page-shell' import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header' import { PageHeader } from '@/components/page-header'
@@ -37,7 +36,8 @@ interface SettingsShellProps {
} }
/** /**
* Settings shell — Frame surface, no hairline Separator under header. * Settings shell — Frame surface, settings-16 left rail.
* Rail stacks above content until the page container is wide enough (no viewport-only squeeze).
* @see https://reui.io/preview/base/settings-16 * @see https://reui.io/preview/base/settings-16
* @see https://reui.io/preview/base/settings-3 * @see https://reui.io/preview/base/settings-3
*/ */
@@ -46,7 +46,6 @@ export function SettingsShell({
description, description,
tabs = DEFAULT_TABS, tabs = DEFAULT_TABS,
}: SettingsShellProps) { }: SettingsShellProps) {
const isMobile = useIsMobile()
const pathname = useRouterState({ select: (s) => s.location.pathname }) const pathname = useRouterState({ select: (s) => s.location.pathname })
const headerDescription = const headerDescription =
description ?? description ??
@@ -56,31 +55,20 @@ export function SettingsShell({
return ( return (
<PageShell> <PageShell>
<div className="mx-auto flex w-full max-w-4xl flex-col gap-5"> <div className="@container mx-auto flex w-full min-w-0 max-w-5xl flex-col gap-5">
<PageHeader title={title} description={headerDescription} /> <PageHeader title={title} description={headerDescription} />
<div <div className="flex min-w-0 flex-col gap-5 @3xl:flex-row @3xl:items-start">
className={cn(
'flex gap-5',
isMobile ? 'flex-col' : 'flex-row items-start',
)}
>
{tabs.length > 1 ? ( {tabs.length > 1 ? (
<nav <nav
aria-label="Разделы настроек" aria-label="Разделы настроек"
className={cn( className="scrollbar-none flex min-w-0 gap-1 overflow-x-auto @3xl:w-44 @3xl:shrink-0 @3xl:flex-col @3xl:overflow-visible"
'flex gap-1',
isMobile
? 'scrollbar-none -mx-1 overflow-x-auto overflow-y-hidden pb-1'
: 'w-44 shrink-0 flex-col',
)}
> >
{tabs.map((tab) => { {tabs.map((tab) => {
const isActive = pathname.startsWith(tab.to) const isActive = pathname.startsWith(tab.to)
const className = cn( const className = cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors', 'flex shrink-0 items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors',
isMobile && 'shrink-0', '@3xl:w-full',
!isMobile && 'w-full',
isActive isActive
? 'bg-muted text-foreground font-medium shadow-sm ring-1 ring-border/60' ? 'bg-muted text-foreground font-medium shadow-sm ring-1 ring-border/60'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground', : 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
@@ -44,7 +44,7 @@ export function AppearanceSettingsTab() {
}) })
return ( return (
<div className="space-y-6"> <div className="flex flex-col gap-6">
<SettingsCard <SettingsCard
title="Тема интерфейса" title="Тема интерфейса"
description="Быстрый переключатель также доступен в боковой панели" description="Быстрый переключатель также доступен в боковой панели"
@@ -61,6 +61,7 @@ export function AppearanceSettingsTab() {
мгновенно мгновенно
</Badge> </Badge>
} }
stacked
last last
> >
<ToggleGroup <ToggleGroup
@@ -72,7 +73,7 @@ export function AppearanceSettingsTab() {
variant="outline" variant="outline"
size="sm" size="sm"
aria-label="Тема интерфейса" aria-label="Тема интерфейса"
className="w-full sm:w-auto" className="w-full min-w-0"
> >
{THEME_OPTIONS.map((option) => { {THEME_OPTIONS.map((option) => {
const Icon = option.icon const Icon = option.icon
@@ -1,6 +1,6 @@
import { Link, useNavigate } from '@tanstack/react-router' import { Link, useNavigate } from '@tanstack/react-router'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { Save } from 'lucide-react' import { KeyRound, Save } from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
@@ -8,6 +8,7 @@ import { SettingRow } from '@/components/settings/setting-row'
import { SettingsCard } from '@/components/settings/settings-card' import { SettingsCard } from '@/components/settings/settings-card'
import { SettingsFieldGroup } from '@/components/settings/settings-field-group' import { SettingsFieldGroup } from '@/components/settings/settings-field-group'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { LoadingButton } from '@/components/loading-button' import { LoadingButton } from '@/components/loading-button'
import { import {
DEV_API_TOKEN, DEV_API_TOKEN,
@@ -16,7 +17,6 @@ import {
TOKEN_STORAGE_KEY, TOKEN_STORAGE_KEY,
} from '@/lib/api-client' } from '@/lib/api-client'
import { authKeys } from '@/queries/auth' import { authKeys } from '@/queries/auth'
import { Alert, AlertDescription } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { Input } from '@evobgp/ui/components/input' import { Input } from '@evobgp/ui/components/input'
@@ -50,12 +50,13 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
} }
return ( return (
<div className="space-y-6"> <div className="flex flex-col gap-6">
{tokenRequired ? ( {tokenRequired ? (
<Alert> <Alert variant="warning">
<KeyRound aria-hidden />
<AlertTitle>Нужен API-токен</AlertTitle>
<AlertDescription> <AlertDescription>
Для доступа к разделу нужен API-токен. Сохраните токен ниже или нажмите «Использовать Сохраните токен ниже или нажмите «Использовать dev».
dev».
</AlertDescription> </AlertDescription>
</Alert> </Alert>
) : null} ) : null}
@@ -64,7 +65,7 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
title="Подключение к API" title="Подключение к API"
description="Токен хранится только в этом браузере (localStorage)" description="Токен хранится только в этом браузере (localStorage)"
footer={ footer={
<div className="flex w-full flex-wrap justify-end gap-2"> <div className="flex w-full min-w-0 flex-wrap justify-end gap-2">
<Button type="button" variant="outline" onClick={useDevToken}> <Button type="button" variant="outline" onClick={useDevToken}>
Использовать dev Использовать dev
</Button> </Button>
@@ -83,8 +84,7 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
title="Токен для запросов" title="Токен для запросов"
description={ description={
<> <>
Ключ для заголовка <code className="text-xs">Authorization</code>. Управление Ключ для заголовка Authorization. Управление ключами tenant в разделе{' '}
ключами tenant в разделе{' '}
<Link to="/access" className="text-primary underline-offset-4 hover:underline"> <Link to="/access" className="text-primary underline-offset-4 hover:underline">
Права доступа Права доступа
</Link> </Link>
@@ -97,12 +97,14 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
</Badge> </Badge>
} }
labelFor="settings-token" labelFor="settings-token"
stacked
last last
> >
<Input <Input
id="settings-token" id="settings-token"
type="password" type="password"
autoComplete="off" autoComplete="off"
className="w-full min-w-0"
value={token} value={token}
onChange={(e) => setTokenValue(e.target.value)} onChange={(e) => setTokenValue(e.target.value)}
placeholder="dev или API-ключ" placeholder="dev или API-ключ"
@@ -49,7 +49,7 @@ const ADMIN_SECTIONS = [
export function SectionsSettingsTab() { export function SectionsSettingsTab() {
return ( return (
<div className="space-y-6"> <div className="flex flex-col gap-6">
<SettingsCard <SettingsCard
title="Разделы control plane" title="Разделы control plane"
description="Параметры tenant и операции — отдельно от настроек браузера" description="Параметры tenant и операции — отдельно от настроек браузера"
@@ -58,7 +58,7 @@ export function SectionsSettingsTab() {
{ADMIN_SECTIONS.map((section, index) => ( {ADMIN_SECTIONS.map((section, index) => (
<Fragment key={section.id}> <Fragment key={section.id}>
{index > 0 ? <ItemSeparator className="my-0" /> : null} {index > 0 ? <ItemSeparator className="my-0" /> : null}
<Item className="min-h-0 items-center gap-4 px-5 py-3.5"> <Item className="min-h-0 min-w-0 flex-wrap items-center gap-4 px-5 py-3.5">
<ItemMedia className="self-center!"> <ItemMedia className="self-center!">
<Item className="bg-muted/60 border-background flex size-8 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] [&_svg]:size-4 [&_svg]:opacity-60"> <Item className="bg-muted/60 border-background flex size-8 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] [&_svg]:size-4 [&_svg]:opacity-60">
{section.icon} {section.icon}
@@ -66,7 +66,7 @@ export function SectionsSettingsTab() {
</ItemMedia> </ItemMedia>
<ItemContent className="min-w-0 justify-center gap-0.5 self-center"> <ItemContent className="min-w-0 justify-center gap-0.5 self-center">
<ItemTitle className="gap-2 leading-5"> <ItemTitle className="flex min-w-0 flex-wrap items-center gap-2 leading-5">
{section.title} {section.title}
<Badge variant={section.badge.variant} size="xs"> <Badge variant={section.badge.variant} size="xs">
{section.badge.label} {section.badge.label}
@@ -35,7 +35,7 @@ export function SessionSettingsTab() {
}) })
return ( return (
<div className="space-y-6"> <div className="flex flex-col gap-6">
<SettingsCard title="Текущая сессия" description="Проверка токена через GET /v1/auth/session"> <SettingsCard title="Текущая сессия" description="Проверка токена через GET /v1/auth/session">
<QueryState <QueryState
data={sessionQ.data} data={sessionQ.data}
@@ -58,7 +58,7 @@ export function SessionSettingsTab() {
</ItemMedia> </ItemMedia>
<ItemContent className="min-w-0 justify-center gap-0.5 self-center"> <ItemContent className="min-w-0 justify-center gap-0.5 self-center">
<ItemTitle className="gap-2 leading-5"> <ItemTitle className="flex min-w-0 flex-wrap items-center gap-2 leading-5">
Этот браузер Этот браузер
<Badge variant="success-light" size="xs"> <Badge variant="success-light" size="xs">
Активна Активна
@@ -66,7 +66,9 @@ export function SessionSettingsTab() {
</ItemTitle> </ItemTitle>
<ItemDescription className="leading-5"> <ItemDescription className="leading-5">
Tenant:{' '} Tenant:{' '}
<code className="text-foreground font-mono text-xs">{session.tenant_id}</code> <code className="text-foreground font-mono text-xs break-all">
{session.tenant_id}
</code>
</ItemDescription> </ItemDescription>
</ItemContent> </ItemContent>
</Item> </Item>
@@ -94,12 +96,13 @@ export function SessionSettingsTab() {
</Badge> </Badge>
) )
} }
stacked
last last
> >
{sessionQ.data ? ( {sessionQ.data ? (
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm break-words">
Ключ с ролью <strong>{sessionQ.data.role}</strong> в tenant{' '} Ключ с ролью <strong>{sessionQ.data.role}</strong> в tenant{' '}
<code className="font-mono text-xs">{sessionQ.data.tenant_id}</code>. <code className="font-mono text-xs break-all">{sessionQ.data.tenant_id}</code>.
</p> </p>
) : ( ) : (
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
@@ -10,31 +10,59 @@ import {
FieldTitle, FieldTitle,
} from '@evobgp/ui/components/field' } from '@evobgp/ui/components/field'
interface SettingRowProps { export interface SettingRowProps {
title: string title: string
description?: ReactNode description?: ReactNode
children: ReactNode children: ReactNode
last?: boolean last?: boolean
/** Opt-in FieldSeparator after the row (default off — ReUI settings use Frame+gap). */
separated?: boolean
compact?: boolean
stacked?: boolean
labelFor?: string labelFor?: string
contentClassName?: string contentClassName?: string
className?: string
titleAddon?: ReactNode titleAddon?: ReactNode
} }
/** Settings row — settings-3 DNA. Preview: https://reui.io/preview/base/settings-3 */ /**
* Settings row — settings-3 / settings-16 DNA.
* Control is flex-1 + min-w-0 (never a fixed w-78) so label and input do not overlap.
* @see https://reui.io/preview/base/settings-3
* @see https://reui.io/preview/base/settings-16
*/
export function SettingRow({ export function SettingRow({
title, title,
description, description,
children, children,
last, last,
separated = false,
compact,
stacked,
labelFor, labelFor,
contentClassName, contentClassName,
className,
titleAddon, titleAddon,
}: SettingRowProps) { }: SettingRowProps) {
return ( return (
<> <>
<Field orientation="responsive" className="gap-4 px-5 py-3"> <Field
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm"> orientation={stacked ? 'vertical' : 'responsive'}
<div className="flex flex-wrap items-center gap-2"> className={cn(
'min-w-0 gap-4 px-5 py-3.5',
!stacked &&
'@md/field-group:items-start @md/field-group:*:min-w-0 @md/field-group:*:max-w-full',
!last && !separated && 'border-border/40 border-b',
className,
)}
>
<div
className={cn(
'flex min-w-0 flex-1 flex-col gap-0.5',
!stacked && '@md/field-group:max-w-sm',
)}
>
<div className="flex min-w-0 flex-wrap items-center gap-2">
{labelFor ? ( {labelFor ? (
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel> <FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
) : ( ) : (
@@ -44,20 +72,33 @@ export function SettingRow({
</div> </div>
{description ? ( {description ? (
<FieldDescription>{description}</FieldDescription> <FieldDescription className="text-sm break-words">{description}</FieldDescription>
) : null} ) : null}
</div> </div>
<FieldContent <FieldContent
className={cn('min-w-0 @md/field-group:w-78', contentClassName)} className={cn(
'w-full min-w-0 @md/field-group:flex-1',
stacked
? 'max-w-none'
: compact
? '@md/field-group:max-w-[17rem] @md/field-group:shrink-0'
: '@md/field-group:max-w-[min(100%,34rem)]',
contentClassName,
)}
> >
<div className="flex w-full justify-start @md/field-group:justify-end"> <div
className={cn(
'flex w-full min-w-0 justify-start',
stacked ? 'justify-start' : '@md/field-group:justify-end',
)}
>
{children} {children}
</div> </div>
</FieldContent> </FieldContent>
</Field> </Field>
{!last ? <FieldSeparator /> : null} {separated && !last ? <FieldSeparator /> : null}
</> </>
) )
} }
@@ -31,7 +31,7 @@ export function SettingsCard({
footerClassName, footerClassName,
}: SettingsCardProps) { }: SettingsCardProps) {
return ( return (
<Frame dense spacing="sm" className={cn('w-full gap-0 p-0', className)}> <Frame dense spacing="sm" className={cn('w-full min-w-0 gap-0 p-0', className)}>
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs"> <FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
<FrameHeader className="gap-0 border-b px-5 py-3"> <FrameHeader className="gap-0 border-b px-5 py-3">
<FrameTitle>{title}</FrameTitle> <FrameTitle>{title}</FrameTitle>
@@ -1,17 +1,9 @@
import { type ComponentProps, type ReactNode } from 'react' import { type ComponentProps, type ReactNode } from 'react'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { cn } from '@evobgp/ui/lib/utils' import { SettingRow } from '@/components/settings/setting-row'
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
FieldSeparator,
FieldTitle,
} from '@evobgp/ui/components/field'
/** settings-3 row pattern for tenant/UI settings. */ /** Tenant settings row — same SettingRow DNA as UI settings. */
export function SettingsSettingField({ export function SettingsSettingField({
title, title,
description, description,
@@ -20,38 +12,34 @@ export function SettingsSettingField({
last, last,
labelFor, labelFor,
contentClassName, contentClassName,
stacked,
}: { }: {
title: string title: string
description: ReactNode description?: ReactNode
badge?: { label: string; variant: ComponentProps<typeof Badge>['variant'] } badge?: { label: string; variant: ComponentProps<typeof Badge>['variant'] }
children: ReactNode children: ReactNode
last?: boolean last?: boolean
labelFor?: string labelFor?: string
contentClassName?: string contentClassName?: string
stacked?: boolean
}) { }) {
return ( return (
<> <SettingRow
<Field orientation="responsive" className="gap-4 px-4 py-4"> title={title}
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm"> description={description}
<div className="flex flex-wrap items-center gap-2"> last={last}
{labelFor ? ( labelFor={labelFor}
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel> contentClassName={contentClassName}
) : ( stacked={stacked}
<FieldTitle>{title}</FieldTitle> titleAddon={
)} badge ? (
{badge ? ( <Badge variant={badge.variant} size="sm">
<Badge variant={badge.variant} size="sm"> {badge.label}
{badge.label} </Badge>
</Badge> ) : null
) : null} }
</div> >
<FieldDescription className="text-xs">{description}</FieldDescription> {children}
</div> </SettingRow>
<FieldContent className={cn('min-w-0 @md/field-group:w-78', contentClassName)}>
{children}
</FieldContent>
</Field>
{!last ? <FieldSeparator /> : null}
</>
) )
} }
+3 -3
View File
@@ -39,11 +39,11 @@ export function AnalyticsDashboardSkeleton() {
</div> </div>
<div className={dashboardMainSidebarClassName}> <div className={dashboardMainSidebarClassName}>
<Skeleton className="h-96 w-full rounded-xl xl:col-span-8" /> <Skeleton className="h-96 w-full min-w-0 rounded-xl" />
<Skeleton className="h-96 w-full rounded-xl xl:col-span-4" /> <Skeleton className="h-96 w-full min-w-0 rounded-xl" />
</div> </div>
<div className="grid gap-4 lg:grid-cols-2"> <div className={chartPanelGridClassName}>
<Skeleton className="h-64 w-full rounded-xl" /> <Skeleton className="h-64 w-full rounded-xl" />
<Skeleton className="h-64 w-full rounded-xl" /> <Skeleton className="h-64 w-full rounded-xl" />
</div> </div>
+1
View File
@@ -44,6 +44,7 @@ export const DATA_GRID_MESSAGES_RU = {
export const DATA_GRID_DENSE_LAYOUT: NonNullable<DataGridProps<object>['tableLayout']> = { export const DATA_GRID_DENSE_LAYOUT: NonNullable<DataGridProps<object>['tableLayout']> = {
...DATA_GRID_TABLE_LAYOUT, ...DATA_GRID_TABLE_LAYOUT,
dense: true, dense: true,
width: 'auto',
} }
export function createTextGlobalFilter<TData extends object>( export function createTextGlobalFilter<TData extends object>(
+9 -3
View File
@@ -30,7 +30,13 @@ export const kpiGridClassName =
export const kpiCardContentClassName = 'flex flex-col items-start gap-4 p-5' export const kpiCardContentClassName = 'flex flex-col items-start gap-4 p-5'
/** Two-column chart panel row (monitoring / network overview). */ /** Two-column chart panel row (monitoring / network overview). */
export const chartPanelGridClassName = 'grid grid-cols-1 gap-4 @5xl:grid-cols-2' export const chartPanelGridClassName =
'grid min-w-0 grid-cols-1 items-start gap-4 @5xl:grid-cols-2'
/** Main + sidebar layout (8+4) used on dashboard. */ /**
export const dashboardMainSidebarClassName = 'grid gap-4 xl:grid-cols-12 xl:items-start' * Main + sidebar: stack until the dashboard container is wide enough.
* Do not nest this inside another 2-col grid — that squeezes modules/timeline.
* @see https://reui.io/preview/base/dashboard-1
*/
export const dashboardMainSidebarClassName =
'grid min-w-0 grid-cols-1 items-start gap-4 @5xl:grid-cols-2'
@@ -4,12 +4,12 @@ import { Save } from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { PanelCard } from '@/components/panel-card'
import { Input } from '@evobgp/ui/components/input' import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { BadgeTabs, TabsContent } from '@/components/badge-tabs' import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
import { FrameDataGrid } from '@/components/reui-kit' import { FrameDataGrid } from '@/components/reui-kit'
import { SelectField } from '@/components/select-field' import { SelectMenu } from '@/components/select-field'
import { SettingsCard } from '@/components/settings/settings-card'
import { SettingsFieldGroup } from '@/components/settings/settings-field-group'
import { SettingsKvGrid } from '@/components/settings/settings-kv-grid' import { SettingsKvGrid } from '@/components/settings/settings-kv-grid'
import { SettingsSettingField } from '@/components/settings/settings-setting-field' import { SettingsSettingField } from '@/components/settings/settings-setting-field'
import { QueryState } from '@/components/query-state' import { QueryState } from '@/components/query-state'
@@ -114,7 +114,7 @@ function TenantSettingsComponent() {
} }
return ( return (
<div className="flex flex-col gap-6"> <div className="flex min-w-0 flex-col gap-6">
<BadgeTabs <BadgeTabs
value={search.tab} value={search.tab}
onValueChange={(tab) => onValueChange={(tab) =>
@@ -130,90 +130,104 @@ function TenantSettingsComponent() {
]} ]}
> >
<TabsContent value="bird" className="mt-0"> <TabsContent value="bird" className="mt-0">
<PanelCard <SettingsCard
title="BIRD control plane" title="BIRD control plane"
description={ description="Глобальные параметры BIRD для pipeline refresh/apply. Сохранение — роль operator."
<> footer={
Глобальные параметры BIRD для pipeline refresh/apply. Сохранение через{' '} <LoadingButton onClick={saveBird} loading={patchMutation.isPending}>
<code className="text-xs">PATCH /v1/settings</code> (роль operator). <Save />
</> Сохранить
</LoadingButton>
} }
contentClassName="space-y-4 py-4"
> >
<QueryState <QueryState
data={partitioned} data={partitioned}
isLoading={settingsQ.isLoading} isLoading={settingsQ.isLoading}
isError={settingsQ.isError} isError={settingsQ.isError}
error={settingsQ.error} error={settingsQ.error}
skeleton={<div className="h-64" />} skeleton={<div className="h-64" />}
onRetry={() => settingsQ.refetch()} onRetry={() => settingsQ.refetch()}
> >
{() => ( {() => (
<div className="flex flex-col"> <SettingsFieldGroup
{BIRD_SETTING_KEYS.map((key, index) => ( legend="BIRD"
<SettingsSettingField description="Параметры демона и автообнаружения пиров."
key={key} >
title={BIRD_LABELS[key]} {BIRD_SETTING_KEYS.map((key, index) => (
description={BIRD_HINTS[key] ?? key} <SettingsSettingField
labelFor={key} key={key}
badge={{ label: 'BIRD', variant: 'info-light' }} title={BIRD_LABELS[key]}
last={index === BIRD_SETTING_KEYS.length - 1} description={BIRD_HINTS[key]}
> labelFor={key}
{key === 'peer_discovery_enabled' || badge={{ label: 'BIRD', variant: 'info-light' }}
key === 'peer_discovery_require_external' ? ( stacked
<SelectField last={index === BIRD_SETTING_KEYS.length - 1}
id={key} >
items={[...BIRD_BOOL_ITEMS]} {key === 'peer_discovery_enabled' ||
value={birdForm[key] || 'false'} key === 'peer_discovery_require_external' ? (
onValueChange={(v) => <SelectMenu
setBirdForm((s) => ({ ...s, [key]: v || 'false' })) id={key}
} items={[...BIRD_BOOL_ITEMS]}
placeholder="Выкл" value={birdForm[key] || 'false'}
/> onValueChange={(v) =>
) : ( setBirdForm((s) => ({ ...s, [key]: v || 'false' }))
<Input }
id={key} placeholder="Выкл"
value={birdForm[key] ?? ''} />
onChange={(e) => ) : (
setBirdForm((s) => ({ ...s, [key]: e.target.value })) <Input
} id={key}
placeholder={BIRD_LABELS[key]} className="w-full min-w-0"
/> value={birdForm[key] ?? ''}
)} onChange={(e) =>
</SettingsSettingField> setBirdForm((s) => ({ ...s, [key]: e.target.value }))
))} }
<div className="px-4 py-4"> placeholder={BIRD_LABELS[key]}
<LoadingButton onClick={saveBird} loading={patchMutation.isPending}> />
<Save /> )}
Сохранить </SettingsSettingField>
</LoadingButton> ))}
</div> </SettingsFieldGroup>
</div> )}
)} </QueryState>
</QueryState> </SettingsCard>
</PanelCard>
</TabsContent> </TabsContent>
<TabsContent value="revision" className="mt-0"> <TabsContent value="revision" className="mt-0">
<PanelCard <SettingsCard
title="Ревизии" title="Ревизии"
description="Время хранения ревизий в БД" description="Время хранения ревизий в БД"
contentClassName="space-y-4 py-4" footer={
<LoadingButton onClick={saveRevision} loading={patchMutation.isPending}>
<Save />
Сохранить
</LoadingButton>
}
> >
<QueryState <QueryState
data={partitioned} data={partitioned}
isLoading={settingsQ.isLoading} isLoading={settingsQ.isLoading}
isError={settingsQ.isError} isError={settingsQ.isError}
error={settingsQ.error} error={settingsQ.error}
skeleton={<div className="h-32" />} skeleton={<div className="h-32" />}
onRetry={() => settingsQ.refetch()} onRetry={() => settingsQ.refetch()}
> >
{() => ( {() => (
<div className="flex max-w-sm flex-col gap-1.5"> <SettingsFieldGroup
<Label htmlFor="revision_retention_minutes">Retention (минуты)</Label> legend="Retention"
description="Срок хранения ревизий в минутах."
>
<SettingsSettingField
title="Retention"
description="Сколько минут хранить ревизии конфигурации."
labelFor="revision_retention_minutes"
stacked
last
>
<Input <Input
id="revision_retention_minutes" id="revision_retention_minutes"
type="number" type="number"
className="w-full min-w-0"
value={revisionForm.revision_retention_minutes ?? ''} value={revisionForm.revision_retention_minutes ?? ''}
onChange={(e) => onChange={(e) =>
setRevisionForm((s) => ({ setRevisionForm((s) => ({
@@ -222,45 +236,46 @@ function TenantSettingsComponent() {
})) }))
} }
/> />
<p className="font-mono text-xs text-muted-foreground"> </SettingsSettingField>
revision_retention_minutes </SettingsFieldGroup>
</p> )}
<LoadingButton </QueryState>
className="mt-2 w-fit" </SettingsCard>
onClick={saveRevision}
loading={patchMutation.isPending}
>
<Save />
Сохранить
</LoadingButton>
</div>
)}
</QueryState>
</PanelCard>
</TabsContent> </TabsContent>
<TabsContent value="runtime-logs" className="mt-0"> <TabsContent value="runtime-logs" className="mt-0">
<PanelCard <SettingsCard
title="Файловые логи" title="Файловые логи"
description="Автоматическая очистка логов" description="Автоматическая очистка логов"
contentClassName="space-y-4 py-4" footer={
<LoadingButton onClick={saveRuntimeLogs} loading={patchMutation.isPending}>
<Save />
Сохранить
</LoadingButton>
}
> >
<QueryState <QueryState
data={partitioned} data={partitioned}
isLoading={settingsQ.isLoading} isLoading={settingsQ.isLoading}
isError={settingsQ.isError} isError={settingsQ.isError}
error={settingsQ.error} error={settingsQ.error}
skeleton={<div className="h-48" />} skeleton={<div className="h-48" />}
onRetry={() => settingsQ.refetch()} onRetry={() => settingsQ.refetch()}
> >
{() => ( {() => (
<div className="grid gap-4 md:grid-cols-2"> <SettingsFieldGroup
<SelectField legend="Авто-очистка"
label="Авто-очистка включена" description="Расписание и лимиты файловых логов runtime."
>
<SettingsSettingField
title="Авто-очистка"
description="Включить периодическую очистку файловых логов."
stacked
>
<SelectMenu
items={[...RUNTIME_LOGS_ENABLED_ITEMS]} items={[...RUNTIME_LOGS_ENABLED_ITEMS]}
value={runtimeLogsForm.runtime_logs_auto_enabled ?? 'false'} value={runtimeLogsForm.runtime_logs_auto_enabled ?? 'false'}
placeholder="Выберите" placeholder="Выберите"
description="runtime_logs_auto_enabled"
onValueChange={(v) => onValueChange={(v) =>
v && v &&
setRuntimeLogsForm((s) => ({ setRuntimeLogsForm((s) => ({
@@ -269,45 +284,54 @@ function TenantSettingsComponent() {
})) }))
} }
/> />
<div className="flex flex-col gap-1.5"> </SettingsSettingField>
<Label htmlFor="runtime_logs_max_file_mb">Макс. размер файла (MB)</Label> <SettingsSettingField
<Input title="Макс. размер файла"
id="runtime_logs_max_file_mb" description="Порог в мегабайтах, после которого срабатывает очистка."
type="number" labelFor="runtime_logs_max_file_mb"
value={runtimeLogsForm.runtime_logs_max_file_mb ?? ''} stacked
onChange={(e) => >
setRuntimeLogsForm((s) => ({ <Input
...s, id="runtime_logs_max_file_mb"
runtime_logs_max_file_mb: e.target.value, type="number"
})) className="w-full min-w-0"
} value={runtimeLogsForm.runtime_logs_max_file_mb ?? ''}
/> onChange={(e) =>
<p className="font-mono text-xs text-muted-foreground"> setRuntimeLogsForm((s) => ({
runtime_logs_max_file_mb ...s,
</p> runtime_logs_max_file_mb: e.target.value,
</div> }))
<div className="flex flex-col gap-1.5"> }
<Label htmlFor="runtime_logs_auto_schedule">Расписание (cron)</Label> />
<Input </SettingsSettingField>
id="runtime_logs_auto_schedule" <SettingsSettingField
value={runtimeLogsForm.runtime_logs_auto_schedule ?? ''} title="Расписание"
onChange={(e) => description="Cron-выражение для авто-очистки."
setRuntimeLogsForm((s) => ({ labelFor="runtime_logs_auto_schedule"
...s, stacked
runtime_logs_auto_schedule: e.target.value, >
})) <Input
} id="runtime_logs_auto_schedule"
/> className="w-full min-w-0"
<p className="font-mono text-xs text-muted-foreground"> value={runtimeLogsForm.runtime_logs_auto_schedule ?? ''}
runtime_logs_auto_schedule onChange={(e) =>
</p> setRuntimeLogsForm((s) => ({
</div> ...s,
<SelectField runtime_logs_auto_schedule: e.target.value,
label="Режим очистки" }))
}
/>
</SettingsSettingField>
<SettingsSettingField
title="Режим очистки"
description="Обнулить файл или удалить его."
stacked
last
>
<SelectMenu
items={[...RUNTIME_LOGS_MODE_ITEMS]} items={[...RUNTIME_LOGS_MODE_ITEMS]}
value={runtimeLogsForm.runtime_logs_auto_mode ?? ''} value={runtimeLogsForm.runtime_logs_auto_mode ?? ''}
placeholder="Выберите" placeholder="Выберите"
description="runtime_logs_auto_mode"
onValueChange={(v) => onValueChange={(v) =>
v && v &&
setRuntimeLogsForm((s) => ({ setRuntimeLogsForm((s) => ({
@@ -316,16 +340,11 @@ function TenantSettingsComponent() {
})) }))
} }
/> />
<div className="md:col-span-2"> </SettingsSettingField>
<LoadingButton onClick={saveRuntimeLogs} loading={patchMutation.isPending}> </SettingsFieldGroup>
<Save /> )}
Сохранить </QueryState>
</LoadingButton> </SettingsCard>
</div>
</div>
)}
</QueryState>
</PanelCard>
</TabsContent> </TabsContent>
<TabsContent value="additional" className="mt-0"> <TabsContent value="additional" className="mt-0">
+66 -45
View File
@@ -1,15 +1,23 @@
import { createFileRoute, Link } from '@tanstack/react-router' import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { KeyRound, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-react' import { KeyRound, MonitorIcon, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-react'
import { useMemo } from 'react' import { useMemo } from 'react'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { PanelCard } from '@/components/panel-card' import {
Item,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemTitle,
} from '@evobgp/ui/components/item'
import { AccessApiKeysCard } from '@/components/access/access-api-keys-card' import { AccessApiKeysCard } from '@/components/access/access-api-keys-card'
import { PageHeader } from '@/components/page-header' import { PageHeader } from '@/components/page-header'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit' import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
import { SettingsCard } from '@/components/settings/settings-card'
import { SectionCardsSkeleton } from '@/components/skeletons' import { SectionCardsSkeleton } from '@/components/skeletons'
import { sessionCanManageApiKeys } from '@/lib/auth' import { sessionCanManageApiKeys } from '@/lib/auth'
import { authSessionQueryOptions } from '@/queries/auth' import { authSessionQueryOptions } from '@/queries/auth'
@@ -38,6 +46,7 @@ function AccessComponent() {
const kpiItems: KpiStatItem[] = useMemo( const kpiItems: KpiStatItem[] = useMemo(
() => [ () => [
{ {
id: 'keys-total',
label: 'Всего ключей', label: 'Всего ключей',
value: keys.length, value: keys.length,
icon: <KeyRound className="size-4" />, icon: <KeyRound className="size-4" />,
@@ -48,6 +57,7 @@ function AccessComponent() {
), ),
}, },
{ {
id: 'keys-active',
label: 'Активных', label: 'Активных',
value: activeCount, value: activeCount,
icon: <ShieldCheck className="size-4" />, icon: <ShieldCheck className="size-4" />,
@@ -58,6 +68,7 @@ function AccessComponent() {
), ),
}, },
{ {
id: 'keys-revoked',
label: 'Отозванных', label: 'Отозванных',
value: revokedCount, value: revokedCount,
icon: <ShieldOff className="size-4" />, icon: <ShieldOff className="size-4" />,
@@ -93,7 +104,7 @@ function AccessComponent() {
})() })()
return ( return (
<div className="flex flex-col gap-6"> <div className="flex min-w-0 flex-col gap-6">
<PageHeader <PageHeader
title="Права доступа" title="Права доступа"
description="API-ключи control plane и текущая сессия Bearer-токена." description="API-ключи control plane и текущая сессия Bearer-токена."
@@ -108,47 +119,55 @@ function AccessComponent() {
/> />
{session ? ( {session ? (
<PanelCard <SettingsCard
title="Текущая сессия" title="Текущая сессия"
description="Tenant и права текущего Bearer (API-ключ или portal JWT)." description="Tenant и права текущего Bearer (API-ключ или portal JWT)."
contentClassName="grid gap-3 py-4 text-sm sm:grid-cols-2"
> >
<div> <ItemGroup className="gap-0">
<p className="text-muted-foreground">Tenant</p> <Item className="min-h-0 min-w-0 items-start gap-4 px-5 py-3.5">
<p className="break-all font-mono text-xs">{session.tenant_id}</p> <ItemMedia className="self-start">
</div> <Item className="bg-muted/60 border-background flex size-8 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] [&_svg]:size-4 [&_svg]:opacity-60">
<div> <MonitorIcon aria-hidden="true" />
<p className="text-muted-foreground">Доступ</p> </Item>
<p className="font-mono text-xs">{sessionAccessLabel}</p> </ItemMedia>
</div> <ItemContent className="min-w-0 gap-3">
{sessionKindLabel ? ( <div className="flex min-w-0 flex-wrap items-center gap-2">
<div> <ItemTitle className="leading-5">Этот браузер</ItemTitle>
<p className="text-muted-foreground">Тип</p> {sessionKindLabel ? (
<p className="font-mono text-xs">{sessionKindLabel}</p> <Badge variant="info-light" size="sm">
</div> {sessionKindLabel}
) : null} </Badge>
{session.email ? ( ) : null}
<div> </div>
<p className="text-muted-foreground">Email</p> <ItemDescription className="flex min-w-0 flex-col gap-1 leading-5">
<p className="break-all text-xs">{session.email}</p> <span className="break-all">
</div> Tenant:{' '}
) : null} <code className="text-foreground font-mono text-xs">{session.tenant_id}</code>
</PanelCard> </span>
<span className="break-words">Доступ: {sessionAccessLabel}</span>
{session.email ? <span className="break-all">Email: {session.email}</span> : null}
</ItemDescription>
</ItemContent>
</Item>
</ItemGroup>
</SettingsCard>
) : ( ) : (
<PanelCard contentClassName="py-4 text-sm text-muted-foreground"> <SettingsCard title="Текущая сессия">
Не удалось определить сессию. Укажите токен в{' '} <p className="text-muted-foreground px-5 py-4 text-sm">
<Link Не удалось определить сессию. Укажите токен в{' '}
to="/settings" <Link
search={{ tab: 'connection' }} to="/settings"
className="text-primary underline-offset-4 hover:underline" search={{ tab: 'connection' }}
> className="text-primary underline-offset-4 hover:underline"
настройках >
</Link>{' '} настройках
(для dev-окружения <code className="text-xs">dev</code> при включённом demo-seed). </Link>{' '}
{sessionQuery.isError && sessionQuery.error instanceof Error ? ( (для dev-окружения <code className="text-xs">dev</code> при включённом demo-seed).
<span className="mt-2 block text-destructive">{sessionQuery.error.message}</span> {sessionQuery.isError && sessionQuery.error instanceof Error ? (
) : null} <span className="text-destructive mt-2 block">{sessionQuery.error.message}</span>
</PanelCard> ) : null}
</p>
</SettingsCard>
)} )}
{canManageKeys ? ( {canManageKeys ? (
@@ -167,11 +186,13 @@ function AccessComponent() {
/> />
</> </>
) : session ? ( ) : session ? (
<PanelCard contentClassName="py-4 text-sm text-muted-foreground"> <SettingsCard title="API-ключи">
Управление API-ключами доступно роли <strong>operator</strong> (API-ключ) или portal JWT <p className="text-muted-foreground px-5 py-4 text-sm break-words">
с <strong>is_admin</strong> / правом <code className="text-xs">bgp:access:admin</code>. Управление API-ключами доступно роли <strong>operator</strong> (API-ключ) или portal JWT
Текущий доступ: <span className="font-mono">{sessionAccessLabel}</span>. с <strong>is_admin</strong> / правом <code className="text-xs">bgp:access:admin</code>.
</PanelCard> Текущий доступ: <span className="font-mono">{sessionAccessLabel}</span>.
</p>
</SettingsCard>
) : null} ) : null}
</div> </div>
) )
+25 -28
View File
@@ -7,7 +7,6 @@ import { Button } from '@evobgp/ui/components/button'
import { Skeleton } from '@evobgp/ui/components/skeleton' import { Skeleton } from '@evobgp/ui/components/skeleton'
import { DashboardActivityTimeline } from '@/components/dashboard/dashboard-activity-timeline' import { DashboardActivityTimeline } from '@/components/dashboard/dashboard-activity-timeline'
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
import { buildDashboardKpiCards } from '@/components/dashboard/dashboard-kpi-grid' import { buildDashboardKpiCards } from '@/components/dashboard/dashboard-kpi-grid'
import { DashboardModulesGrid } from '@/components/dashboard/dashboard-modules-grid' import { DashboardModulesGrid } from '@/components/dashboard/dashboard-modules-grid'
import { DashboardNetworkHealth } from '@/components/dashboard/dashboard-network-health' import { DashboardNetworkHealth } from '@/components/dashboard/dashboard-network-health'
@@ -15,7 +14,7 @@ import { DashboardOperationsBreakdown } from '@/components/dashboard/dashboard-o
import { DashboardQuickLinks } from '@/components/dashboard/dashboard-quick-links' import { DashboardQuickLinks } from '@/components/dashboard/dashboard-quick-links'
import { DashboardRecentJobsGrid } from '@/components/dashboard/dashboard-recent-jobs-grid' import { DashboardRecentJobsGrid } from '@/components/dashboard/dashboard-recent-jobs-grid'
import { DashboardRecentRevisionsGrid } from '@/components/dashboard/dashboard-recent-revisions-grid' import { DashboardRecentRevisionsGrid } from '@/components/dashboard/dashboard-recent-revisions-grid'
import { OpsDashboard } from '@/components/reui-kit' import { FrameDataGrid, OpsDashboard } from '@/components/reui-kit'
import { chartPanelGridClassName, dashboardMainSidebarClassName } from '@/lib/ui-surface' import { chartPanelGridClassName, dashboardMainSidebarClassName } from '@/lib/ui-surface'
import { import {
moduleNameById, moduleNameById,
@@ -37,7 +36,9 @@ function parseShowQuickActions(value: unknown): boolean {
} }
/** /**
* Dashboard — OpsDashboard kit (KPI → QuickActions → charts → queue). * Dashboard — OpsDashboard kit (KPI → QuickActions → modules → activity/health → queue).
* Charts slot is a vertical stack: modules stay full-width; activity shares a row
* with BGP widgets only at @5xl (container), never nested 8+4 inside a 2-col parent.
* @see https://reui.io/preview/base/dashboard-1 * @see https://reui.io/preview/base/dashboard-1
* @see https://reui.io/preview/base/stats-12 * @see https://reui.io/preview/base/stats-12
*/ */
@@ -99,51 +100,47 @@ function DashboardComponent() {
afterKpi={showQuickActions ? <DashboardQuickLinks /> : null} afterKpi={showQuickActions ? <DashboardQuickLinks /> : null}
charts={ charts={
<> <>
<div className={dashboardMainSidebarClassName}> <div className="min-w-0">
<div className="xl:col-span-8"> <DashboardModulesGrid modules={modules} isLoading={refreshing} />
<DashboardModulesGrid modules={modules} isLoading={refreshing} />
</div>
<div className="xl:col-span-4">
<DashboardActivityTimeline
jobs={jobs}
revisions={revisions}
peers={peers}
speakers={speakers}
/>
</div>
</div> </div>
<div className={chartPanelGridClassName}> <div className={dashboardMainSidebarClassName}>
<DashboardNetworkHealth peers={peers} speakers={speakers} jobs={jobs} /> <DashboardActivityTimeline
<DashboardOperationsBreakdown jobs={jobs} modules={modules} /> jobs={jobs}
revisions={revisions}
peers={peers}
speakers={speakers}
/>
<div className="grid min-w-0 items-start gap-4">
<DashboardNetworkHealth peers={peers} speakers={speakers} jobs={jobs} />
<DashboardOperationsBreakdown jobs={jobs} modules={modules} />
</div>
</div> </div>
</> </>
} }
queueTitle="Недавняя активность" queueTitle="Задачи и ревизии"
queueDescription="Задачи и ревизии плоскости управления" queueDescription="Последние фоновые операции и история конфигураций"
queue={ queue={
<div className="grid gap-4 lg:grid-cols-2"> <div className={chartPanelGridClassName}>
<DashboardFramePanel <FrameDataGrid
title="Недавние задачи" title="Недавние задачи"
description="Последние фоновые операции" description="Последние фоновые операции"
className="h-full"
> >
{activityLoading ? ( {activityLoading ? (
<Skeleton className="m-4 h-24 w-auto" /> <Skeleton className="m-4 h-24 w-auto" />
) : ( ) : (
<DashboardRecentJobsGrid jobs={jobs.slice(0, 10)} nameById={nameById} isLoading={refreshing} /> <DashboardRecentJobsGrid jobs={jobs.slice(0, 8)} nameById={nameById} isLoading={refreshing} />
)} )}
</DashboardFramePanel> </FrameDataGrid>
<DashboardFramePanel <FrameDataGrid
title="Последние ревизии" title="Последние ревизии"
description="История конфигураций" description="История конфигураций"
className="h-full"
> >
{activityLoading ? ( {activityLoading ? (
<Skeleton className="m-4 h-24 w-auto" /> <Skeleton className="m-4 h-24 w-auto" />
) : ( ) : (
<DashboardRecentRevisionsGrid revisions={revisions} isLoading={refreshing} /> <DashboardRecentRevisionsGrid revisions={revisions} isLoading={refreshing} />
)} )}
</DashboardFramePanel> </FrameDataGrid>
</div> </div>
} }
/> />
+10 -7
View File
@@ -24,12 +24,13 @@ function Tabs({
} }
const tabsListVariants = cva( const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none", "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg text-muted-foreground group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
{ {
variants: { variants: {
variant: { variant: {
default: "bg-muted", default:
line: "gap-1 border-b border-border bg-transparent", "bg-muted p-[3px] group-data-[orientation=horizontal]/tabs:h-9",
line: "h-auto gap-1 overflow-visible border-b border-border bg-transparent p-0",
}, },
}, },
defaultVariants: { defaultVariants: {
@@ -58,10 +59,12 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
<TabsPrimitive.Tab <TabsPrimitive.Tab
data-slot="tabs-trigger" data-slot="tabs-trigger"
className={cn( className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "relative inline-flex items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent", "group-data-[variant=default]/tabs-list:h-[calc(100%-1px)] group-data-[variant=default]/tabs-list:flex-1 group-data-[variant=default]/tabs-list:data-active:shadow-sm",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground", "group-data-[variant=line]/tabs-list:h-auto group-data-[variant=line]/tabs-list:flex-none group-data-[variant=line]/tabs-list:rounded-none group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:py-1.5 group-data-[variant=line]/tabs-list:shadow-none group-data-[variant=line]/tabs-list:data-active:bg-transparent group-data-[variant=line]/tabs-list:data-active:shadow-none dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100", "group-data-[variant=default]/tabs-list:data-active:bg-background group-data-[variant=default]/tabs-list:data-active:text-foreground dark:group-data-[variant=default]/tabs-list:data-active:border-input dark:group-data-[variant=default]/tabs-list:data-active:bg-input/30 dark:group-data-[variant=default]/tabs-list:data-active:text-foreground",
"data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-0 group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className className
)} )}
{...props} {...props}