feat(web): pages and tables as on shared mockup boards
quality / changes (push) Successful in 9s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 23s
quality / web (push) Successful in 1m22s
CD / quality (push) Successful in 1m57s
CD / publish (push) Successful in 3m31s

ResourcePage и FrameDataGrid получили раскладку досок: заголовок Frame с поиском и фильтрами, линейные виды со счётчиками, подвал «Показано N из M». Страница модулей — KPI-полоса, кнопка создания в шапке, вкладка. Операции: действия применения и перезагрузки BIRD в шапке, баннер при ошибках задач. Сеть: баннер пиров на одобрение. Кнопки шапок — стандартной высоты.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-23 21:59:12 +07:00
1 parent 53420b49a1
commit aba18e15b7
13 files changed
+185 -160

No files matched your search

@@ -17,8 +17,8 @@ import type { ModuleRow } from '@/types/api'
const MODULE_TABS = [
{ id: 'all', label: 'Все' },
{ id: 'enabled', label: 'Вкл' },
{ id: 'disabled', label: 'Выкл' },
{ id: 'enabled', label: 'Включённые' },
{ id: 'disabled', label: 'Выключенные' },
]
const TYPE_OPTIONS = [
@@ -66,7 +66,7 @@ function tabFilter(item: ModuleRow, tabId: string): boolean {
export function ModulesListGrid({
items,
isLoading = false,
title = 'Все модули',
title = 'Маршрутные модули',
actions,
}: {
items: ModuleRow[]
@@ -130,7 +130,7 @@ export function ModulesListGrid({
return (
<ResourcePage
title={title}
description="Маршрутные списки"
description="Поиск по имени · фильтр по типу · клик открывает модуль"
tabs={MODULE_TABS}
tabFilter={tabFilter}
filterFields={filterFields}
+3 -3
View File
@@ -382,7 +382,7 @@ export function CodeBlock({ label, children }: { label: string; children: string
return (
<div className="flex flex-col gap-1">
<span className="text-muted-foreground text-xs">{label}</span>
<pre className="bg-muted/40 overflow-x-auto rounded-md border p-3 font-mono text-xs leading-relaxed">{children}</pre>
<pre className="bg-muted/40 overflow-x-auto [scrollbar-width:none] rounded-md border p-3 font-mono text-xs leading-relaxed">{children}</pre>
</div>
)
}
@@ -401,7 +401,7 @@ export function Segmented({
}) {
return (
<Tabs value={String(value)} onValueChange={(v) => onChange(Number(v))} aria-label={label} className="max-w-full">
<TabsList className="max-w-full justify-start overflow-x-auto">
<TabsList className="max-w-full justify-start overflow-x-auto [scrollbar-width:none]">
{options.map((o, i) => (
<TabsTrigger key={o} value={String(i)}>
{o}
@@ -424,7 +424,7 @@ export function PendingChip({ mark }: { mark: 'new' | 'dirty' | 'deleted' }) {
/** Вкладки страницы как на досках: линия-подчёркивание, число — мелким индексом. */
export function PageTabsList({ children }: { children: ReactNode }) {
return (
<TabsList variant="line" className="h-10! w-full justify-start gap-0.5 overflow-x-auto">
<TabsList variant="line" className="h-10! w-full justify-start gap-0.5 overflow-x-auto [scrollbar-width:none]">
{children}
</TabsList>
)
@@ -19,6 +19,7 @@ import {
import { Columns3Icon } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { Separator } from '@evobgp/ui/components/separator'
import { cn } from '@evobgp/ui/lib/utils'
import {
DataGrid,
@@ -40,6 +41,7 @@ import { DataGridTableVirtual } from '@/components/reui/data-grid/data-grid-tabl
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
@@ -315,7 +317,13 @@ function FrameDataGridBody<TData extends object>({
)}
{showPagination ? (
<>
<DataGridPagination className="px-5 py-3" sizes={[10, 25, 50]} />
<Separator />
<FrameFooter className="flex-wrap justify-between gap-2">
<span className="text-muted-foreground text-xs tabular-nums">
Показано {table.getRowModel().rows.length} из {data.length}
</span>
<DataGridPagination sizes={[10, 25, 50]} />
</FrameFooter>
</>
) : null}
</DataGrid>
@@ -23,12 +23,12 @@ import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import { DataGridTableRowExpand } from '@/components/reui/data-grid/data-grid-table'
import { Filters } from '@/components/reui/filters/filters'
import { SegmentedControl } from '@/components/portal/data-page'
import { flattenFilterConditions } from '@/components/reui/filters/filters-query'
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
@@ -40,7 +40,9 @@ import {
InputGroupAddon,
InputGroupInput,
} from '@evobgp/ui/components/input-group'
import { Separator } from '@evobgp/ui/components/separator'
import { Skeleton } from '@evobgp/ui/components/skeleton'
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import { DATA_GRID_I18N_RU } from '@/lib/reui-i18n-ru'
import { FILTERS_LABELS_RU, FILTERS_OPERATOR_LABELS_RU } from '@/lib/filters-i18n'
import {
@@ -538,92 +540,75 @@ function ResourcePageFiltered<T extends object>({
tableClassNames={kitDataGridTableClassNames}
>
<Frame dense variant="default" spacing="sm" className="w-full">
{!hideHeader ? (
<FrameHeader className="flex-row items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-px">
<FrameHeader className="flex-row flex-wrap items-center gap-x-3 gap-y-2">
{!hideHeader ? (
<div className="flex min-w-0 flex-1 basis-52 flex-col gap-0.5">
<FrameTitle className="text-balance">{title}</FrameTitle>
{description ? (
<FrameDescription className="flex flex-wrap items-center gap-1.5 text-xs text-pretty">
<span>{description}</span>
<span className="bg-input size-1 shrink-0 rounded-full" aria-hidden="true" />
<span className="tabular-nums">{filteredData.length} записей</span>
{selectedCount > 0 ? (
<>
<span className="bg-input size-1 shrink-0 rounded-full" aria-hidden="true" />
<span>{selectedCount} выбрано</span>
</>
) : null}
</FrameDescription>
<FrameDescription className="text-xs text-pretty">{description}</FrameDescription>
) : null}
</div>
{headerActions ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{headerActions}
</div>
) : null}
</FrameHeader>
) : null}
) : (
<div className="flex-1" />
)}
{headerActions ? <div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{headerActions}</div> : null}
{toolbarExtra}
{showFiltersPopover ? (
<Filters
query={extraQuery}
fields={extraFilterFields}
onQueryChange={handleFiltersChange}
variant="advanced"
advancedMode="popover"
size="default"
labels={FILTERS_LABELS_RU}
operatorLabels={FILTERS_OPERATOR_LABELS_RU}
trigger={
<Button type="button" variant="outline" aria-label="Фильтры">
<FilterIcon className="size-4" aria-hidden="true" />
Фильтры
{extraActiveCount > 0 ? (
<Badge size="xs" variant="secondary" radius="full">
{extraActiveCount}
</Badge>
) : null}
</Button>
}
/>
) : null}
{showSearch && primaryTextField ? (
<InputGroup className="w-[220px] max-w-full">
<InputGroupAddon>
<SearchIcon aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
value={searchText}
onChange={(event) => handleSearchChange(event.target.value)}
placeholder="Поиск"
aria-label={primaryTextField.label || 'Поиск'}
/>
</InputGroup>
) : null}
{hasDirtyFilters ? (
<Button type="button" variant="outline" onClick={handleClear}>
<FilterXIcon className="size-4" aria-hidden="true" />
Сбросить
</Button>
) : null}
</FrameHeader>
<FramePanel className="p-0 shadow-none!">
<div className="flex flex-wrap items-center gap-3 border-b px-5 py-3">
{countedTabs.length > 0 ? (
<SegmentedControl
value={activeTab}
onChange={handleTabChange}
label="Фильтр по статусу"
options={countedTabs.map((t) => ({ value: t.id, label: t.label, count: t.count }))}
/>
) : null}
{showFiltersPopover ? (
<Filters
query={extraQuery}
fields={extraFilterFields}
onQueryChange={handleFiltersChange}
variant="advanced"
advancedMode="popover"
size="default"
labels={FILTERS_LABELS_RU}
operatorLabels={FILTERS_OPERATOR_LABELS_RU}
trigger={
<Button type="button" variant="outline" aria-label="Фильтры">
<FilterIcon className="size-4" aria-hidden="true" />
Фильтры
{extraActiveCount > 0 ? (
<Badge size="xs" variant="secondary" radius="full">
{extraActiveCount}
</Badge>
) : null}
</Button>
}
/>
) : null}
{showSearch && primaryTextField ? (
<InputGroup className="max-w-sm min-w-[220px]">
<InputGroupAddon>
<SearchIcon className="size-3.5" aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
value={searchText}
onChange={(event) => handleSearchChange(event.target.value)}
placeholder={primaryTextField.placeholder ?? 'Поиск…'}
aria-label={primaryTextField.label || 'Поиск'}
/>
</InputGroup>
) : null}
<span className="text-muted-foreground ml-auto text-sm tabular-nums">{filteredData.length} записей</span>
{toolbarExtra}
{selectedCount > 0 ? (
<Badge size="sm" variant="secondary">
{selectedCount} выбрано
</Badge>
) : null}
{hasDirtyFilters ? (
<Button type="button" variant="outline" onClick={handleClear}>
<FilterXIcon className="size-4" aria-hidden="true" />
Сбросить
</Button>
) : null}
</div>
{countedTabs.length > 0 ? (
<Tabs value={activeTab} onValueChange={handleTabChange}>
<TabsList variant="line" aria-label="Сохранённые виды" className="h-10! w-full justify-start gap-4 overflow-x-auto [scrollbar-width:none] px-4">
{countedTabs.map((t) => (
<TabsTrigger key={t.id} value={t.id} className="flex-none px-1 text-[13px]">
{t.label} · {t.count}
</TabsTrigger>
))}
</TabsList>
</Tabs>
) : null}
{virtualization ? (
<DataGridScrollArea orientation="vertical" style={{ height }}>
@@ -633,7 +618,14 @@ function ResourcePageFiltered<T extends object>({
<DataGridScrollArea>{tableNode}</DataGridScrollArea>
)}
<DataGridPagination className="px-5 py-3" sizes={[5, 10, 20, 50]} />
<Separator />
<FrameFooter className="flex-wrap justify-between gap-2">
<span className="text-muted-foreground text-xs tabular-nums">
{selectedCount > 0 ? `Выбрано ${selectedCount} · ` : ''}Показано {table.getRowModel().rows.length} из {filteredData.length}
</span>
<DataGridPagination sizes={[5, 10, 20, 50]} />
</FrameFooter>
</FramePanel>
</Frame>
</DataGrid>
+1 -1
View File
@@ -46,7 +46,7 @@ export function SegmentedTabs({
orientation="horizontal"
className={cn('w-full min-w-0 gap-4', className)}
>
<TabsList variant="line" className={cn('h-10! w-full min-w-0 justify-start gap-0.5 overflow-x-auto', listClassName)}>
<TabsList variant="line" className={cn('h-10! w-full min-w-0 justify-start gap-0.5 overflow-x-auto [scrollbar-width:none]', listClassName)}>
{items.map((item) => (
<TabsTrigger key={item.value} value={item.value} className="flex-none shrink-0 gap-1.5 px-4 text-sm">
{item.icon}
+1 -1
View File
@@ -109,7 +109,7 @@ function AccessComponent() {
description="API-ключи плоскости управления и текущая сессия."
actions={
canManageKeys ? (
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
<Button variant="outline" onClick={refetchAll} disabled={refreshing}>
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
Обновить
</Button>
+3 -3
View File
@@ -97,14 +97,14 @@ function DirectoriesComponent() {
}
const addCommunityButton = canWrite ? (
<Button size="sm" type="button" onClick={openCreateCommunity}>
<Button type="button" onClick={openCreateCommunity}>
<Plus />
Добавить
</Button>
) : null
const addDohButton = canWrite ? (
<Button size="sm" type="button" onClick={openCreateDoh}>
<Button type="button" onClick={openCreateDoh}>
<Plus />
Добавить
</Button>
@@ -118,7 +118,7 @@ function DirectoriesComponent() {
actions={
<Button
variant="outline"
size="sm"
onClick={() => {
void communitiesQ.refetch()
void dohQ.refetch()
@@ -69,19 +69,19 @@ function ModuleDetailComponent() {
const headerActions = (
<>
<Button variant="outline" size="sm" render={<Link to="/modules" />}>
<Button variant="outline" render={<Link to="/modules" />}>
<ArrowLeft />
К списку
</Button>
{canWrite && mod ? (
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
<Button variant="outline" onClick={() => setEditOpen(true)}>
<Pencil />
Редактировать
</Button>
) : null}
<Button
variant="outline"
size="sm"
onClick={() => void refreshAll()}
disabled={detail.isFetching}
>
+38 -22
View File
@@ -1,6 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { Plus, RefreshCw } from 'lucide-react'
import { BoxesIcon, CircleAlertIcon, PlusIcon, PowerIcon, RefreshCwIcon } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
@@ -8,6 +8,10 @@ import { ProjectsEmptyState } from '@/components/patterns/projects-empty-state'
import { ModuleCreateDialog } from '@/components/modules/module-create-dialog'
import { ModulesListGrid } from '@/components/modules/modules-list-grid'
import { PageHeader } from '@/components/page-header'
import { PageTab, PageTabsList } from '@/components/portal/bits'
import { kpi } from '@/components/portal/kpi'
import { KpiStrip } from '@/components/portal/kpi-strip'
import { Tabs, TabsContent } from '@evobgp/ui/components/tabs'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { sessionCanWriteModules } from '@/lib/auth'
@@ -52,15 +56,16 @@ function ModulesListComponent() {
description="Маршрутные списки: AS, CDN, домены, IP-диапазоны"
actions={
<>
<Button
variant="outline"
size="sm"
onClick={() => query.refetch()}
disabled={query.isFetching}
>
<RefreshCw className={query.isFetching ? 'animate-spin' : ''} />
<Button variant="outline" onClick={() => query.refetch()} disabled={query.isFetching}>
<RefreshCwIcon className={query.isFetching ? 'animate-spin' : ''} />
Обновить
</Button>
{canWrite ? (
<Button onClick={() => setCreateOpen(true)}>
<PlusIcon />
Создать модуль
</Button>
) : null}
</>
}
/>
@@ -80,20 +85,31 @@ function ModulesListComponent() {
skeleton={<TableSkeleton rows={6} cols={5} />}
onRetry={() => query.refetch()}
>
{(items) => (
<ModulesListGrid
items={items}
isLoading={query.isFetching && !query.isLoading}
actions={
canWrite ? (
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus />
Создать
</Button>
) : undefined
}
/>
)}
{(items) => {
const enabled = items.filter((m) => m.enabled !== false).length
const never = items.filter((m) => !m.last_refreshed_at).length
return (
<>
<KpiStrip
aria-label="Показатели"
items={[
kpi('all', 'Модулей', String(items.length), { icon: BoxesIcon, badge: 'в арендаторе' }),
kpi('on', 'Включено', String(enabled), { icon: PowerIcon, tone: 'ok', badge: 'участвуют в ревизии' }),
kpi('off', 'Выключено', String(items.length - enabled), { icon: PowerIcon, badge: 'не в ревизии' }),
kpi('never', 'Не обновлялись', String(never), { icon: CircleAlertIcon, tone: never ? 'wn' : 'neutral', badge: 'нет первого refresh' }),
]}
/>
<Tabs defaultValue="list" className="min-w-0">
<PageTabsList>
<PageTab value="list" label="Модули" count={items.length} />
</PageTabsList>
<TabsContent value="list" className="min-w-0">
<ModulesListGrid items={items} isLoading={query.isFetching && !query.isLoading} />
</TabsContent>
</Tabs>
</>
)
}}
</QueryState>
<ModuleCreateDialog
+1 -1
View File
@@ -66,7 +66,7 @@ function MonitoringComponent() {
: ''
}`}
actions={
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
<Button variant="outline" onClick={refetchAll} disabled={refreshing}>
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
Обновить
</Button>
+17 -1
View File
@@ -9,6 +9,7 @@ import { NetworkKpi } from '@/components/network/network-kpi'
import { NetworkPeersCard } from '@/components/network/network-peers-card'
import { NetworkSpeakersCard } from '@/components/network/network-speakers-card'
import { PageHeader } from '@/components/page-header'
import { SignalAlert } from '@/components/portal/bits'
import {
networkBirdQueryOptions,
networkDiscoveredPeersQueryOptions,
@@ -73,13 +74,28 @@ function NetworkComponent() {
title="Сеть"
description="BGP-пиры, автообнаружение и спикеры"
actions={
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
<Button variant="outline" onClick={refetchAll} disabled={refreshing}>
<RefreshCw className={refreshing ? 'animate-spin' : undefined} />
Обновить
</Button>
}
/>
{discovered.length > 0 ? (
<SignalAlert
tone="wn"
title={`Есть пиры на одобрение: ${discovered.length}.`}
action={
<Button variant="outline" onClick={() => navigate({ search: { tab: 'discovered' } })}>
Показать
</Button>
}
>
{discovered[0]?.neighbor}
{discovered[0]?.remote_asn ? ` · AS${discovered[0].remote_asn}` : ''} обнаружен автоматически · после одобрения пир попадёт в конфигурацию BIRD.
</SignalAlert>
) : null}
<NetworkKpi
peers={peers}
speakers={speakers}
+30 -37
View File
@@ -10,9 +10,7 @@ import { SegmentedTabs, TabsContent } from '@/components/segmented-tabs'
import {
FrameSection,
KpiStatGrid,
QuickActionGrid,
type KpiStatItem,
type QuickActionItem,
} from '@/components/reui-kit'
import { Badge } from '@/components/reui/badge'
import {
@@ -27,6 +25,7 @@ import { SelectMenu } from '@/components/select-field'
import { OperationsJobsCard } from '@/components/operations/operations-jobs-card'
import { OperationsRevisionsGrid } from '@/components/operations/operations-revisions-grid'
import { PageHeader } from '@/components/page-header'
import { SignalAlert } from '@/components/portal/bits'
import { QueryState } from '@/components/query-state'
import { ConfirmDialog } from '@/components/confirm-dialog'
@@ -111,6 +110,7 @@ function OperationsComponent() {
const revisions = useMemo(() => revisionsQ.data?.items ?? [], [revisionsQ.data?.items])
const jobs = useMemo(() => jobsQ.data?.items ?? [], [jobsQ.data?.items])
const failedJobs = jobs.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
const nameById = moduleNameById(modulesQ.data?.items ?? [])
const refreshing = revisionsQ.isFetching || jobsQ.isFetching
@@ -157,51 +157,44 @@ function OperationsComponent() {
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось перезагрузить'),
})
const quickActions: QuickActionItem[] = useMemo(
() => [
{
id: 'apply',
title: 'Применить',
description: 'Деплой текущей ревизии на все BIRD-агенты.',
icon: <Play aria-hidden />,
iconClassName: 'text-primary',
badge: 'Выполнить',
disabled: applyMutation.isPending || revisions.length === 0,
onClick: () => setApplyOpen(true),
},
{
id: 'bird-reload',
title: 'Перезагрузка BIRD',
description: 'Выполнить birdc configure на локальном демоне.',
icon: <RotateCcw aria-hidden />,
iconClassName: 'text-warning',
badge: 'Выполнить',
disabled: birdReloadMutation.isPending,
onClick: () => setReloadOpen(true),
},
],
[applyMutation.isPending, birdReloadMutation.isPending, revisions.length],
)
return (
<div className="flex flex-col gap-4 md:gap-6">
<PageHeader
title="Ревизии и операции"
description="Деплой конфигурации, управление ревизиями и задачами"
actions={
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
Обновить
</Button>
<>
<Button variant="outline" onClick={refetchAll} disabled={refreshing}>
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
Обновить
</Button>
<Button variant="outline" disabled={birdReloadMutation.isPending} onClick={() => setReloadOpen(true)}>
<RotateCcw />
Перезагрузить BIRD
</Button>
<Button disabled={applyMutation.isPending || revisions.length === 0} onClick={() => setApplyOpen(true)}>
<Play />
Применить на всех спикерах
</Button>
</>
}
/>
<KpiStatGrid items={kpiItems} isLoading={kpiLoading} skeletonCount={3} />
{failedJobs.length > 0 ? (
<SignalAlert
tone="er"
title={`Задач с ошибкой: ${failedJobs.length}.`}
action={
<Button variant="outline" onClick={() => navigate({ search: { tab: 'jobs' } })}>
Показать
</Button>
}
>
{failedJobs[0]?.error ?? 'Проверьте вкладку «Задачи» и журнал применения.'}
</SignalAlert>
) : null}
<QuickActionGrid
actions={quickActions}
description="Деплой конфигурации и управление локальным BIRD"
/>
<KpiStatGrid items={kpiItems} isLoading={kpiLoading} skeletonCount={3} />
<ConfirmDialog
open={applyOpen}
+1 -1
View File
@@ -91,7 +91,7 @@ function ScheduleComponent() {
actions={
<Button
variant="outline"
size="sm"
onClick={() => {
void modulesQ.refetch()
void jobsQ.refetch()