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