Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfc33dcb99 | ||
|
|
44f70443c3 | ||
|
|
357ed4ce7b |
@@ -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"
|
||||||
|
|||||||
@@ -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="Поиск ревизий…"
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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 5–6 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}
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>(
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user