Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44f70443c3 | ||
|
|
357ed4ce7b |
@@ -49,7 +49,8 @@ export function AccessApiKeysCard({
|
||||
<>
|
||||
<FrameDataGrid
|
||||
title="API-ключи"
|
||||
description="Управление ключами tenant. Полный токен показывается только при создании и ротации."
|
||||
description="Полный токен показывается только при создании и ротации."
|
||||
className="min-w-0"
|
||||
actions={
|
||||
<>
|
||||
<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 { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
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 type { ApiKey } from '@/types/api'
|
||||
|
||||
@@ -34,7 +35,14 @@ export function AccessApiKeysGrid({
|
||||
{
|
||||
accessorKey: 'name',
|
||||
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: 'Имя' },
|
||||
},
|
||||
{
|
||||
@@ -45,14 +53,6 @@ export function AccessApiKeysGrid({
|
||||
),
|
||||
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',
|
||||
enableSorting: false,
|
||||
@@ -77,11 +77,11 @@ export function AccessApiKeysGrid({
|
||||
{
|
||||
id: 'last_used_at',
|
||||
accessorFn: (row) => row.last_used_at ?? '',
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Последнее использование" />,
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Использован" />,
|
||||
cell: ({ row }) => (
|
||||
<DataGridMutedCell>{formatApiKeyDate(row.original.last_used_at)}</DataGridMutedCell>
|
||||
),
|
||||
meta: { headerTitle: 'Последнее использование' },
|
||||
meta: { headerTitle: 'Использован' },
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
@@ -90,7 +90,7 @@ export function AccessApiKeysGrid({
|
||||
cell: ({ row }) => {
|
||||
const k = row.original
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button
|
||||
@@ -149,6 +149,7 @@ export function AccessApiKeysGrid({
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет ключей"
|
||||
tableLayout={DATA_GRID_DENSE_LAYOUT}
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск API-ключей…"
|
||||
|
||||
@@ -41,14 +41,17 @@ export function BadgeTabs({
|
||||
defaultValue={defaultValue}
|
||||
onValueChange={onValueChange}
|
||||
orientation="horizontal"
|
||||
className={cn('w-full', className)}
|
||||
className={cn('w-full min-w-0', className)}
|
||||
>
|
||||
<TabsList
|
||||
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 overflow-x-auto',
|
||||
listClassName,
|
||||
)}
|
||||
>
|
||||
{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.label}
|
||||
{item.count !== undefined ? (
|
||||
|
||||
@@ -50,7 +50,7 @@ export function DashboardActivityTimeline({
|
||||
<DashboardFramePanel
|
||||
title="Недавняя активность"
|
||||
description="Задачи, ревизии и сетевые события"
|
||||
className="h-full"
|
||||
className="h-full min-w-0"
|
||||
>
|
||||
{loading ? (
|
||||
<p className="text-muted-foreground px-4 py-6 text-sm">Загрузка…</p>
|
||||
@@ -82,7 +82,7 @@ export function DashboardActivityTimeline({
|
||||
</TimelineIndicator>
|
||||
</TimelineHeader>
|
||||
<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}
|
||||
</TimelineTitle>
|
||||
<div className="mt-2">
|
||||
|
||||
@@ -119,9 +119,10 @@ export function DashboardModulesGrid({
|
||||
cell: ({ row }) => (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<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>
|
||||
),
|
||||
minSize: 180,
|
||||
meta: { headerTitle: 'Модуль' },
|
||||
},
|
||||
{
|
||||
@@ -164,6 +165,7 @@ export function DashboardModulesGrid({
|
||||
<DashboardFramePanel
|
||||
title="Модули"
|
||||
description="Поиск, сортировка и быстрый переход к настройке"
|
||||
className="min-w-0"
|
||||
actions={
|
||||
<Button variant="outline" size="sm" render={<Link to="/modules/new" />}>
|
||||
<PlusIcon />
|
||||
@@ -183,14 +185,14 @@ export function DashboardModulesGrid({
|
||||
columnsVisibility: false,
|
||||
columnsResizable: false,
|
||||
columnsMovable: false,
|
||||
width: 'fixed',
|
||||
width: 'auto',
|
||||
}}
|
||||
tableClassNames={{ bodyRow: 'group/module-row cursor-pointer [&>td]:h-14' }}
|
||||
onRowClick={(row) => void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })}
|
||||
>
|
||||
<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">
|
||||
<InputGroup className="w-full min-w-40 sm:max-w-xs">
|
||||
<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-0 @3xl:max-w-xs">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<SearchIcon className="text-muted-foreground size-4" aria-hidden />
|
||||
</InputGroupAddon>
|
||||
|
||||
@@ -50,7 +50,7 @@ export function DataGridShell<TData extends object>({
|
||||
loadingMessage={DATA_GRID_MESSAGES_RU.loadingMessage}
|
||||
tableLayout={tableLayout}
|
||||
tableClassNames={DATA_GRID_TABLE_CLASS_NAMES}
|
||||
className={className}
|
||||
className={cn('min-w-0', className)}
|
||||
onRowClick={onRowClick}
|
||||
>
|
||||
<DataGridContainer border={false}>
|
||||
|
||||
@@ -8,12 +8,16 @@ interface PageHeaderProps {
|
||||
|
||||
export function PageHeader({ title, description, actions }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex min-w-0 flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<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>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ export function PanelCard({
|
||||
<Frame
|
||||
dense
|
||||
spacing={spacing}
|
||||
className={cn(panelCardClassName, className)}
|
||||
className={cn(panelCardClassName, 'min-w-0', className)}
|
||||
>
|
||||
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
|
||||
{hasHeader ? (
|
||||
|
||||
@@ -29,10 +29,10 @@ export function FrameDataGrid({
|
||||
}) {
|
||||
const hasHeader = Boolean(title || description || actions)
|
||||
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">
|
||||
{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">
|
||||
{title ? <FrameTitle>{title}</FrameTitle> : null}
|
||||
{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 {
|
||||
if (count <= 1) return 'grid-cols-1'
|
||||
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
|
||||
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
|
||||
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
|
||||
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
|
||||
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
|
||||
return 'grid-cols-1 sm:grid-cols-2 xl:grid-cols-4'
|
||||
if (count <= 6) return 'grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3'
|
||||
return 'grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3 @6xl:grid-cols-4'
|
||||
}
|
||||
|
||||
@@ -305,7 +305,7 @@ export function KpiStatGrid({
|
||||
}
|
||||
|
||||
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))}>
|
||||
{list.map((item, index) => (
|
||||
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
|
||||
|
||||
@@ -27,7 +27,7 @@ interface OpsDashboardProps {
|
||||
}
|
||||
|
||||
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() {
|
||||
return (
|
||||
@@ -37,7 +37,7 @@ function OpsDashboardSkeleton() {
|
||||
<Skeleton className="mt-2 h-4 w-80 max-w-full" />
|
||||
</header>
|
||||
<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" />
|
||||
</div>
|
||||
@@ -79,13 +79,13 @@ export function OpsDashboard({
|
||||
|
||||
<section
|
||||
aria-label="Аналитика"
|
||||
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
|
||||
className="flex min-w-0 flex-col gap-4"
|
||||
>
|
||||
{charts}
|
||||
</section>
|
||||
|
||||
<section aria-label={queueTitle}>
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
<Frame dense spacing="sm" className="w-full min-w-0">
|
||||
<FrameHeader>
|
||||
<FrameTitle>{queueTitle}</FrameTitle>
|
||||
<FrameDescription>{queueDescription}</FrameDescription>
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { ReactNode } from 'react'
|
||||
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
|
||||
import { ServerCogIcon, SettingsIcon } from 'lucide-react'
|
||||
|
||||
import { useIsMobile } from '@evobgp/ui/hooks/use-mobile'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
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-3
|
||||
*/
|
||||
@@ -46,7 +46,6 @@ export function SettingsShell({
|
||||
description,
|
||||
tabs = DEFAULT_TABS,
|
||||
}: SettingsShellProps) {
|
||||
const isMobile = useIsMobile()
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const headerDescription =
|
||||
description ??
|
||||
@@ -56,31 +55,20 @@ export function SettingsShell({
|
||||
|
||||
return (
|
||||
<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} />
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'flex gap-5',
|
||||
isMobile ? 'flex-col' : 'flex-row items-start',
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-5 @3xl:flex-row @3xl:items-start">
|
||||
{tabs.length > 1 ? (
|
||||
<nav
|
||||
aria-label="Разделы настроек"
|
||||
className={cn(
|
||||
'flex gap-1',
|
||||
isMobile
|
||||
? 'scrollbar-none -mx-1 overflow-x-auto overflow-y-hidden pb-1'
|
||||
: 'w-44 shrink-0 flex-col',
|
||||
)}
|
||||
className="scrollbar-none flex min-w-0 gap-1 overflow-x-auto @3xl:w-44 @3xl:shrink-0 @3xl:flex-col @3xl:overflow-visible"
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = pathname.startsWith(tab.to)
|
||||
const className = cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors',
|
||||
isMobile && 'shrink-0',
|
||||
!isMobile && 'w-full',
|
||||
'flex shrink-0 items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors',
|
||||
'@3xl:w-full',
|
||||
isActive
|
||||
? 'bg-muted text-foreground font-medium shadow-sm ring-1 ring-border/60'
|
||||
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
|
||||
|
||||
@@ -44,7 +44,7 @@ export function AppearanceSettingsTab() {
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
<SettingsCard
|
||||
title="Тема интерфейса"
|
||||
description="Быстрый переключатель также доступен в боковой панели"
|
||||
@@ -61,6 +61,7 @@ export function AppearanceSettingsTab() {
|
||||
мгновенно
|
||||
</Badge>
|
||||
}
|
||||
stacked
|
||||
last
|
||||
>
|
||||
<ToggleGroup
|
||||
@@ -72,7 +73,7 @@ export function AppearanceSettingsTab() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label="Тема интерфейса"
|
||||
className="w-full sm:w-auto"
|
||||
className="w-full min-w-0"
|
||||
>
|
||||
{THEME_OPTIONS.map((option) => {
|
||||
const Icon = option.icon
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Save } from 'lucide-react'
|
||||
import { KeyRound, Save } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
@@ -8,6 +8,7 @@ import { SettingRow } from '@/components/settings/setting-row'
|
||||
import { SettingsCard } from '@/components/settings/settings-card'
|
||||
import { SettingsFieldGroup } from '@/components/settings/settings-field-group'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import {
|
||||
DEV_API_TOKEN,
|
||||
@@ -16,7 +17,6 @@ import {
|
||||
TOKEN_STORAGE_KEY,
|
||||
} from '@/lib/api-client'
|
||||
import { authKeys } from '@/queries/auth'
|
||||
import { Alert, AlertDescription } from '@evobgp/ui/components/alert'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
|
||||
@@ -50,12 +50,13 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
{tokenRequired ? (
|
||||
<Alert>
|
||||
<Alert variant="warning">
|
||||
<KeyRound aria-hidden />
|
||||
<AlertTitle>Нужен API-токен</AlertTitle>
|
||||
<AlertDescription>
|
||||
Для доступа к разделу нужен API-токен. Сохраните токен ниже или нажмите «Использовать
|
||||
dev».
|
||||
Сохраните токен ниже или нажмите «Использовать dev».
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
@@ -64,7 +65,7 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
|
||||
title="Подключение к API"
|
||||
description="Токен хранится только в этом браузере (localStorage)"
|
||||
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}>
|
||||
Использовать dev
|
||||
</Button>
|
||||
@@ -83,8 +84,7 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
|
||||
title="Токен для запросов"
|
||||
description={
|
||||
<>
|
||||
Ключ для заголовка <code className="text-xs">Authorization</code>. Управление
|
||||
ключами tenant — в разделе{' '}
|
||||
Ключ для заголовка Authorization. Управление ключами tenant — в разделе{' '}
|
||||
<Link to="/access" className="text-primary underline-offset-4 hover:underline">
|
||||
Права доступа
|
||||
</Link>
|
||||
@@ -97,12 +97,14 @@ export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolea
|
||||
</Badge>
|
||||
}
|
||||
labelFor="settings-token"
|
||||
stacked
|
||||
last
|
||||
>
|
||||
<Input
|
||||
id="settings-token"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
className="w-full min-w-0"
|
||||
value={token}
|
||||
onChange={(e) => setTokenValue(e.target.value)}
|
||||
placeholder="dev или API-ключ"
|
||||
|
||||
@@ -49,7 +49,7 @@ const ADMIN_SECTIONS = [
|
||||
|
||||
export function SectionsSettingsTab() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
<SettingsCard
|
||||
title="Разделы control plane"
|
||||
description="Параметры tenant и операции — отдельно от настроек браузера"
|
||||
@@ -58,7 +58,7 @@ export function SectionsSettingsTab() {
|
||||
{ADMIN_SECTIONS.map((section, index) => (
|
||||
<Fragment key={section.id}>
|
||||
{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!">
|
||||
<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}
|
||||
@@ -66,7 +66,7 @@ export function SectionsSettingsTab() {
|
||||
</ItemMedia>
|
||||
|
||||
<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}
|
||||
<Badge variant={section.badge.variant} size="xs">
|
||||
{section.badge.label}
|
||||
|
||||
@@ -35,7 +35,7 @@ export function SessionSettingsTab() {
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
<SettingsCard title="Текущая сессия" description="Проверка токена через GET /v1/auth/session">
|
||||
<QueryState
|
||||
data={sessionQ.data}
|
||||
@@ -58,7 +58,7 @@ export function SessionSettingsTab() {
|
||||
</ItemMedia>
|
||||
|
||||
<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">
|
||||
Активна
|
||||
@@ -66,7 +66,9 @@ export function SessionSettingsTab() {
|
||||
</ItemTitle>
|
||||
<ItemDescription className="leading-5">
|
||||
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>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
@@ -94,12 +96,13 @@ export function SessionSettingsTab() {
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
stacked
|
||||
last
|
||||
>
|
||||
{sessionQ.data ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<p className="text-muted-foreground text-sm break-words">
|
||||
Ключ с ролью <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 className="text-muted-foreground text-sm">
|
||||
|
||||
@@ -10,31 +10,59 @@ import {
|
||||
FieldTitle,
|
||||
} from '@evobgp/ui/components/field'
|
||||
|
||||
interface SettingRowProps {
|
||||
export interface SettingRowProps {
|
||||
title: string
|
||||
description?: ReactNode
|
||||
children: ReactNode
|
||||
last?: boolean
|
||||
/** Opt-in FieldSeparator after the row (default off — ReUI settings use Frame+gap). */
|
||||
separated?: boolean
|
||||
compact?: boolean
|
||||
stacked?: boolean
|
||||
labelFor?: string
|
||||
contentClassName?: string
|
||||
className?: string
|
||||
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({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
last,
|
||||
separated = false,
|
||||
compact,
|
||||
stacked,
|
||||
labelFor,
|
||||
contentClassName,
|
||||
className,
|
||||
titleAddon,
|
||||
}: SettingRowProps) {
|
||||
return (
|
||||
<>
|
||||
<Field orientation="responsive" className="gap-4 px-5 py-3">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Field
|
||||
orientation={stacked ? 'vertical' : 'responsive'}
|
||||
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 ? (
|
||||
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
|
||||
) : (
|
||||
@@ -44,20 +72,33 @@ export function SettingRow({
|
||||
</div>
|
||||
|
||||
{description ? (
|
||||
<FieldDescription>{description}</FieldDescription>
|
||||
<FieldDescription className="text-sm break-words">{description}</FieldDescription>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<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}
|
||||
</div>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
{!last ? <FieldSeparator /> : null}
|
||||
{separated && !last ? <FieldSeparator /> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export function SettingsCard({
|
||||
footerClassName,
|
||||
}: SettingsCardProps) {
|
||||
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">
|
||||
<FrameHeader className="gap-0 border-b px-5 py-3">
|
||||
<FrameTitle>{title}</FrameTitle>
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import { type ComponentProps, type ReactNode } from 'react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldDescription,
|
||||
FieldLabel,
|
||||
FieldSeparator,
|
||||
FieldTitle,
|
||||
} from '@evobgp/ui/components/field'
|
||||
import { SettingRow } from '@/components/settings/setting-row'
|
||||
|
||||
/** settings-3 row pattern for tenant/UI settings. */
|
||||
/** Tenant settings row — same SettingRow DNA as UI settings. */
|
||||
export function SettingsSettingField({
|
||||
title,
|
||||
description,
|
||||
@@ -20,38 +12,34 @@ export function SettingsSettingField({
|
||||
last,
|
||||
labelFor,
|
||||
contentClassName,
|
||||
stacked,
|
||||
}: {
|
||||
title: string
|
||||
description: ReactNode
|
||||
description?: ReactNode
|
||||
badge?: { label: string; variant: ComponentProps<typeof Badge>['variant'] }
|
||||
children: ReactNode
|
||||
last?: boolean
|
||||
labelFor?: string
|
||||
contentClassName?: string
|
||||
stacked?: boolean
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Field orientation="responsive" className="gap-4 px-4 py-4">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{labelFor ? (
|
||||
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
|
||||
) : (
|
||||
<FieldTitle>{title}</FieldTitle>
|
||||
)}
|
||||
{badge ? (
|
||||
<Badge variant={badge.variant} size="sm">
|
||||
{badge.label}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<FieldDescription className="text-xs">{description}</FieldDescription>
|
||||
</div>
|
||||
<FieldContent className={cn('min-w-0 @md/field-group:w-78', contentClassName)}>
|
||||
{children}
|
||||
</FieldContent>
|
||||
</Field>
|
||||
{!last ? <FieldSeparator /> : null}
|
||||
</>
|
||||
<SettingRow
|
||||
title={title}
|
||||
description={description}
|
||||
last={last}
|
||||
labelFor={labelFor}
|
||||
contentClassName={contentClassName}
|
||||
stacked={stacked}
|
||||
titleAddon={
|
||||
badge ? (
|
||||
<Badge variant={badge.variant} size="sm">
|
||||
{badge.label}
|
||||
</Badge>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</SettingRow>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -39,11 +39,11 @@ export function AnalyticsDashboardSkeleton() {
|
||||
</div>
|
||||
|
||||
<div className={dashboardMainSidebarClassName}>
|
||||
<Skeleton className="h-96 w-full rounded-xl xl:col-span-8" />
|
||||
<Skeleton className="h-96 w-full rounded-xl xl:col-span-4" />
|
||||
<Skeleton className="h-96 w-full min-w-0 rounded-xl" />
|
||||
<Skeleton className="h-96 w-full min-w-0 rounded-xl" />
|
||||
</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" />
|
||||
</div>
|
||||
|
||||
@@ -44,6 +44,7 @@ export const DATA_GRID_MESSAGES_RU = {
|
||||
export const DATA_GRID_DENSE_LAYOUT: NonNullable<DataGridProps<object>['tableLayout']> = {
|
||||
...DATA_GRID_TABLE_LAYOUT,
|
||||
dense: true,
|
||||
width: 'auto',
|
||||
}
|
||||
|
||||
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'
|
||||
|
||||
/** 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 { toast } from 'sonner'
|
||||
|
||||
import { PanelCard } from '@/components/panel-card'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
import { Label } from '@evobgp/ui/components/label'
|
||||
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
|
||||
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 { SettingsSettingField } from '@/components/settings/settings-setting-field'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
@@ -114,7 +114,7 @@ function TenantSettingsComponent() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex min-w-0 flex-col gap-6">
|
||||
<BadgeTabs
|
||||
value={search.tab}
|
||||
onValueChange={(tab) =>
|
||||
@@ -130,90 +130,104 @@ function TenantSettingsComponent() {
|
||||
]}
|
||||
>
|
||||
<TabsContent value="bird" className="mt-0">
|
||||
<PanelCard
|
||||
<SettingsCard
|
||||
title="BIRD control plane"
|
||||
description={
|
||||
<>
|
||||
Глобальные параметры BIRD для pipeline refresh/apply. Сохранение через{' '}
|
||||
<code className="text-xs">PATCH /v1/settings</code> (роль operator).
|
||||
</>
|
||||
description="Глобальные параметры BIRD для pipeline refresh/apply. Сохранение — роль operator."
|
||||
footer={
|
||||
<LoadingButton onClick={saveBird} loading={patchMutation.isPending}>
|
||||
<Save />
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
}
|
||||
contentClassName="space-y-4 py-4"
|
||||
>
|
||||
<QueryState
|
||||
data={partitioned}
|
||||
isLoading={settingsQ.isLoading}
|
||||
isError={settingsQ.isError}
|
||||
error={settingsQ.error}
|
||||
skeleton={<div className="h-64" />}
|
||||
onRetry={() => settingsQ.refetch()}
|
||||
>
|
||||
{() => (
|
||||
<div className="flex flex-col">
|
||||
{BIRD_SETTING_KEYS.map((key, index) => (
|
||||
<SettingsSettingField
|
||||
key={key}
|
||||
title={BIRD_LABELS[key]}
|
||||
description={BIRD_HINTS[key] ?? key}
|
||||
labelFor={key}
|
||||
badge={{ label: 'BIRD', variant: 'info-light' }}
|
||||
last={index === BIRD_SETTING_KEYS.length - 1}
|
||||
>
|
||||
{key === 'peer_discovery_enabled' ||
|
||||
key === 'peer_discovery_require_external' ? (
|
||||
<SelectField
|
||||
id={key}
|
||||
items={[...BIRD_BOOL_ITEMS]}
|
||||
value={birdForm[key] || 'false'}
|
||||
onValueChange={(v) =>
|
||||
setBirdForm((s) => ({ ...s, [key]: v || 'false' }))
|
||||
}
|
||||
placeholder="Выкл"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id={key}
|
||||
value={birdForm[key] ?? ''}
|
||||
onChange={(e) =>
|
||||
setBirdForm((s) => ({ ...s, [key]: e.target.value }))
|
||||
}
|
||||
placeholder={BIRD_LABELS[key]}
|
||||
/>
|
||||
)}
|
||||
</SettingsSettingField>
|
||||
))}
|
||||
<div className="px-4 py-4">
|
||||
<LoadingButton onClick={saveBird} loading={patchMutation.isPending}>
|
||||
<Save />
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</QueryState>
|
||||
</PanelCard>
|
||||
<QueryState
|
||||
data={partitioned}
|
||||
isLoading={settingsQ.isLoading}
|
||||
isError={settingsQ.isError}
|
||||
error={settingsQ.error}
|
||||
skeleton={<div className="h-64" />}
|
||||
onRetry={() => settingsQ.refetch()}
|
||||
>
|
||||
{() => (
|
||||
<SettingsFieldGroup
|
||||
legend="BIRD"
|
||||
description="Параметры демона и автообнаружения пиров."
|
||||
>
|
||||
{BIRD_SETTING_KEYS.map((key, index) => (
|
||||
<SettingsSettingField
|
||||
key={key}
|
||||
title={BIRD_LABELS[key]}
|
||||
description={BIRD_HINTS[key]}
|
||||
labelFor={key}
|
||||
badge={{ label: 'BIRD', variant: 'info-light' }}
|
||||
stacked
|
||||
last={index === BIRD_SETTING_KEYS.length - 1}
|
||||
>
|
||||
{key === 'peer_discovery_enabled' ||
|
||||
key === 'peer_discovery_require_external' ? (
|
||||
<SelectMenu
|
||||
id={key}
|
||||
items={[...BIRD_BOOL_ITEMS]}
|
||||
value={birdForm[key] || 'false'}
|
||||
onValueChange={(v) =>
|
||||
setBirdForm((s) => ({ ...s, [key]: v || 'false' }))
|
||||
}
|
||||
placeholder="Выкл"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id={key}
|
||||
className="w-full min-w-0"
|
||||
value={birdForm[key] ?? ''}
|
||||
onChange={(e) =>
|
||||
setBirdForm((s) => ({ ...s, [key]: e.target.value }))
|
||||
}
|
||||
placeholder={BIRD_LABELS[key]}
|
||||
/>
|
||||
)}
|
||||
</SettingsSettingField>
|
||||
))}
|
||||
</SettingsFieldGroup>
|
||||
)}
|
||||
</QueryState>
|
||||
</SettingsCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="revision" className="mt-0">
|
||||
<PanelCard
|
||||
<SettingsCard
|
||||
title="Ревизии"
|
||||
description="Время хранения ревизий в БД"
|
||||
contentClassName="space-y-4 py-4"
|
||||
footer={
|
||||
<LoadingButton onClick={saveRevision} loading={patchMutation.isPending}>
|
||||
<Save />
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
}
|
||||
>
|
||||
<QueryState
|
||||
data={partitioned}
|
||||
isLoading={settingsQ.isLoading}
|
||||
isError={settingsQ.isError}
|
||||
error={settingsQ.error}
|
||||
skeleton={<div className="h-32" />}
|
||||
onRetry={() => settingsQ.refetch()}
|
||||
>
|
||||
{() => (
|
||||
<div className="flex max-w-sm flex-col gap-1.5">
|
||||
<Label htmlFor="revision_retention_minutes">Retention (минуты)</Label>
|
||||
<QueryState
|
||||
data={partitioned}
|
||||
isLoading={settingsQ.isLoading}
|
||||
isError={settingsQ.isError}
|
||||
error={settingsQ.error}
|
||||
skeleton={<div className="h-32" />}
|
||||
onRetry={() => settingsQ.refetch()}
|
||||
>
|
||||
{() => (
|
||||
<SettingsFieldGroup
|
||||
legend="Retention"
|
||||
description="Срок хранения ревизий в минутах."
|
||||
>
|
||||
<SettingsSettingField
|
||||
title="Retention"
|
||||
description="Сколько минут хранить ревизии конфигурации."
|
||||
labelFor="revision_retention_minutes"
|
||||
stacked
|
||||
last
|
||||
>
|
||||
<Input
|
||||
id="revision_retention_minutes"
|
||||
type="number"
|
||||
className="w-full min-w-0"
|
||||
value={revisionForm.revision_retention_minutes ?? ''}
|
||||
onChange={(e) =>
|
||||
setRevisionForm((s) => ({
|
||||
@@ -222,45 +236,46 @@ function TenantSettingsComponent() {
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
revision_retention_minutes
|
||||
</p>
|
||||
<LoadingButton
|
||||
className="mt-2 w-fit"
|
||||
onClick={saveRevision}
|
||||
loading={patchMutation.isPending}
|
||||
>
|
||||
<Save />
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
</div>
|
||||
)}
|
||||
</QueryState>
|
||||
</PanelCard>
|
||||
</SettingsSettingField>
|
||||
</SettingsFieldGroup>
|
||||
)}
|
||||
</QueryState>
|
||||
</SettingsCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="runtime-logs" className="mt-0">
|
||||
<PanelCard
|
||||
<SettingsCard
|
||||
title="Файловые логи"
|
||||
description="Автоматическая очистка логов"
|
||||
contentClassName="space-y-4 py-4"
|
||||
footer={
|
||||
<LoadingButton onClick={saveRuntimeLogs} loading={patchMutation.isPending}>
|
||||
<Save />
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
}
|
||||
>
|
||||
<QueryState
|
||||
data={partitioned}
|
||||
isLoading={settingsQ.isLoading}
|
||||
isError={settingsQ.isError}
|
||||
error={settingsQ.error}
|
||||
skeleton={<div className="h-48" />}
|
||||
onRetry={() => settingsQ.refetch()}
|
||||
>
|
||||
{() => (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<SelectField
|
||||
label="Авто-очистка включена"
|
||||
<QueryState
|
||||
data={partitioned}
|
||||
isLoading={settingsQ.isLoading}
|
||||
isError={settingsQ.isError}
|
||||
error={settingsQ.error}
|
||||
skeleton={<div className="h-48" />}
|
||||
onRetry={() => settingsQ.refetch()}
|
||||
>
|
||||
{() => (
|
||||
<SettingsFieldGroup
|
||||
legend="Авто-очистка"
|
||||
description="Расписание и лимиты файловых логов runtime."
|
||||
>
|
||||
<SettingsSettingField
|
||||
title="Авто-очистка"
|
||||
description="Включить периодическую очистку файловых логов."
|
||||
stacked
|
||||
>
|
||||
<SelectMenu
|
||||
items={[...RUNTIME_LOGS_ENABLED_ITEMS]}
|
||||
value={runtimeLogsForm.runtime_logs_auto_enabled ?? 'false'}
|
||||
placeholder="Выберите"
|
||||
description="runtime_logs_auto_enabled"
|
||||
onValueChange={(v) =>
|
||||
v &&
|
||||
setRuntimeLogsForm((s) => ({
|
||||
@@ -269,45 +284,54 @@ function TenantSettingsComponent() {
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="runtime_logs_max_file_mb">Макс. размер файла (MB)</Label>
|
||||
<Input
|
||||
id="runtime_logs_max_file_mb"
|
||||
type="number"
|
||||
value={runtimeLogsForm.runtime_logs_max_file_mb ?? ''}
|
||||
onChange={(e) =>
|
||||
setRuntimeLogsForm((s) => ({
|
||||
...s,
|
||||
runtime_logs_max_file_mb: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
runtime_logs_max_file_mb
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="runtime_logs_auto_schedule">Расписание (cron)</Label>
|
||||
<Input
|
||||
id="runtime_logs_auto_schedule"
|
||||
value={runtimeLogsForm.runtime_logs_auto_schedule ?? ''}
|
||||
onChange={(e) =>
|
||||
setRuntimeLogsForm((s) => ({
|
||||
...s,
|
||||
runtime_logs_auto_schedule: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
runtime_logs_auto_schedule
|
||||
</p>
|
||||
</div>
|
||||
<SelectField
|
||||
label="Режим очистки"
|
||||
</SettingsSettingField>
|
||||
<SettingsSettingField
|
||||
title="Макс. размер файла"
|
||||
description="Порог в мегабайтах, после которого срабатывает очистка."
|
||||
labelFor="runtime_logs_max_file_mb"
|
||||
stacked
|
||||
>
|
||||
<Input
|
||||
id="runtime_logs_max_file_mb"
|
||||
type="number"
|
||||
className="w-full min-w-0"
|
||||
value={runtimeLogsForm.runtime_logs_max_file_mb ?? ''}
|
||||
onChange={(e) =>
|
||||
setRuntimeLogsForm((s) => ({
|
||||
...s,
|
||||
runtime_logs_max_file_mb: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</SettingsSettingField>
|
||||
<SettingsSettingField
|
||||
title="Расписание"
|
||||
description="Cron-выражение для авто-очистки."
|
||||
labelFor="runtime_logs_auto_schedule"
|
||||
stacked
|
||||
>
|
||||
<Input
|
||||
id="runtime_logs_auto_schedule"
|
||||
className="w-full min-w-0"
|
||||
value={runtimeLogsForm.runtime_logs_auto_schedule ?? ''}
|
||||
onChange={(e) =>
|
||||
setRuntimeLogsForm((s) => ({
|
||||
...s,
|
||||
runtime_logs_auto_schedule: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</SettingsSettingField>
|
||||
<SettingsSettingField
|
||||
title="Режим очистки"
|
||||
description="Обнулить файл или удалить его."
|
||||
stacked
|
||||
last
|
||||
>
|
||||
<SelectMenu
|
||||
items={[...RUNTIME_LOGS_MODE_ITEMS]}
|
||||
value={runtimeLogsForm.runtime_logs_auto_mode ?? ''}
|
||||
placeholder="Выберите"
|
||||
description="runtime_logs_auto_mode"
|
||||
onValueChange={(v) =>
|
||||
v &&
|
||||
setRuntimeLogsForm((s) => ({
|
||||
@@ -316,16 +340,11 @@ function TenantSettingsComponent() {
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<div className="md:col-span-2">
|
||||
<LoadingButton onClick={saveRuntimeLogs} loading={patchMutation.isPending}>
|
||||
<Save />
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</QueryState>
|
||||
</PanelCard>
|
||||
</SettingsSettingField>
|
||||
</SettingsFieldGroup>
|
||||
)}
|
||||
</QueryState>
|
||||
</SettingsCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="additional" className="mt-0">
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
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 { 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 { PageHeader } from '@/components/page-header'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
|
||||
import { SettingsCard } from '@/components/settings/settings-card'
|
||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
||||
import { sessionCanManageApiKeys } from '@/lib/auth'
|
||||
import { authSessionQueryOptions } from '@/queries/auth'
|
||||
@@ -38,6 +46,7 @@ function AccessComponent() {
|
||||
const kpiItems: KpiStatItem[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'keys-total',
|
||||
label: 'Всего ключей',
|
||||
value: keys.length,
|
||||
icon: <KeyRound className="size-4" />,
|
||||
@@ -48,6 +57,7 @@ function AccessComponent() {
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'keys-active',
|
||||
label: 'Активных',
|
||||
value: activeCount,
|
||||
icon: <ShieldCheck className="size-4" />,
|
||||
@@ -58,6 +68,7 @@ function AccessComponent() {
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'keys-revoked',
|
||||
label: 'Отозванных',
|
||||
value: revokedCount,
|
||||
icon: <ShieldOff className="size-4" />,
|
||||
@@ -93,7 +104,7 @@ function AccessComponent() {
|
||||
})()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex min-w-0 flex-col gap-6">
|
||||
<PageHeader
|
||||
title="Права доступа"
|
||||
description="API-ключи control plane и текущая сессия Bearer-токена."
|
||||
@@ -108,47 +119,55 @@ function AccessComponent() {
|
||||
/>
|
||||
|
||||
{session ? (
|
||||
<PanelCard
|
||||
<SettingsCard
|
||||
title="Текущая сессия"
|
||||
description="Tenant и права текущего Bearer (API-ключ или portal JWT)."
|
||||
contentClassName="grid gap-3 py-4 text-sm sm:grid-cols-2"
|
||||
>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Tenant</p>
|
||||
<p className="break-all font-mono text-xs">{session.tenant_id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Доступ</p>
|
||||
<p className="font-mono text-xs">{sessionAccessLabel}</p>
|
||||
</div>
|
||||
{sessionKindLabel ? (
|
||||
<div>
|
||||
<p className="text-muted-foreground">Тип</p>
|
||||
<p className="font-mono text-xs">{sessionKindLabel}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{session.email ? (
|
||||
<div>
|
||||
<p className="text-muted-foreground">Email</p>
|
||||
<p className="break-all text-xs">{session.email}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</PanelCard>
|
||||
<ItemGroup className="gap-0">
|
||||
<Item className="min-h-0 min-w-0 items-start gap-4 px-5 py-3.5">
|
||||
<ItemMedia className="self-start">
|
||||
<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">
|
||||
<MonitorIcon aria-hidden="true" />
|
||||
</Item>
|
||||
</ItemMedia>
|
||||
<ItemContent className="min-w-0 gap-3">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<ItemTitle className="leading-5">Этот браузер</ItemTitle>
|
||||
{sessionKindLabel ? (
|
||||
<Badge variant="info-light" size="sm">
|
||||
{sessionKindLabel}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<ItemDescription className="flex min-w-0 flex-col gap-1 leading-5">
|
||||
<span className="break-all">
|
||||
Tenant:{' '}
|
||||
<code className="text-foreground font-mono text-xs">{session.tenant_id}</code>
|
||||
</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">
|
||||
Не удалось определить сессию. Укажите токен в{' '}
|
||||
<Link
|
||||
to="/settings"
|
||||
search={{ tab: 'connection' }}
|
||||
className="text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
настройках
|
||||
</Link>{' '}
|
||||
(для dev-окружения — <code className="text-xs">dev</code> при включённом demo-seed).
|
||||
{sessionQuery.isError && sessionQuery.error instanceof Error ? (
|
||||
<span className="mt-2 block text-destructive">{sessionQuery.error.message}</span>
|
||||
) : null}
|
||||
</PanelCard>
|
||||
<SettingsCard title="Текущая сессия">
|
||||
<p className="text-muted-foreground px-5 py-4 text-sm">
|
||||
Не удалось определить сессию. Укажите токен в{' '}
|
||||
<Link
|
||||
to="/settings"
|
||||
search={{ tab: 'connection' }}
|
||||
className="text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
настройках
|
||||
</Link>{' '}
|
||||
(для dev-окружения — <code className="text-xs">dev</code> при включённом demo-seed).
|
||||
{sessionQuery.isError && sessionQuery.error instanceof Error ? (
|
||||
<span className="text-destructive mt-2 block">{sessionQuery.error.message}</span>
|
||||
) : null}
|
||||
</p>
|
||||
</SettingsCard>
|
||||
)}
|
||||
|
||||
{canManageKeys ? (
|
||||
@@ -167,11 +186,13 @@ function AccessComponent() {
|
||||
/>
|
||||
</>
|
||||
) : session ? (
|
||||
<PanelCard contentClassName="py-4 text-sm text-muted-foreground">
|
||||
Управление API-ключами доступно роли <strong>operator</strong> (API-ключ) или portal JWT
|
||||
с <strong>is_admin</strong> / правом <code className="text-xs">bgp:access:admin</code>.
|
||||
Текущий доступ: <span className="font-mono">{sessionAccessLabel}</span>.
|
||||
</PanelCard>
|
||||
<SettingsCard title="API-ключи">
|
||||
<p className="text-muted-foreground px-5 py-4 text-sm break-words">
|
||||
Управление API-ключами доступно роли <strong>operator</strong> (API-ключ) или portal JWT
|
||||
с <strong>is_admin</strong> / правом <code className="text-xs">bgp:access:admin</code>.
|
||||
Текущий доступ: <span className="font-mono">{sessionAccessLabel}</span>.
|
||||
</p>
|
||||
</SettingsCard>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -37,7 +37,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/stats-12
|
||||
*/
|
||||
@@ -99,33 +101,31 @@ function DashboardComponent() {
|
||||
afterKpi={showQuickActions ? <DashboardQuickLinks /> : null}
|
||||
charts={
|
||||
<>
|
||||
<div className={dashboardMainSidebarClassName}>
|
||||
<div className="xl:col-span-8">
|
||||
<DashboardModulesGrid modules={modules} isLoading={refreshing} />
|
||||
</div>
|
||||
<div className="xl:col-span-4">
|
||||
<DashboardActivityTimeline
|
||||
jobs={jobs}
|
||||
revisions={revisions}
|
||||
peers={peers}
|
||||
speakers={speakers}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<DashboardModulesGrid modules={modules} isLoading={refreshing} />
|
||||
</div>
|
||||
<div className={chartPanelGridClassName}>
|
||||
<DashboardNetworkHealth peers={peers} speakers={speakers} jobs={jobs} />
|
||||
<DashboardOperationsBreakdown jobs={jobs} modules={modules} />
|
||||
<div className={dashboardMainSidebarClassName}>
|
||||
<DashboardActivityTimeline
|
||||
jobs={jobs}
|
||||
revisions={revisions}
|
||||
peers={peers}
|
||||
speakers={speakers}
|
||||
/>
|
||||
<div className="grid min-w-0 gap-4">
|
||||
<DashboardNetworkHealth peers={peers} speakers={speakers} jobs={jobs} />
|
||||
<DashboardOperationsBreakdown jobs={jobs} modules={modules} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
queueTitle="Недавняя активность"
|
||||
queueDescription="Задачи и ревизии плоскости управления"
|
||||
queueTitle="Задачи и ревизии"
|
||||
queueDescription="Последние фоновые операции и история конфигураций"
|
||||
queue={
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className={chartPanelGridClassName}>
|
||||
<DashboardFramePanel
|
||||
title="Недавние задачи"
|
||||
description="Последние фоновые операции"
|
||||
className="h-full"
|
||||
className="h-full min-w-0"
|
||||
>
|
||||
{activityLoading ? (
|
||||
<Skeleton className="m-4 h-24 w-auto" />
|
||||
@@ -136,7 +136,7 @@ function DashboardComponent() {
|
||||
<DashboardFramePanel
|
||||
title="Последние ревизии"
|
||||
description="История конфигураций"
|
||||
className="h-full"
|
||||
className="h-full min-w-0"
|
||||
>
|
||||
{activityLoading ? (
|
||||
<Skeleton className="m-4 h-24 w-auto" />
|
||||
|
||||
Reference in New Issue
Block a user