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]>
127 lines
4.9 KiB
TypeScript
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>
|
|
)
|
|
}
|