Files
EvoBGP/apps/web/src/routes/_auth/modules/index.tsx
T
DenozordecandClaude Sonnet 5.5 aba18e15b7
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
feat(web): pages and tables as on shared mockup boards
ResourcePage и FrameDataGrid получили раскладку досок: заголовок Frame с поиском и фильтрами, линейные виды со счётчиками, подвал «Показано N из M». Страница модулей — KPI-полоса, кнопка создания в шапке, вкладка. Операции: действия применения и перезагрузки BIRD в шапке, баннер при ошибках задач. Сеть: баннер пиров на одобрение. Кнопки шапок — стандартной высоты.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 21:59:12 +07:00

127 lines
4.9 KiB
TypeScript

import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { BoxesIcon, CircleAlertIcon, PlusIcon, PowerIcon, RefreshCwIcon } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
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'
import { authSessionQueryOptions } from '@/queries/auth'
import {
directoriesCommunitiesQueryOptions,
directoriesDohQueryOptions,
} from '@/queries/directories'
import { modulesListQueryOptions } from '@/queries/modules'
function parseCreateFlag(value: unknown): boolean {
return value === true || value === '1' || value === 'true'
}
export const Route = createFileRoute('/_auth/modules/')({
component: ModulesListComponent,
validateSearch: (search: Record<string, unknown>): { create?: boolean } => {
if (parseCreateFlag(search.create)) return { create: true }
return {}
},
})
function ModulesListComponent() {
const { create } = Route.useSearch()
const navigate = Route.useNavigate()
const query = useQuery(modulesListQueryOptions())
const sessionQ = useQuery(authSessionQueryOptions())
const canWrite = sessionCanWriteModules(sessionQ.data)
const communitiesQ = useQuery(directoriesCommunitiesQueryOptions())
const dohQ = useQuery(directoriesDohQueryOptions())
const createOpen = canWrite && create === true
function setCreateOpen(open: boolean) {
void navigate({ search: open ? { create: true } : {}, replace: true })
}
return (
<div className="flex flex-col gap-6">
<PageHeader
title="Модули"
description="Маршрутные списки: AS, CDN, домены, IP-диапазоны"
actions={
<>
<Button variant="outline" onClick={() => query.refetch()} disabled={query.isFetching}>
<RefreshCwIcon className={query.isFetching ? 'animate-spin' : ''} />
Обновить
</Button>
{canWrite ? (
<Button onClick={() => setCreateOpen(true)}>
<PlusIcon />
Создать модуль
</Button>
) : null}
</>
}
/>
<QueryState
data={query.data?.items}
isLoading={query.isLoading}
isError={query.isError}
error={query.error}
empty={query.data?.items?.length === 0}
emptyContent={
<ProjectsEmptyState
canCreate={canWrite}
onCreate={() => setCreateOpen(true)}
/>
}
skeleton={<TableSkeleton rows={6} cols={5} />}
onRetry={() => query.refetch()}
>
{(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
open={createOpen}
onOpenChange={setCreateOpen}
communities={communitiesQ.data?.items ?? []}
dohProfiles={dohQ.data?.items ?? []}
onCreated={(mod) => {
void navigate({ to: '/modules/$moduleId', params: { moduleId: mod.id } })
}}
/>
</div>
)
}