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
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:
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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in new issue
Block a user