Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
632adaa63f | ||
|
|
cc80858952 | ||
|
|
b8dbf15254 | ||
|
|
f45b2bed90 | ||
|
|
b13c233679 |
@@ -10,4 +10,3 @@ export { DashboardOperationsFlowCard } from './dashboard-operations-flow-card'
|
|||||||
export { DashboardPlatformCard } from './dashboard-platform-card'
|
export { DashboardPlatformCard } from './dashboard-platform-card'
|
||||||
export { MonitoringHealthCard } from './monitoring-health-card'
|
export { MonitoringHealthCard } from './monitoring-health-card'
|
||||||
export { NetworkOverviewAnalyticsCard } from './network-overview-analytics-card'
|
export { NetworkOverviewAnalyticsCard } from './network-overview-analytics-card'
|
||||||
export { OperationsAnalyticsCard } from './operations-analytics-card'
|
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell'
|
|
||||||
import { AnalyticsKpiRow } from '@/components/analytics/analytics-kpi-row'
|
|
||||||
import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric'
|
|
||||||
import { jobStatusBreakdown } from '@/lib/metrics'
|
|
||||||
import type { JobRow, RevisionRow } from '@/types/api'
|
|
||||||
|
|
||||||
export function OperationsAnalyticsCard({
|
|
||||||
jobs,
|
|
||||||
revisions,
|
|
||||||
loading,
|
|
||||||
}: {
|
|
||||||
jobs: JobRow[]
|
|
||||||
revisions: RevisionRow[]
|
|
||||||
loading?: boolean
|
|
||||||
}) {
|
|
||||||
const running = jobs.filter((j) => ['running', 'queued'].includes(j.status.toLowerCase())).length
|
|
||||||
const failed = jobs.filter((j) =>
|
|
||||||
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
|
||||||
).length
|
|
||||||
const slices = jobStatusBreakdown(jobs)
|
|
||||||
const total = slices.reduce((sum, slice) => sum + slice.count, 0)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AnalyticsCardShell
|
|
||||||
title="Операции и задачи"
|
|
||||||
description="Статистика ревизий и фоновых jobs"
|
|
||||||
info="Данные из GET /v1/jobs и /v1/revisions."
|
|
||||||
>
|
|
||||||
<AnalyticsKpiRow
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
label: 'Ревизий',
|
|
||||||
value: loading ? '—' : String(revisions.length),
|
|
||||||
delta: { direction: 'neutral', label: 'в выборке', tone: 'muted' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Активных задач',
|
|
||||||
value: loading ? '—' : String(running),
|
|
||||||
delta: {
|
|
||||||
direction: running > 0 ? 'up' : 'neutral',
|
|
||||||
label: running > 0 ? 'выполняются' : 'очередь пуста',
|
|
||||||
tone: (running > 0 ? 'warning' : 'muted') as 'warning' | 'muted',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'С ошибкой',
|
|
||||||
value: loading ? '—' : String(failed),
|
|
||||||
delta: {
|
|
||||||
direction: failed > 0 ? 'down' : 'up',
|
|
||||||
label: failed > 0 ? 'требуют внимания' : 'в норме',
|
|
||||||
tone: failed > 0 ? 'destructive' : 'success',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
|
||||||
Загрузка…
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ChartDonutMetric slices={slices} centerLabel="Задачи" centerValue={total} />
|
|
||||||
)}
|
|
||||||
</AnalyticsCardShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import { Field, FieldLabel } from '@evobgp/ui/components/field'
|
||||||
|
|
||||||
|
import { CommunitySelect } from '@/components/modules/community-select'
|
||||||
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
AlertDescription,
|
||||||
|
AlertTitle,
|
||||||
|
} from '@/components/reui/alert'
|
||||||
|
import { SelectMenu } from '@/components/select-field'
|
||||||
|
import { ApiError, apiMutate } from '@/lib/api-client'
|
||||||
|
import type {
|
||||||
|
BgpCommunity,
|
||||||
|
LookupQueryKind,
|
||||||
|
LookupResponse,
|
||||||
|
ModuleRow,
|
||||||
|
} from '@/types/api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lookup wizard step 3 — module + community (bare content for single Frame).
|
||||||
|
* @see https://reui.io/preview/base/wizard-2
|
||||||
|
*/
|
||||||
|
|
||||||
|
function hostPrefixFromIp(ip: string): string {
|
||||||
|
return ip.includes(':') ? `${ip}/128` : `${ip}/32`
|
||||||
|
}
|
||||||
|
|
||||||
|
function moduleTypeForKind(kind: LookupQueryKind): ModuleRow['type'] {
|
||||||
|
return kind === 'domain' ? 'DOMAINS' : 'IP_RANGES'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LookupAddStep({
|
||||||
|
data,
|
||||||
|
modules,
|
||||||
|
communities,
|
||||||
|
onCancel,
|
||||||
|
onAdded,
|
||||||
|
}: {
|
||||||
|
data: LookupResponse
|
||||||
|
modules: ModuleRow[]
|
||||||
|
communities: BgpCommunity[]
|
||||||
|
onCancel: () => void
|
||||||
|
onAdded: () => void | Promise<void>
|
||||||
|
}) {
|
||||||
|
const wantedType = moduleTypeForKind(data.query_kind)
|
||||||
|
const eligible = useMemo(
|
||||||
|
() => modules.filter((m) => m.type === wantedType),
|
||||||
|
[modules, wantedType],
|
||||||
|
)
|
||||||
|
|
||||||
|
const [moduleId, setModuleId] = useState('')
|
||||||
|
const [communityId, setCommunityId] = useState<string | null>(null)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (eligible.length === 0) {
|
||||||
|
setModuleId('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setModuleId((prev) =>
|
||||||
|
prev && eligible.some((m) => m.id === prev) ? prev : eligible[0]!.id,
|
||||||
|
)
|
||||||
|
}, [eligible])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mod = eligible.find((m) => m.id === moduleId)
|
||||||
|
if (!mod) {
|
||||||
|
setCommunityId(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCommunityId(mod.default_community_id ?? null)
|
||||||
|
}, [moduleId, eligible])
|
||||||
|
|
||||||
|
const moduleItems = useMemo(
|
||||||
|
() =>
|
||||||
|
eligible.map((m) => ({
|
||||||
|
value: m.id,
|
||||||
|
label: m.name,
|
||||||
|
})),
|
||||||
|
[eligible],
|
||||||
|
)
|
||||||
|
|
||||||
|
async function handleAdd() {
|
||||||
|
if (!moduleId) {
|
||||||
|
toast.error('Выберите модуль')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (data.query_kind !== 'domain' && !communityId) {
|
||||||
|
toast.error('Укажите community')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
if (data.query_kind === 'domain') {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/domain-entries`, 'POST', {
|
||||||
|
fqdn: data.normalized,
|
||||||
|
community_id: communityId,
|
||||||
|
})
|
||||||
|
toast.success('Домен добавлен')
|
||||||
|
} else {
|
||||||
|
const prefix =
|
||||||
|
data.query_kind === 'cidr'
|
||||||
|
? data.normalized
|
||||||
|
: hostPrefixFromIp(data.normalized)
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/ip-range-entries`, 'POST', {
|
||||||
|
prefix,
|
||||||
|
community_id: communityId,
|
||||||
|
})
|
||||||
|
toast.success('Префикс добавлен')
|
||||||
|
}
|
||||||
|
await onAdded()
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof ApiError ? e.message : String(e))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eligible.length === 0) {
|
||||||
|
return (
|
||||||
|
<Alert variant="warning">
|
||||||
|
<AlertTitle>Нет подходящего модуля</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Создайте модуль типа {wantedType}, затем повторите добавление.{' '}
|
||||||
|
<Button variant="link" size="sm" className="h-auto p-0" render={<Link to="/modules/new" />}>
|
||||||
|
Перейти к модулям
|
||||||
|
</Button>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueLabel =
|
||||||
|
data.query_kind === 'domain'
|
||||||
|
? data.normalized
|
||||||
|
: data.query_kind === 'cidr'
|
||||||
|
? data.normalized
|
||||||
|
: hostPrefixFromIp(data.normalized)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h3 className="text-sm font-semibold">Добавить в списки</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
«{valueLabel}» отсутствует в списках. Выберите модуль и community.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="lookup-add-module">Модуль ({wantedType})</FieldLabel>
|
||||||
|
<SelectMenu
|
||||||
|
id="lookup-add-module"
|
||||||
|
items={moduleItems}
|
||||||
|
value={moduleId}
|
||||||
|
placeholder="Выберите модуль"
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (v) setModuleId(v)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<CommunitySelect
|
||||||
|
id="lookup-add-comm"
|
||||||
|
label="Community"
|
||||||
|
value={communityId}
|
||||||
|
onValueChange={setCommunityId}
|
||||||
|
communities={communities}
|
||||||
|
nullable={data.query_kind === 'domain'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center justify-end gap-2 border-t pt-4">
|
||||||
|
<Button type="button" variant="outline" onClick={onCancel}>
|
||||||
|
Назад
|
||||||
|
</Button>
|
||||||
|
<LoadingButton
|
||||||
|
loading={saving}
|
||||||
|
onClick={() => void handleAdd()}
|
||||||
|
disabled={!moduleId || (data.query_kind !== 'domain' && !communityId)}
|
||||||
|
>
|
||||||
|
Добавить
|
||||||
|
</LoadingButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import { useNavigate } from '@tanstack/react-router'
|
|||||||
|
|
||||||
import { CategoryBadge } from '@/components/category-badge'
|
import { CategoryBadge } from '@/components/category-badge'
|
||||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||||
import { FrameDataGrid } from '@/components/reui-kit'
|
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
import { DataGridSection } from '@/components/data-grid-shell'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
@@ -12,7 +11,8 @@ import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
|||||||
import type { LookupMatch } from '@/types/api'
|
import type { LookupMatch } from '@/types/api'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lookup matches grid — data-grid-filtering-2 pattern.
|
* Lookup matches grid — bare section for wizard-2 single Frame.
|
||||||
|
* @see https://reui.io/preview/base/wizard-2
|
||||||
* @see https://reui.io/preview/base/data-grid-filtering-2
|
* @see https://reui.io/preview/base/data-grid-filtering-2
|
||||||
* @see https://reui.io/docs/components/base/badge
|
* @see https://reui.io/docs/components/base/badge
|
||||||
*/
|
*/
|
||||||
@@ -113,10 +113,13 @@ export function LookupMatchesGrid({
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FrameDataGrid
|
<div className="flex flex-col gap-3">
|
||||||
title="Совпадения"
|
<div className="flex flex-col gap-px">
|
||||||
description="Entries и snapshots · клик по строке открывает модуль"
|
<h3 className="text-sm font-semibold">Совпадения</h3>
|
||||||
>
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Entries и snapshots · клик по строке открывает модуль
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<DataGridSection
|
<DataGridSection
|
||||||
table={table}
|
table={table}
|
||||||
recordCount={filteredCount}
|
recordCount={filteredCount}
|
||||||
@@ -129,6 +132,6 @@ export function LookupMatchesGrid({
|
|||||||
void navigate({ to: '/modules/$moduleId', params: { moduleId: row.module_id } })
|
void navigate({ to: '/modules/$moduleId', params: { moduleId: row.module_id } })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</FrameDataGrid>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,17 +8,10 @@ import {
|
|||||||
InputGroupAddon,
|
InputGroupAddon,
|
||||||
InputGroupInput,
|
InputGroupInput,
|
||||||
} from '@evobgp/ui/components/input-group'
|
} from '@evobgp/ui/components/input-group'
|
||||||
import {
|
|
||||||
Frame,
|
|
||||||
FrameDescription,
|
|
||||||
FrameHeader,
|
|
||||||
FramePanel,
|
|
||||||
FrameTitle,
|
|
||||||
} from '@/components/reui/frame'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lookup search form — Frame + InputGroup (form-7 pattern).
|
* Lookup search fields — bare form for wizard-2 single-Frame surface.
|
||||||
* @see https://reui.io/preview/base/form-7
|
* @see https://reui.io/preview/base/wizard-2
|
||||||
* @see https://reui.io/docs/components/base/frame
|
* @see https://reui.io/docs/components/base/frame
|
||||||
*/
|
*/
|
||||||
export function LookupSearchForm({
|
export function LookupSearchForm({
|
||||||
@@ -40,37 +33,27 @@ export function LookupSearchForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Frame spacing="sm" className="w-full">
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||||
<FrameHeader>
|
<Field className="min-w-0 flex-1">
|
||||||
<FrameTitle>Проверка списка</FrameTitle>
|
<FieldLabel htmlFor="lookup-q">IP, CIDR или домен</FieldLabel>
|
||||||
<FrameDescription>
|
<InputGroup>
|
||||||
IP или FQDN — поиск в entries и материализованных snapshots с community.
|
<InputGroupAddon align="inline-start">
|
||||||
</FrameDescription>
|
<Search aria-hidden />
|
||||||
</FrameHeader>
|
</InputGroupAddon>
|
||||||
<FramePanel>
|
<InputGroupInput
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 sm:flex-row sm:items-end">
|
id="lookup-q"
|
||||||
<Field className="min-w-0 flex-1">
|
name="q"
|
||||||
<FieldLabel htmlFor="lookup-q">IP или домен</FieldLabel>
|
value={value}
|
||||||
<InputGroup>
|
onChange={(e) => setValue(e.target.value)}
|
||||||
<InputGroupAddon align="inline-start">
|
placeholder="8.8.8.8, 203.0.113.0/24 или example.com"
|
||||||
<Search aria-hidden />
|
autoComplete="off"
|
||||||
</InputGroupAddon>
|
autoFocus
|
||||||
<InputGroupInput
|
/>
|
||||||
id="lookup-q"
|
</InputGroup>
|
||||||
name="q"
|
</Field>
|
||||||
value={value}
|
<Button type="submit" disabled={isPending || !value.trim()}>
|
||||||
onChange={(e) => setValue(e.target.value)}
|
Проверить
|
||||||
placeholder="8.8.8.8 или example.com"
|
</Button>
|
||||||
autoComplete="off"
|
</form>
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</InputGroup>
|
|
||||||
</Field>
|
|
||||||
<Button type="submit" disabled={isPending || !value.trim()}>
|
|
||||||
Проверить
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</FramePanel>
|
|
||||||
</Frame>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
import { Globe, Layers, ListChecks, Radar } from 'lucide-react'
|
|
||||||
|
|
||||||
import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
|
|
||||||
import type { LookupResponse } from '@/types/api'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lookup summary KPI — stats-12 via KpiStatGrid.
|
|
||||||
* @see https://reui.io/preview/base/stats-12
|
|
||||||
*/
|
|
||||||
export function LookupSummaryKpi({ data }: { data: LookupResponse }) {
|
|
||||||
const entryCount = data.matches.filter((m) => m.layer === 'entry').length
|
|
||||||
const snapshotCount = data.matches.filter((m) => m.layer === 'snapshot').length
|
|
||||||
const resolvedCount = data.resolved_ips?.length ?? 0
|
|
||||||
|
|
||||||
const items: KpiStatItem[] = [
|
|
||||||
{
|
|
||||||
id: 'matched',
|
|
||||||
label: 'Результат',
|
|
||||||
value: data.matched ? 'Найдено' : 'Не найдено',
|
|
||||||
hint: data.normalized,
|
|
||||||
icon: <Radar aria-hidden />,
|
|
||||||
iconClassName: data.matched
|
|
||||||
? 'bg-success text-success-foreground [&_svg]:text-success-foreground'
|
|
||||||
: 'bg-muted text-muted-foreground [&_svg]:text-muted-foreground',
|
|
||||||
variant: data.matched ? 'default' : 'warning',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'entry',
|
|
||||||
label: 'Слой entry',
|
|
||||||
value: entryCount,
|
|
||||||
hint: 'сырые списки',
|
|
||||||
icon: <ListChecks aria-hidden />,
|
|
||||||
iconClassName: 'bg-info text-info-foreground [&_svg]:text-info-foreground',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'snapshot',
|
|
||||||
label: 'Слой snapshot',
|
|
||||||
value: snapshotCount,
|
|
||||||
hint: 'материализация',
|
|
||||||
icon: <Layers aria-hidden />,
|
|
||||||
iconClassName: 'bg-focus text-focus-foreground [&_svg]:text-focus-foreground',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
if (data.query_kind === 'domain') {
|
|
||||||
items.push({
|
|
||||||
id: 'resolved',
|
|
||||||
label: 'DNS IP',
|
|
||||||
value: resolvedCount,
|
|
||||||
hint: resolvedCount > 0 ? data.resolved_ips?.slice(0, 3).join(', ') : 'нет A/AAAA',
|
|
||||||
icon: <Globe aria-hidden />,
|
|
||||||
iconClassName: 'bg-primary text-primary-foreground [&_svg]:text-primary-foreground',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<KpiStatGrid
|
|
||||||
items={items}
|
|
||||||
aria-label={`Запрос: ${data.query_kind} · ${data.query}`}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
import { useEffect, useState, type ReactNode } from 'react'
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Check, Plus, Search, ShieldAlert } from 'lucide-react'
|
||||||
|
|
||||||
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
|
||||||
|
import { LookupAddStep } from '@/components/lookup/lookup-add-step'
|
||||||
|
import { LookupMatchesGrid } from '@/components/lookup/lookup-matches-grid'
|
||||||
|
import { LookupSearchForm } from '@/components/lookup/lookup-search-form'
|
||||||
|
import { EmptyState } from '@/components/empty-state'
|
||||||
|
import { QueryState } from '@/components/query-state'
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
AlertDescription,
|
||||||
|
AlertTitle,
|
||||||
|
} from '@/components/reui/alert'
|
||||||
|
import {
|
||||||
|
Stepper,
|
||||||
|
StepperContent,
|
||||||
|
StepperDescription,
|
||||||
|
StepperIndicator,
|
||||||
|
StepperItem,
|
||||||
|
StepperNav,
|
||||||
|
StepperPanel,
|
||||||
|
StepperSeparator,
|
||||||
|
StepperTitle,
|
||||||
|
StepperTrigger,
|
||||||
|
} from '@/components/reui/stepper'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
|
import { TableSkeleton } from '@/components/skeletons'
|
||||||
|
import { sessionCanWriteModules } from '@/lib/auth'
|
||||||
|
import { authSessionQueryOptions } from '@/queries/auth'
|
||||||
|
import { directoriesCommunitiesQueryOptions } from '@/queries/directories'
|
||||||
|
import { lookupKeys, lookupQueryOptions } from '@/queries/lookup'
|
||||||
|
import { modulesListQueryOptions } from '@/queries/modules'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lookup membership wizard — one Frame (wizard-2), all content inside.
|
||||||
|
* @see https://reui.io/preview/base/wizard-2
|
||||||
|
* @see https://reui.io/docs/components/base/stepper
|
||||||
|
* @see https://reui.io/docs/components/base/frame
|
||||||
|
* @see https://reui.io/preview/base/data-grid-filtering-2
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STEP_QUERY = 1
|
||||||
|
const STEP_RESULT = 2
|
||||||
|
const STEP_ADD = 3
|
||||||
|
const STEP_DONE = 4
|
||||||
|
|
||||||
|
function WizardActions({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center justify-end gap-2 border-t pt-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LookupWizard({
|
||||||
|
q,
|
||||||
|
onSubmitQuery,
|
||||||
|
}: {
|
||||||
|
q: string
|
||||||
|
onSubmitQuery: (next: string) => void
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const trimmed = q.trim()
|
||||||
|
const lookupQ = useQuery(lookupQueryOptions(trimmed))
|
||||||
|
const sessionQ = useQuery(authSessionQueryOptions())
|
||||||
|
|
||||||
|
const canWrite = sessionCanWriteModules(sessionQ.data)
|
||||||
|
const [step, setStep] = useState(trimmed ? STEP_RESULT : STEP_QUERY)
|
||||||
|
const [offerAdd, setOfferAdd] = useState(false)
|
||||||
|
const loadCatalog = step === STEP_ADD || step === STEP_DONE
|
||||||
|
|
||||||
|
const modulesQ = useQuery({
|
||||||
|
...modulesListQueryOptions(),
|
||||||
|
enabled: loadCatalog,
|
||||||
|
})
|
||||||
|
const communitiesQ = useQuery({
|
||||||
|
...directoriesCommunitiesQueryOptions(),
|
||||||
|
enabled: loadCatalog,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOfferAdd(false)
|
||||||
|
setStep(trimmed ? STEP_RESULT : STEP_QUERY)
|
||||||
|
}, [trimmed])
|
||||||
|
|
||||||
|
function resetToQuery() {
|
||||||
|
setOfferAdd(false)
|
||||||
|
setStep(STEP_QUERY)
|
||||||
|
onSubmitQuery('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToAdd() {
|
||||||
|
setOfferAdd(true)
|
||||||
|
setStep(STEP_ADD)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAdded() {
|
||||||
|
setStep(STEP_DONE)
|
||||||
|
await queryClient.invalidateQueries({ queryKey: lookupKeys.query(trimmed) })
|
||||||
|
await lookupQ.refetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = lookupQ.data
|
||||||
|
const notFound = data != null && !data.matched
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 md:gap-6">
|
||||||
|
<Frame spacing="sm" className="w-full">
|
||||||
|
<FramePanel className="flex flex-col gap-6">
|
||||||
|
<FrameHeader className="px-0 py-0">
|
||||||
|
<FrameTitle>Мастер проверки</FrameTitle>
|
||||||
|
<FrameDescription>
|
||||||
|
Проверка IP / CIDR / домена в списках, затем при необходимости — добавление.
|
||||||
|
</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
|
||||||
|
<Stepper
|
||||||
|
value={step}
|
||||||
|
onValueChange={setStep}
|
||||||
|
className="flex flex-col gap-6"
|
||||||
|
indicators={{
|
||||||
|
completed: <Check className="size-4" />,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StepperNav>
|
||||||
|
<StepperItem step={STEP_QUERY} completed={step > STEP_QUERY}>
|
||||||
|
<StepperTrigger className="flex flex-col items-start gap-1 sm:flex-row sm:items-center sm:gap-2">
|
||||||
|
<StepperIndicator>{STEP_QUERY}</StepperIndicator>
|
||||||
|
<div className="flex flex-col items-start">
|
||||||
|
<StepperTitle>Запрос</StepperTitle>
|
||||||
|
<StepperDescription className="hidden sm:block">
|
||||||
|
IP, CIDR или FQDN
|
||||||
|
</StepperDescription>
|
||||||
|
</div>
|
||||||
|
</StepperTrigger>
|
||||||
|
<StepperSeparator className="max-sm:hidden" />
|
||||||
|
</StepperItem>
|
||||||
|
<StepperItem
|
||||||
|
step={STEP_RESULT}
|
||||||
|
completed={step > STEP_RESULT}
|
||||||
|
disabled={!trimmed}
|
||||||
|
>
|
||||||
|
<StepperTrigger className="flex flex-col items-start gap-1 sm:flex-row sm:items-center sm:gap-2">
|
||||||
|
<StepperIndicator>{STEP_RESULT}</StepperIndicator>
|
||||||
|
<div className="flex flex-col items-start">
|
||||||
|
<StepperTitle>Результат</StepperTitle>
|
||||||
|
<StepperDescription className="hidden sm:block">
|
||||||
|
Есть в списках?
|
||||||
|
</StepperDescription>
|
||||||
|
</div>
|
||||||
|
</StepperTrigger>
|
||||||
|
<StepperSeparator className="max-sm:hidden" />
|
||||||
|
</StepperItem>
|
||||||
|
<StepperItem
|
||||||
|
step={STEP_ADD}
|
||||||
|
completed={step > STEP_ADD}
|
||||||
|
disabled={!offerAdd && step !== STEP_ADD && step !== STEP_DONE}
|
||||||
|
>
|
||||||
|
<StepperTrigger className="flex flex-col items-start gap-1 sm:flex-row sm:items-center sm:gap-2">
|
||||||
|
<StepperIndicator>{STEP_ADD}</StepperIndicator>
|
||||||
|
<div className="flex flex-col items-start">
|
||||||
|
<StepperTitle>Добавление</StepperTitle>
|
||||||
|
<StepperDescription className="hidden sm:block">
|
||||||
|
Модуль и community
|
||||||
|
</StepperDescription>
|
||||||
|
</div>
|
||||||
|
</StepperTrigger>
|
||||||
|
<StepperSeparator className="max-sm:hidden" />
|
||||||
|
</StepperItem>
|
||||||
|
<StepperItem step={STEP_DONE} completed={step === STEP_DONE}>
|
||||||
|
<StepperTrigger className="flex flex-col items-start gap-1 sm:flex-row sm:items-center sm:gap-2">
|
||||||
|
<StepperIndicator>{STEP_DONE}</StepperIndicator>
|
||||||
|
<div className="flex flex-col items-start">
|
||||||
|
<StepperTitle>Готово</StepperTitle>
|
||||||
|
<StepperDescription className="hidden sm:block">
|
||||||
|
Подтверждение
|
||||||
|
</StepperDescription>
|
||||||
|
</div>
|
||||||
|
</StepperTrigger>
|
||||||
|
</StepperItem>
|
||||||
|
</StepperNav>
|
||||||
|
|
||||||
|
<StepperPanel>
|
||||||
|
<StepperContent value={STEP_QUERY}>
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<LookupSearchForm
|
||||||
|
key={`form-${trimmed}`}
|
||||||
|
initialQuery={trimmed}
|
||||||
|
isPending={lookupQ.isFetching}
|
||||||
|
onSubmit={onSubmitQuery}
|
||||||
|
/>
|
||||||
|
{!trimmed ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Search className="size-8" />}
|
||||||
|
title="Введите IP, CIDR или домен"
|
||||||
|
description="Например 8.8.8.8, 203.0.113.0/24 или example.com — проверка по entries и snapshots."
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</StepperContent>
|
||||||
|
|
||||||
|
<StepperContent value={STEP_RESULT}>
|
||||||
|
{!trimmed ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Search className="size-8" />}
|
||||||
|
title="Сначала выполните проверку"
|
||||||
|
description="Вернитесь к шагу «Запрос» и укажите значение."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<QueryState
|
||||||
|
data={lookupQ.data}
|
||||||
|
isLoading={lookupQ.isLoading}
|
||||||
|
isError={lookupQ.isError}
|
||||||
|
error={lookupQ.error}
|
||||||
|
onRetry={() => void lookupQ.refetch()}
|
||||||
|
skeleton={<TableSkeleton rows={5} />}
|
||||||
|
>
|
||||||
|
{(result) => (
|
||||||
|
<div className="flex flex-col gap-4 md:gap-6">
|
||||||
|
<LookupSearchForm
|
||||||
|
key={`result-form-${trimmed}`}
|
||||||
|
initialQuery={trimmed}
|
||||||
|
isPending={lookupQ.isFetching}
|
||||||
|
onSubmit={onSubmitQuery}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{result.matched ? (
|
||||||
|
<>
|
||||||
|
<Alert variant="success">
|
||||||
|
<Check className="size-4" />
|
||||||
|
<AlertTitle>Уже есть в списках</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
«{result.normalized}» найден в entries и/или snapshots
|
||||||
|
({result.match_count} совпад.).
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<LookupMatchesGrid
|
||||||
|
items={result.matches}
|
||||||
|
isLoading={lookupQ.isFetching}
|
||||||
|
/>
|
||||||
|
<WizardActions>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={resetToQuery}
|
||||||
|
>
|
||||||
|
Новая проверка
|
||||||
|
</Button>
|
||||||
|
</WizardActions>
|
||||||
|
</>
|
||||||
|
) : canWrite ? (
|
||||||
|
<>
|
||||||
|
<Alert variant="info">
|
||||||
|
<Plus className="size-4" />
|
||||||
|
<AlertTitle>Не найдено в списках</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
«{result.normalized}» отсутствует. Добавить запись?
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<WizardActions>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={resetToQuery}
|
||||||
|
>
|
||||||
|
Новая проверка
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={() => goToAdd()}>
|
||||||
|
Добавить
|
||||||
|
</Button>
|
||||||
|
</WizardActions>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Alert variant="warning">
|
||||||
|
<ShieldAlert className="size-4" />
|
||||||
|
<AlertTitle>Не найдено</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
«{result.normalized}» отсутствует в списках. У вас нет
|
||||||
|
права добавлять записи (нужно bgp:modules:write).
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<WizardActions>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={resetToQuery}
|
||||||
|
>
|
||||||
|
Новая проверка
|
||||||
|
</Button>
|
||||||
|
</WizardActions>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
)}
|
||||||
|
</StepperContent>
|
||||||
|
|
||||||
|
<StepperContent value={STEP_ADD}>
|
||||||
|
{data && notFound ? (
|
||||||
|
<LookupAddStep
|
||||||
|
data={data}
|
||||||
|
modules={modulesQ.data?.items ?? []}
|
||||||
|
communities={communitiesQ.data?.items ?? []}
|
||||||
|
onCancel={() => {
|
||||||
|
setOfferAdd(false)
|
||||||
|
setStep(STEP_RESULT)
|
||||||
|
}}
|
||||||
|
onAdded={handleAdded}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Alert variant="warning">
|
||||||
|
<AlertTitle>Добавление недоступно</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Сначала выполните проверку для значения, которого ещё нет в списках.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</StepperContent>
|
||||||
|
|
||||||
|
<StepperContent value={STEP_DONE}>
|
||||||
|
<div className="flex flex-col gap-4 md:gap-6">
|
||||||
|
<Alert variant="success">
|
||||||
|
<Check className="size-4" />
|
||||||
|
<AlertTitle>Запись добавлена</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Повторная проверка обновлена. Можно посмотреть совпадения или
|
||||||
|
начать новый запрос.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
{lookupQ.data?.matched ? (
|
||||||
|
<LookupMatchesGrid
|
||||||
|
items={lookupQ.data.matches}
|
||||||
|
isLoading={lookupQ.isFetching}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<WizardActions>
|
||||||
|
<Button type="button" variant="outline" onClick={resetToQuery}>
|
||||||
|
Новая проверка
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={() => setStep(STEP_RESULT)}>
|
||||||
|
К результату
|
||||||
|
</Button>
|
||||||
|
</WizardActions>
|
||||||
|
</div>
|
||||||
|
</StepperContent>
|
||||||
|
</StepperPanel>
|
||||||
|
</Stepper>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { KeyboardEvent, ReactNode } from 'react'
|
||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
import {
|
import {
|
||||||
Frame,
|
Frame,
|
||||||
@@ -12,16 +12,23 @@ import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
|||||||
import { cn } from '@evobgp/ui/lib/utils'
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
import { kpiCols } from './kpi-cols'
|
import { kpiCols } from './kpi-cols'
|
||||||
|
|
||||||
export interface QuickActionItem {
|
type QuickActionBase = {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
to: string
|
|
||||||
search?: Record<string, unknown>
|
|
||||||
icon?: ReactNode
|
icon?: ReactNode
|
||||||
iconClassName?: string
|
iconClassName?: string
|
||||||
|
/** Override badge label (default: «Перейти» for links, «Выполнить» for onClick). */
|
||||||
|
badge?: string
|
||||||
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type QuickActionItem = QuickActionBase &
|
||||||
|
(
|
||||||
|
| { to: string; search?: Record<string, unknown>; onClick?: never }
|
||||||
|
| { onClick: () => void; to?: never; search?: never }
|
||||||
|
)
|
||||||
|
|
||||||
interface QuickActionGridProps {
|
interface QuickActionGridProps {
|
||||||
actions: QuickActionItem[]
|
actions: QuickActionItem[]
|
||||||
title?: string
|
title?: string
|
||||||
@@ -31,6 +38,18 @@ interface QuickActionGridProps {
|
|||||||
|
|
||||||
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
|
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
|
||||||
|
|
||||||
|
function resolveBadge(action: QuickActionItem): string {
|
||||||
|
if (action.badge) return action.badge
|
||||||
|
return action.onClick ? 'Выполнить' : 'Перейти'
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleActionKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault()
|
||||||
|
onActivate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function QuickActionBody({ action }: { action: QuickActionItem }) {
|
function QuickActionBody({ action }: { action: QuickActionItem }) {
|
||||||
return (
|
return (
|
||||||
<div className="relative z-10 flex h-full items-start gap-3">
|
<div className="relative z-10 flex h-full items-start gap-3">
|
||||||
@@ -51,7 +70,7 @@ function QuickActionBody({ action }: { action: QuickActionItem }) {
|
|||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<span className="text-foreground text-sm font-medium">{action.title}</span>
|
<span className="text-foreground text-sm font-medium">{action.title}</span>
|
||||||
<Badge variant="outline" size="sm" className="shrink-0">
|
<Badge variant="outline" size="sm" className="shrink-0">
|
||||||
Перейти
|
{resolveBadge(action)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
|
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
|
||||||
@@ -62,6 +81,15 @@ function QuickActionBody({ action }: { action: QuickActionItem }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function panelClassName(disabled?: boolean) {
|
||||||
|
return cn(
|
||||||
|
'relative isolate flex h-full flex-col transition-colors',
|
||||||
|
disabled
|
||||||
|
? 'cursor-not-allowed opacity-60'
|
||||||
|
: 'hover:bg-muted/40 focus-within:ring-ring cursor-pointer focus-within:ring-2',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* KPI-like quick actions strip (horizontal Frame tiles).
|
* KPI-like quick actions strip (horizontal Frame tiles).
|
||||||
* Preview: https://reui.io/preview/base/stats-12
|
* Preview: https://reui.io/preview/base/stats-12
|
||||||
@@ -83,21 +111,51 @@ export function QuickActionGrid({
|
|||||||
</FrameHeader>
|
</FrameHeader>
|
||||||
)}
|
)}
|
||||||
<div className={cn('grid gap-2', kpiCols(actions.length))}>
|
<div className={cn('grid gap-2', kpiCols(actions.length))}>
|
||||||
{actions.map((action) => (
|
{actions.map((action) => {
|
||||||
<FramePanel
|
const label = `${action.title}: ${action.description}`
|
||||||
key={action.id}
|
|
||||||
className="relative isolate flex h-full flex-col hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2"
|
if ('to' in action && action.to) {
|
||||||
>
|
return (
|
||||||
<Link
|
<FramePanel key={action.id} className={panelClassName(action.disabled)}>
|
||||||
to={action.to}
|
{action.disabled ? (
|
||||||
search={action.search}
|
<div aria-disabled aria-label={label}>
|
||||||
className="focus-visible:outline-none"
|
<QuickActionBody action={action} />
|
||||||
aria-label={`${action.title}: ${action.description}`}
|
</div>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
to={action.to}
|
||||||
|
search={action.search}
|
||||||
|
className="focus-visible:outline-none"
|
||||||
|
aria-label={label}
|
||||||
|
>
|
||||||
|
<QuickActionBody action={action} />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</FramePanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onClick = action.onClick
|
||||||
|
const onActivate = () => {
|
||||||
|
if (action.disabled || !onClick) return
|
||||||
|
onClick()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FramePanel
|
||||||
|
key={action.id}
|
||||||
|
className={panelClassName(action.disabled)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={action.disabled ? -1 : 0}
|
||||||
|
aria-disabled={action.disabled || undefined}
|
||||||
|
aria-label={label}
|
||||||
|
onClick={onActivate}
|
||||||
|
onKeyDown={(e) => handleActionKeyDown(onActivate, e)}
|
||||||
>
|
>
|
||||||
<QuickActionBody action={action} />
|
<QuickActionBody action={action} />
|
||||||
</Link>
|
</FramePanel>
|
||||||
</FramePanel>
|
)
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</Frame>
|
</Frame>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,470 @@
|
|||||||
|
import type { HTMLAttributes, ReactElement } from "react"
|
||||||
|
import {
|
||||||
|
Children,
|
||||||
|
createContext,
|
||||||
|
isValidElement,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react"
|
||||||
|
import { mergeProps } from "@base-ui/react/merge-props"
|
||||||
|
import { useRender } from "@base-ui/react/use-render"
|
||||||
|
|
||||||
|
import { cn } from "@evobgp/ui/lib/utils"
|
||||||
|
|
||||||
|
// Types
|
||||||
|
type StepperOrientation = "horizontal" | "vertical"
|
||||||
|
type StepState = "active" | "completed" | "inactive" | "loading"
|
||||||
|
type StepIndicators = {
|
||||||
|
active?: React.ReactNode
|
||||||
|
completed?: React.ReactNode
|
||||||
|
inactive?: React.ReactNode
|
||||||
|
loading?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StepperContextValue {
|
||||||
|
activeStep: number
|
||||||
|
setActiveStep: (step: number) => void
|
||||||
|
stepsCount: number
|
||||||
|
orientation: StepperOrientation
|
||||||
|
registerTrigger: (node: HTMLButtonElement | null) => void
|
||||||
|
triggerNodes: HTMLButtonElement[]
|
||||||
|
focusNext: (currentIdx: number) => void
|
||||||
|
focusPrev: (currentIdx: number) => void
|
||||||
|
focusFirst: () => void
|
||||||
|
focusLast: () => void
|
||||||
|
indicators: StepIndicators
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StepItemContextValue {
|
||||||
|
step: number
|
||||||
|
state: StepState
|
||||||
|
isDisabled: boolean
|
||||||
|
isLoading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepperContext = createContext<StepperContextValue | undefined>(undefined)
|
||||||
|
const StepItemContext = createContext<StepItemContextValue | undefined>(
|
||||||
|
undefined
|
||||||
|
)
|
||||||
|
|
||||||
|
function useStepper() {
|
||||||
|
const ctx = useContext(StepperContext)
|
||||||
|
if (!ctx) throw new Error("useStepper must be used within a Stepper")
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
function useStepItem() {
|
||||||
|
const ctx = useContext(StepItemContext)
|
||||||
|
if (!ctx) throw new Error("useStepItem must be used within a StepperItem")
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StepperProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
defaultValue?: number
|
||||||
|
value?: number
|
||||||
|
onValueChange?: (value: number) => void
|
||||||
|
orientation?: StepperOrientation
|
||||||
|
indicators?: StepIndicators
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stepper({
|
||||||
|
defaultValue = 1,
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
orientation = "horizontal",
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
indicators = {},
|
||||||
|
...props
|
||||||
|
}: StepperProps) {
|
||||||
|
const [activeStep, setActiveStep] = useState(defaultValue)
|
||||||
|
const [triggerNodes, setTriggerNodes] = useState<HTMLButtonElement[]>([])
|
||||||
|
|
||||||
|
// Register/unregister triggers
|
||||||
|
const registerTrigger = useCallback((node: HTMLButtonElement | null) => {
|
||||||
|
setTriggerNodes((prev) => {
|
||||||
|
if (node && !prev.includes(node)) {
|
||||||
|
return [...prev, node]
|
||||||
|
} else if (!node && prev.includes(node!)) {
|
||||||
|
return prev.filter((n) => n !== node)
|
||||||
|
} else {
|
||||||
|
return prev
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSetActiveStep = useCallback(
|
||||||
|
(step: number) => {
|
||||||
|
if (value === undefined) {
|
||||||
|
setActiveStep(step)
|
||||||
|
}
|
||||||
|
onValueChange?.(step)
|
||||||
|
},
|
||||||
|
[value, onValueChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
const currentStep = value ?? activeStep
|
||||||
|
|
||||||
|
// Keyboard navigation logic
|
||||||
|
const focusTrigger = (idx: number) => {
|
||||||
|
if (triggerNodes[idx]) triggerNodes[idx].focus()
|
||||||
|
}
|
||||||
|
const focusNext = (currentIdx: number) =>
|
||||||
|
focusTrigger((currentIdx + 1) % triggerNodes.length)
|
||||||
|
const focusPrev = (currentIdx: number) =>
|
||||||
|
focusTrigger((currentIdx - 1 + triggerNodes.length) % triggerNodes.length)
|
||||||
|
const focusFirst = () => focusTrigger(0)
|
||||||
|
const focusLast = () => focusTrigger(triggerNodes.length - 1)
|
||||||
|
|
||||||
|
// Context value
|
||||||
|
const contextValue = useMemo<StepperContextValue>(
|
||||||
|
() => ({
|
||||||
|
activeStep: currentStep,
|
||||||
|
setActiveStep: handleSetActiveStep,
|
||||||
|
stepsCount: Children.toArray(children).filter(
|
||||||
|
(child): child is ReactElement =>
|
||||||
|
isValidElement(child) &&
|
||||||
|
(child.type as { displayName?: string }).displayName === "StepperItem"
|
||||||
|
).length,
|
||||||
|
orientation,
|
||||||
|
registerTrigger,
|
||||||
|
focusNext,
|
||||||
|
focusPrev,
|
||||||
|
focusFirst,
|
||||||
|
focusLast,
|
||||||
|
triggerNodes,
|
||||||
|
indicators,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
currentStep,
|
||||||
|
handleSetActiveStep,
|
||||||
|
children,
|
||||||
|
orientation,
|
||||||
|
registerTrigger,
|
||||||
|
triggerNodes,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StepperContext.Provider value={contextValue}>
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
aria-orientation={orientation}
|
||||||
|
data-slot="stepper"
|
||||||
|
className={cn("w-full", className)}
|
||||||
|
data-orientation={orientation}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</StepperContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StepperItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
step: number
|
||||||
|
completed?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
loading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepperItem({
|
||||||
|
step,
|
||||||
|
completed = false,
|
||||||
|
disabled = false,
|
||||||
|
loading = false,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: StepperItemProps) {
|
||||||
|
const { activeStep } = useStepper()
|
||||||
|
|
||||||
|
const state: StepState =
|
||||||
|
completed || step < activeStep
|
||||||
|
? "completed"
|
||||||
|
: activeStep === step
|
||||||
|
? "active"
|
||||||
|
: "inactive"
|
||||||
|
|
||||||
|
const isLoading = loading && step === activeStep
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StepItemContext.Provider
|
||||||
|
value={{ step, state, isDisabled: disabled, isLoading }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-slot="stepper-item"
|
||||||
|
className={cn(
|
||||||
|
"group/step flex items-center justify-center not-last:flex-1 group-data-[orientation=horizontal]/stepper-nav:flex-row group-data-[orientation=vertical]/stepper-nav:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-state={state}
|
||||||
|
{...(isLoading ? { "data-loading": true } : {})}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</StepItemContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type StepperTriggerProps = useRender.ComponentProps<"button">
|
||||||
|
|
||||||
|
function StepperTrigger({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
tabIndex,
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}: StepperTriggerProps) {
|
||||||
|
const { state, isLoading } = useStepItem()
|
||||||
|
const stepperCtx = useStepper()
|
||||||
|
const {
|
||||||
|
setActiveStep,
|
||||||
|
activeStep,
|
||||||
|
registerTrigger,
|
||||||
|
triggerNodes,
|
||||||
|
focusNext,
|
||||||
|
focusPrev,
|
||||||
|
focusFirst,
|
||||||
|
focusLast,
|
||||||
|
} = stepperCtx
|
||||||
|
const { step, isDisabled } = useStepItem()
|
||||||
|
const isSelected = activeStep === step
|
||||||
|
const id = `stepper-tab-${step}`
|
||||||
|
const panelId = `stepper-panel-${step}`
|
||||||
|
|
||||||
|
// Register this trigger for keyboard navigation
|
||||||
|
const btnRef = useRef<HTMLButtonElement>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
if (btnRef.current) {
|
||||||
|
registerTrigger(btnRef.current)
|
||||||
|
}
|
||||||
|
}, [btnRef.current])
|
||||||
|
|
||||||
|
// Find our index among triggers for navigation
|
||||||
|
const myIdx = useMemo(
|
||||||
|
() =>
|
||||||
|
triggerNodes.findIndex((n: HTMLButtonElement) => n === btnRef.current),
|
||||||
|
[triggerNodes, btnRef.current]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||||
|
switch (e.key) {
|
||||||
|
case "ArrowRight":
|
||||||
|
case "ArrowDown":
|
||||||
|
e.preventDefault()
|
||||||
|
if (myIdx !== -1 && focusNext) focusNext(myIdx)
|
||||||
|
break
|
||||||
|
case "ArrowLeft":
|
||||||
|
case "ArrowUp":
|
||||||
|
e.preventDefault()
|
||||||
|
if (myIdx !== -1 && focusPrev) focusPrev(myIdx)
|
||||||
|
break
|
||||||
|
case "Home":
|
||||||
|
e.preventDefault()
|
||||||
|
if (focusFirst) focusFirst()
|
||||||
|
break
|
||||||
|
case "End":
|
||||||
|
e.preventDefault()
|
||||||
|
if (focusLast) focusLast()
|
||||||
|
break
|
||||||
|
case "Enter":
|
||||||
|
case " ":
|
||||||
|
e.preventDefault()
|
||||||
|
setActiveStep(step)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
role: "tab",
|
||||||
|
id,
|
||||||
|
"aria-selected": isSelected,
|
||||||
|
"aria-controls": panelId,
|
||||||
|
tabIndex: typeof tabIndex === "number" ? tabIndex : isSelected ? 0 : -1,
|
||||||
|
"data-slot": "stepper-trigger",
|
||||||
|
"data-state": state,
|
||||||
|
"data-loading": isLoading,
|
||||||
|
className: cn(
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 inline-flex cursor-pointer items-center outline-none focus-visible:z-10 focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-60",
|
||||||
|
"gap-2.5 rounded-full",
|
||||||
|
className
|
||||||
|
),
|
||||||
|
onClick: () => setActiveStep(step),
|
||||||
|
onKeyDown: handleKeyDown,
|
||||||
|
disabled: isDisabled,
|
||||||
|
children,
|
||||||
|
}
|
||||||
|
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "button",
|
||||||
|
render,
|
||||||
|
ref: btnRef,
|
||||||
|
props: mergeProps<"button">(defaultProps, props),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepperIndicator({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
const { state, isLoading } = useStepItem()
|
||||||
|
const { indicators } = useStepper()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="stepper-indicator"
|
||||||
|
data-state={state}
|
||||||
|
className={cn(
|
||||||
|
"border-background bg-accent text-accent-foreground data-[state=completed]:bg-primary data-[state=completed]:text-primary-foreground data-[state=active]:bg-primary data-[state=active]:text-primary-foreground relative flex size-6 shrink-0 items-center justify-center overflow-hidden",
|
||||||
|
"rounded-full text-xs",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="absolute">
|
||||||
|
{indicators &&
|
||||||
|
((isLoading && indicators.loading) ||
|
||||||
|
(state === "completed" && indicators.completed) ||
|
||||||
|
(state === "active" && indicators.active) ||
|
||||||
|
(state === "inactive" && indicators.inactive))
|
||||||
|
? (isLoading && indicators.loading) ||
|
||||||
|
(state === "completed" && indicators.completed) ||
|
||||||
|
(state === "active" && indicators.active) ||
|
||||||
|
(state === "inactive" && indicators.inactive)
|
||||||
|
: children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepperSeparator({ className }: React.ComponentProps<"div">) {
|
||||||
|
const { state } = useStepItem()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="stepper-separator"
|
||||||
|
data-state={state}
|
||||||
|
className={cn(
|
||||||
|
"bg-muted rounded-sm m-0.5 group-data-[orientation=horizontal]/stepper-nav:h-0.5 group-data-[orientation=horizontal]/stepper-nav:flex-1 group-data-[orientation=vertical]/stepper-nav:h-12 group-data-[orientation=vertical]/stepper-nav:w-0.5",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepperTitle({ children, className }: React.ComponentProps<"h3">) {
|
||||||
|
const { state } = useStepItem()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<h3
|
||||||
|
data-slot="stepper-title"
|
||||||
|
data-state={state}
|
||||||
|
className={cn("text-sm leading-none font-medium", className)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</h3>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepperDescription({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
const { state } = useStepItem()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="stepper-description"
|
||||||
|
data-state={state}
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepperNav({ children, className }: React.ComponentProps<"nav">) {
|
||||||
|
const { activeStep, orientation } = useStepper()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
data-slot="stepper-nav"
|
||||||
|
data-state={activeStep}
|
||||||
|
data-orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"group/stepper-nav inline-flex data-[orientation=horizontal]:w-full data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepperPanel({ children, className }: React.ComponentProps<"div">) {
|
||||||
|
const { activeStep } = useStepper()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="stepper-panel"
|
||||||
|
data-state={activeStep}
|
||||||
|
className={cn("w-full", className)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StepperContentProps extends React.ComponentProps<"div"> {
|
||||||
|
value: number
|
||||||
|
forceMount?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepperContent({
|
||||||
|
value,
|
||||||
|
forceMount,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: StepperContentProps) {
|
||||||
|
const { activeStep } = useStepper()
|
||||||
|
const isActive = value === activeStep
|
||||||
|
|
||||||
|
if (!forceMount && !isActive) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="stepper-content"
|
||||||
|
data-state={activeStep}
|
||||||
|
className={cn("w-full", className, !isActive && forceMount && "hidden")}
|
||||||
|
hidden={!isActive && forceMount}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
useStepper,
|
||||||
|
useStepItem,
|
||||||
|
Stepper,
|
||||||
|
StepperItem,
|
||||||
|
StepperTrigger,
|
||||||
|
StepperIndicator,
|
||||||
|
StepperSeparator,
|
||||||
|
StepperTitle,
|
||||||
|
StepperDescription,
|
||||||
|
StepperPanel,
|
||||||
|
StepperContent,
|
||||||
|
StepperNav,
|
||||||
|
type StepperProps,
|
||||||
|
type StepperItemProps,
|
||||||
|
type StepperTriggerProps,
|
||||||
|
type StepperContentProps,
|
||||||
|
}
|
||||||
@@ -283,6 +283,31 @@ export function sessionCanManageApiKeys(session: {
|
|||||||
return session.role === 'operator'
|
return session.role === 'operator'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether session may create/update module entries (`bgp:modules:write`).
|
||||||
|
* Mirrors backend `requirePerm` for JWT (is_admin / permissions) and API-key editor+.
|
||||||
|
*/
|
||||||
|
export function sessionCanWriteModules(session: {
|
||||||
|
role?: string
|
||||||
|
kind?: string
|
||||||
|
is_admin?: boolean
|
||||||
|
permissions?: readonly string[]
|
||||||
|
} | null | undefined): boolean {
|
||||||
|
if (!session) return false
|
||||||
|
const jwtPath =
|
||||||
|
session.kind === 'jwt' ||
|
||||||
|
session.is_admin === true ||
|
||||||
|
(session.permissions?.length ?? 0) > 0
|
||||||
|
if (jwtPath) {
|
||||||
|
return (
|
||||||
|
session.is_admin === true ||
|
||||||
|
hasPermission(session.permissions ?? [], 'bgp:modules:write')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const role = (session.role ?? '').toLowerCase()
|
||||||
|
return role === 'editor' || role === 'operator'
|
||||||
|
}
|
||||||
|
|
||||||
/** Nav path → minimum permission to show the item. Sync with app-shell NAV. */
|
/** Nav path → minimum permission to show the item. Sync with app-shell NAV. */
|
||||||
export function permissionForPath(pathname: string): string | null {
|
export function permissionForPath(pathname: string): string | null {
|
||||||
if (pathname === '/' || pathname.startsWith('/dashboard')) {
|
if (pathname === '/' || pathname.startsWith('/dashboard')) {
|
||||||
|
|||||||
@@ -1,23 +1,16 @@
|
|||||||
import { createFileRoute, useSearch } from '@tanstack/react-router'
|
import { createFileRoute, useSearch } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
|
||||||
import { Search } from 'lucide-react'
|
|
||||||
|
|
||||||
import { LookupMatchesGrid } from '@/components/lookup/lookup-matches-grid'
|
import { LookupWizard } from '@/components/lookup/lookup-wizard'
|
||||||
import { LookupSearchForm } from '@/components/lookup/lookup-search-form'
|
|
||||||
import { LookupSummaryKpi } from '@/components/lookup/lookup-summary-kpi'
|
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import { EmptyState } from '@/components/empty-state'
|
|
||||||
import { QueryState } from '@/components/query-state'
|
|
||||||
import { SectionCardsSkeleton, TableSkeleton } from '@/components/skeletons'
|
|
||||||
import { lookupQueryOptions } from '@/queries/lookup'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Quick membership lookup page.
|
* Quick membership lookup wizard.
|
||||||
* Surface: frame · KPI: stats-12 · form: form-7 · grid: data-grid-filtering-2 · empty: empty-state-2
|
* Surface: frame · wizard-2 · stepper · KPI: stats-12 · grid: data-grid-filtering-2
|
||||||
|
* @see https://reui.io/preview/base/wizard-2
|
||||||
|
* @see https://reui.io/docs/components/base/stepper
|
||||||
* @see https://reui.io/preview/base/stats-12
|
* @see https://reui.io/preview/base/stats-12
|
||||||
* @see https://reui.io/preview/base/form-7
|
|
||||||
* @see https://reui.io/preview/base/data-grid-filtering-2
|
* @see https://reui.io/preview/base/data-grid-filtering-2
|
||||||
* @see https://reui.io/preview/base/empty-state-2
|
* @see https://reui.io/preview/base/empty-state-12
|
||||||
*/
|
*/
|
||||||
export const Route = createFileRoute('/_auth/lookup')({
|
export const Route = createFileRoute('/_auth/lookup')({
|
||||||
component: LookupComponent,
|
component: LookupComponent,
|
||||||
@@ -29,58 +22,18 @@ export const Route = createFileRoute('/_auth/lookup')({
|
|||||||
function LookupComponent() {
|
function LookupComponent() {
|
||||||
const { q } = useSearch({ from: '/_auth/lookup' })
|
const { q } = useSearch({ from: '/_auth/lookup' })
|
||||||
const navigate = Route.useNavigate()
|
const navigate = Route.useNavigate()
|
||||||
const lookupQ = useQuery(lookupQueryOptions(q))
|
|
||||||
|
|
||||||
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="Быстрая проверка IP или домена в списках и community (entry + snapshot)"
|
description="Мастер: проверка IP / CIDR / домена в списках и community, при отсутствии — добавление"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<LookupSearchForm
|
<LookupWizard
|
||||||
key={q}
|
q={q}
|
||||||
initialQuery={q}
|
onSubmitQuery={(next) => void navigate({ search: { q: next } })}
|
||||||
isPending={lookupQ.isFetching}
|
|
||||||
onSubmit={(next) => void navigate({ search: { q: next } })}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!q.trim() ? (
|
|
||||||
<EmptyState
|
|
||||||
icon={<Search className="size-8" />}
|
|
||||||
title="Введите IP или домен"
|
|
||||||
description="Например 8.8.8.8 или example.com — проверка по сырым entries и материализованным префиксам."
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<QueryState
|
|
||||||
data={lookupQ.data}
|
|
||||||
isLoading={lookupQ.isLoading}
|
|
||||||
isError={lookupQ.isError}
|
|
||||||
error={lookupQ.error}
|
|
||||||
onRetry={() => void lookupQ.refetch()}
|
|
||||||
skeleton={
|
|
||||||
<div className="flex flex-col gap-4 md:gap-6">
|
|
||||||
<SectionCardsSkeleton />
|
|
||||||
<TableSkeleton rows={5} />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(data) => (
|
|
||||||
<div className="flex flex-col gap-4 md:gap-6">
|
|
||||||
<LookupSummaryKpi data={data} />
|
|
||||||
{data.matched ? (
|
|
||||||
<LookupMatchesGrid items={data.matches} isLoading={lookupQ.isFetching} />
|
|
||||||
) : (
|
|
||||||
<EmptyState
|
|
||||||
icon={<Search className="size-8" />}
|
|
||||||
title="Не найдено в списках"
|
|
||||||
description={`«${data.normalized}» отсутствует в entries и snapshots tenant.`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</QueryState>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
import { createFileRoute, useSearch } from '@tanstack/react-router'
|
import { createFileRoute, useSearch } from '@tanstack/react-router'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { RefreshCw } from 'lucide-react'
|
import { AlertTriangle, GitBranch, ListTodo, Play, RefreshCw, RotateCcw } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useState, useMemo } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
import { PanelCard } from '@/components/panel-card'
|
import { PanelCard } from '@/components/panel-card'
|
||||||
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
|
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
|
||||||
import { FrameDataGrid } from '@/components/reui-kit'
|
import {
|
||||||
import { OperationsAnalyticsCard } from '@/components/analytics'
|
FrameDataGrid,
|
||||||
|
KpiStatGrid,
|
||||||
|
QuickActionGrid,
|
||||||
|
type KpiStatItem,
|
||||||
|
type QuickActionItem,
|
||||||
|
} from '@/components/reui-kit'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { SelectMenu } from '@/components/select-field'
|
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'
|
||||||
@@ -16,9 +22,14 @@ import { PageHeader } from '@/components/page-header'
|
|||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
|
|
||||||
import { operationsJobsQueryOptions, operationsRevisionsQueryOptions, operationsDiffQueryOptions } from '@/queries/operations'
|
import {
|
||||||
|
operationsDiffQueryOptions,
|
||||||
|
operationsJobsQueryOptions,
|
||||||
|
operationsRevisionsQueryOptions,
|
||||||
|
} from '@/queries/operations'
|
||||||
import { moduleNameById, overviewModulesQueryOptions } from '@/queries/overview'
|
import { moduleNameById, overviewModulesQueryOptions } from '@/queries/overview'
|
||||||
import { apiMutate, waitForJob } from '@/lib/api-client'
|
import { apiMutate, waitForJob } from '@/lib/api-client'
|
||||||
|
import type { JobRow, RevisionRow } from '@/types/api'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/operations')({
|
export const Route = createFileRoute('/_auth/operations')({
|
||||||
component: OperationsComponent,
|
component: OperationsComponent,
|
||||||
@@ -30,20 +41,74 @@ export const Route = createFileRoute('/_auth/operations')({
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function buildOperationsKpiItems(revisions: RevisionRow[], jobs: JobRow[]): KpiStatItem[] {
|
||||||
|
const running = jobs.filter((j) => ['running', 'queued'].includes(j.status.toLowerCase())).length
|
||||||
|
const failed = jobs.filter((j) =>
|
||||||
|
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
||||||
|
).length
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'revisions',
|
||||||
|
label: 'Ревизий',
|
||||||
|
value: revisions.length,
|
||||||
|
icon: <GitBranch className="size-4" aria-hidden />,
|
||||||
|
iconClassName: 'text-primary',
|
||||||
|
footer: (
|
||||||
|
<Badge variant="outline" size="sm">
|
||||||
|
в выборке
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'active-jobs',
|
||||||
|
label: 'Активных задач',
|
||||||
|
value: running,
|
||||||
|
icon: <ListTodo className="size-4" aria-hidden />,
|
||||||
|
iconClassName: running > 0 ? 'text-warning' : 'text-muted-foreground',
|
||||||
|
variant: running > 0 ? 'warning' : 'default',
|
||||||
|
footer: (
|
||||||
|
<Badge variant={running > 0 ? 'warning-light' : 'outline'} size="sm">
|
||||||
|
{running > 0 ? 'выполняются' : 'очередь пуста'}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'failed-jobs',
|
||||||
|
label: 'С ошибкой',
|
||||||
|
value: failed,
|
||||||
|
icon: <AlertTriangle className="size-4" aria-hidden />,
|
||||||
|
iconClassName: failed > 0 ? 'text-destructive' : 'text-muted-foreground',
|
||||||
|
variant: failed > 0 ? 'destructive' : 'default',
|
||||||
|
footer: (
|
||||||
|
<Badge variant={failed > 0 ? 'destructive-light' : 'success-light'} size="sm">
|
||||||
|
{failed > 0 ? 'требуют внимания' : 'в норме'}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
function OperationsComponent() {
|
function OperationsComponent() {
|
||||||
const search = useSearch({ from: '/_auth/operations' })
|
const search = useSearch({ from: '/_auth/operations' })
|
||||||
const navigate = Route.useNavigate()
|
const navigate = Route.useNavigate()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
|
||||||
|
const [applyOpen, setApplyOpen] = useState(false)
|
||||||
|
const [reloadOpen, setReloadOpen] = useState(false)
|
||||||
|
|
||||||
const revisionsQ = useQuery(operationsRevisionsQueryOptions())
|
const revisionsQ = useQuery(operationsRevisionsQueryOptions())
|
||||||
const jobsQ = useQuery(operationsJobsQueryOptions())
|
const jobsQ = useQuery(operationsJobsQueryOptions())
|
||||||
const modulesQ = useQuery(overviewModulesQueryOptions())
|
const modulesQ = useQuery(overviewModulesQueryOptions())
|
||||||
|
|
||||||
const revisions = revisionsQ.data?.items ?? []
|
const revisions = useMemo(() => revisionsQ.data?.items ?? [], [revisionsQ.data?.items])
|
||||||
const jobs = jobsQ.data?.items ?? []
|
const jobs = useMemo(() => jobsQ.data?.items ?? [], [jobsQ.data?.items])
|
||||||
const nameById = moduleNameById(modulesQ.data?.items ?? [])
|
const nameById = moduleNameById(modulesQ.data?.items ?? [])
|
||||||
|
|
||||||
const refreshing = revisionsQ.isFetching || jobsQ.isFetching
|
const refreshing = revisionsQ.isFetching || jobsQ.isFetching
|
||||||
|
const kpiLoading = revisionsQ.isLoading || jobsQ.isLoading
|
||||||
|
|
||||||
|
const kpiItems = useMemo(() => buildOperationsKpiItems(revisions, jobs), [revisions, jobs])
|
||||||
|
|
||||||
function refetchAll() {
|
function refetchAll() {
|
||||||
void revisionsQ.refetch()
|
void revisionsQ.refetch()
|
||||||
@@ -63,6 +128,7 @@ function OperationsComponent() {
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Конфигурация успешно применена')
|
toast.success('Конфигурация успешно применена')
|
||||||
|
setApplyOpen(false)
|
||||||
void qc.invalidateQueries({ queryKey: ['operations'] })
|
void qc.invalidateQueries({ queryKey: ['operations'] })
|
||||||
},
|
},
|
||||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось применить'),
|
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось применить'),
|
||||||
@@ -76,12 +142,41 @@ function OperationsComponent() {
|
|||||||
if (job.status !== 'succeeded') throw new Error(job.error ?? job.status)
|
if (job.status !== 'succeeded') throw new Error(job.error ?? job.status)
|
||||||
return job
|
return job
|
||||||
},
|
},
|
||||||
onSuccess: () => toast.success('Команда birdc configure выполнена'),
|
onSuccess: () => {
|
||||||
|
toast.success('Команда birdc configure выполнена')
|
||||||
|
setReloadOpen(false)
|
||||||
|
},
|
||||||
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-6">
|
<div className="flex flex-col gap-4 md:gap-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Ревизии и операции"
|
title="Ревизии и операции"
|
||||||
description="Деплой конфигурации, управление ревизиями и задачами"
|
description="Деплой конфигурации, управление ревизиями и задачами"
|
||||||
@@ -93,36 +188,31 @@ function OperationsComponent() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<KpiStatGrid items={kpiItems} isLoading={kpiLoading} skeletonCount={3} />
|
||||||
<ConfirmDialog
|
|
||||||
trigger={
|
|
||||||
<Button variant="default" size="sm" disabled={applyMutation.isPending}>
|
|
||||||
Применить
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
title="Применить конфигурацию на всех спикерах?"
|
|
||||||
description="Текущая конфигурация будет применена на всех BIRD-агентах. Требуется роль оператора."
|
|
||||||
confirmLabel="Применить"
|
|
||||||
onConfirm={() => applyMutation.mutate()}
|
|
||||||
/>
|
|
||||||
<ConfirmDialog
|
|
||||||
trigger={
|
|
||||||
<Button variant="outline" size="sm" disabled={birdReloadMutation.isPending}>
|
|
||||||
Перезагрузка BIRD
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
title="Перезагрузить BIRD?"
|
|
||||||
description="BIRD перезагрузит конфигурацию. Требуется роль оператора."
|
|
||||||
confirmLabel="Перезагрузить"
|
|
||||||
onConfirm={() => birdReloadMutation.mutate()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{revisionsQ.isLoading ? (
|
<QuickActionGrid
|
||||||
<OperationsAnalyticsCard jobs={[]} revisions={[]} loading />
|
actions={quickActions}
|
||||||
) : (
|
description="Деплой конфигурации и управление локальным BIRD"
|
||||||
<OperationsAnalyticsCard jobs={jobs} revisions={revisions} />
|
/>
|
||||||
)}
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={applyOpen}
|
||||||
|
onOpenChange={setApplyOpen}
|
||||||
|
title="Применить конфигурацию на всех спикерах?"
|
||||||
|
description="Текущая конфигурация будет применена на всех BIRD-агентах. Требуется роль оператора."
|
||||||
|
confirmLabel="Применить"
|
||||||
|
confirmLoading={applyMutation.isPending}
|
||||||
|
onConfirm={() => applyMutation.mutate()}
|
||||||
|
/>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={reloadOpen}
|
||||||
|
onOpenChange={setReloadOpen}
|
||||||
|
title="Перезагрузить BIRD?"
|
||||||
|
description="BIRD перезагрузит конфигурацию. Требуется роль оператора."
|
||||||
|
confirmLabel="Перезагрузить"
|
||||||
|
confirmLoading={birdReloadMutation.isPending}
|
||||||
|
onConfirm={() => birdReloadMutation.mutate()}
|
||||||
|
/>
|
||||||
|
|
||||||
<BadgeTabs
|
<BadgeTabs
|
||||||
value={search.tab}
|
value={search.tab}
|
||||||
@@ -177,7 +267,7 @@ function OperationsComponent() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DiffTab({ revisions }: { revisions: import('@/types/api').RevisionRow[] }) {
|
function DiffTab({ revisions }: { revisions: RevisionRow[] }) {
|
||||||
const [a, setA] = useState('')
|
const [a, setA] = useState('')
|
||||||
const [b, setB] = useState('')
|
const [b, setB] = useState('')
|
||||||
const diffQ = useQuery(operationsDiffQueryOptions(a, b))
|
const diffQ = useQuery(operationsDiffQueryOptions(a, b))
|
||||||
@@ -192,41 +282,41 @@ function DiffTab({ revisions }: { revisions: import('@/types/api').RevisionRow[]
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelCard title="Сравнение ревизий" contentClassName="flex flex-col gap-4 py-4">
|
<PanelCard title="Сравнение ревизий" contentClassName="flex flex-col gap-4 py-4">
|
||||||
<div className="flex flex-wrap items-end gap-3">
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
<div className="flex w-full max-w-xs flex-col gap-1">
|
<div className="flex w-full max-w-xs flex-col gap-1">
|
||||||
<span className="text-xs text-muted-foreground">Ревизия A</span>
|
<span className="text-xs text-muted-foreground">Ревизия A</span>
|
||||||
<SelectMenu
|
<SelectMenu
|
||||||
items={revisionItems}
|
items={revisionItems}
|
||||||
value={a}
|
value={a}
|
||||||
placeholder="Выберите"
|
placeholder="Выберите"
|
||||||
onValueChange={(v) => v && setA(v)}
|
onValueChange={(v) => v && setA(v)}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div className="flex w-full max-w-xs flex-col gap-1">
|
|
||||||
<span className="text-xs text-muted-foreground">Ревизия B</span>
|
|
||||||
<SelectMenu
|
|
||||||
items={revisionItems}
|
|
||||||
value={b}
|
|
||||||
placeholder="Выберите"
|
|
||||||
onValueChange={(v) => v && setB(v)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button onClick={() => diffQ.refetch()} disabled={!a || !b || diffQ.isFetching}>
|
|
||||||
Сравнить
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex w-full max-w-xs flex-col gap-1">
|
||||||
|
<span className="text-xs text-muted-foreground">Ревизия B</span>
|
||||||
|
<SelectMenu
|
||||||
|
items={revisionItems}
|
||||||
|
value={b}
|
||||||
|
placeholder="Выберите"
|
||||||
|
onValueChange={(v) => v && setB(v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => diffQ.refetch()} disabled={!a || !b || diffQ.isFetching}>
|
||||||
|
Сравнить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<QueryState
|
<QueryState
|
||||||
data={diffQ.data}
|
data={diffQ.data}
|
||||||
isLoading={diffQ.isFetching}
|
isLoading={diffQ.isFetching}
|
||||||
isError={diffQ.isError}
|
isError={diffQ.isError}
|
||||||
error={diffQ.error}
|
error={diffQ.error}
|
||||||
empty={!diffQ.data}
|
empty={!diffQ.data}
|
||||||
emptyTitle="Выберите две ревизии"
|
emptyTitle="Выберите две ревизии"
|
||||||
onRetry={() => diffQ.refetch()}
|
onRetry={() => diffQ.refetch()}
|
||||||
>
|
>
|
||||||
{(diff) => <DiffView diff={diff} />}
|
{(diff) => <DiffView diff={diff} />}
|
||||||
</QueryState>
|
</QueryState>
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,11 +114,13 @@ export interface paths {
|
|||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* Проверка IP или домена в списках
|
* Проверка IP, CIDR или домена в списках
|
||||||
* @description Быстрая membership-проверка по tenant:
|
* @description Быстрая membership-проверка по tenant:
|
||||||
*
|
*
|
||||||
* - **IP** — слой `entry` (`IP_RANGES`, `CIDR.Contains`) и слой `snapshot`
|
* - **IP** — слой `entry` (`IP_RANGES`, `CIDR.Contains`) и слой `snapshot`
|
||||||
* (все module prefix snapshots, `Prefix.Contains`);
|
* (все module prefix snapshots, `Prefix.Contains`);
|
||||||
|
* - **CIDR** — слой `entry` (`IP_RANGES`, равенство masked-префикса или покрытие
|
||||||
|
* запрошенной сети) и слой `snapshot` (то же правило);
|
||||||
* - **Domain** — слой `entry` (нормализованный FQDN в `DOMAINS`) и слой `snapshot`
|
* - **Domain** — слой `entry` (нормализованный FQDN в `DOMAINS`) и слой `snapshot`
|
||||||
* (префиксы `source=domain` у matched DOMAINS-модулей, если snapshot есть);
|
* (префиксы `source=domain` у matched DOMAINS-модулей, если snapshot есть);
|
||||||
* затем **live DNS resolve** (A/AAAA через системный резолвер) и проверка
|
* затем **live DNS resolve** (A/AAAA через системный резолвер) и проверка
|
||||||
@@ -2167,7 +2169,7 @@ export interface components {
|
|||||||
* @description Определённый тип запроса после нормализации.
|
* @description Определённый тип запроса после нормализации.
|
||||||
* @enum {string}
|
* @enum {string}
|
||||||
*/
|
*/
|
||||||
LookupQueryKind: "ip" | "domain";
|
LookupQueryKind: "ip" | "domain" | "cidr";
|
||||||
/**
|
/**
|
||||||
* @description `entry` — сырые IP_RANGES / DOMAINS entries;
|
* @description `entry` — сырые IP_RANGES / DOMAINS entries;
|
||||||
* `snapshot` — материализованные префиксы `module_prefix_snapshot`.
|
* `snapshot` — материализованные префиксы `module_prefix_snapshot`.
|
||||||
@@ -2206,7 +2208,7 @@ export interface components {
|
|||||||
/** @description Исходная строка запроса. */
|
/** @description Исходная строка запроса. */
|
||||||
query: string;
|
query: string;
|
||||||
query_kind: components["schemas"]["LookupQueryKind"];
|
query_kind: components["schemas"]["LookupQueryKind"];
|
||||||
/** @description Нормализованный IP или FQDN. */
|
/** @description Нормализованный IP, masked CIDR или FQDN. */
|
||||||
normalized: string;
|
normalized: string;
|
||||||
/** @description true, если есть хотя бы одно совпадение. */
|
/** @description true, если есть хотя бы одно совпадение. */
|
||||||
matched: boolean;
|
matched: boolean;
|
||||||
@@ -3033,7 +3035,7 @@ export interface operations {
|
|||||||
lookupMembership: {
|
lookupMembership: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query: {
|
query: {
|
||||||
/** @description IP-адрес или FQDN для проверки. */
|
/** @description IP-адрес, CIDR или FQDN для проверки. */
|
||||||
q: string;
|
q: string;
|
||||||
};
|
};
|
||||||
header?: {
|
header?: {
|
||||||
|
|||||||
@@ -162,8 +162,8 @@ export type BgpCommunityPatch = Partial<BgpCommunityCreate>
|
|||||||
export type CommunitiesResponse = Page<BgpCommunity>
|
export type CommunitiesResponse = Page<BgpCommunity>
|
||||||
|
|
||||||
// ---- Lookup (GET /v1/lookup) ----
|
// ---- Lookup (GET /v1/lookup) ----
|
||||||
/** @see https://reui.io/preview/base/stats-12 — KPI summary on /lookup */
|
/** @see https://reui.io/preview/base/wizard-2 — lookup membership wizard */
|
||||||
export type LookupQueryKind = 'ip' | 'domain'
|
export type LookupQueryKind = 'ip' | 'domain' | 'cidr'
|
||||||
export type LookupLayer = 'entry' | 'snapshot'
|
export type LookupLayer = 'entry' | 'snapshot'
|
||||||
export type LookupMatchKind = 'ip_range' | 'domain' | 'prefix'
|
export type LookupMatchKind = 'ip_range' | 'domain' | 'prefix'
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+6
-4
@@ -789,7 +789,7 @@ components:
|
|||||||
|
|
||||||
LookupQueryKind:
|
LookupQueryKind:
|
||||||
type: string
|
type: string
|
||||||
enum: [ip, domain]
|
enum: [ip, domain, cidr]
|
||||||
description: Определённый тип запроса после нормализации.
|
description: Определённый тип запроса после нормализации.
|
||||||
|
|
||||||
LookupLayer:
|
LookupLayer:
|
||||||
@@ -864,7 +864,7 @@ components:
|
|||||||
$ref: "#/components/schemas/LookupQueryKind"
|
$ref: "#/components/schemas/LookupQueryKind"
|
||||||
normalized:
|
normalized:
|
||||||
type: string
|
type: string
|
||||||
description: Нормализованный IP или FQDN.
|
description: Нормализованный IP, masked CIDR или FQDN.
|
||||||
matched:
|
matched:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: true, если есть хотя бы одно совпадение.
|
description: true, если есть хотя бы одно совпадение.
|
||||||
@@ -2028,12 +2028,14 @@ paths:
|
|||||||
/v1/lookup:
|
/v1/lookup:
|
||||||
get:
|
get:
|
||||||
tags: [Lookup]
|
tags: [Lookup]
|
||||||
summary: Проверка IP или домена в списках
|
summary: Проверка IP, CIDR или домена в списках
|
||||||
description: |
|
description: |
|
||||||
Быстрая membership-проверка по tenant:
|
Быстрая membership-проверка по tenant:
|
||||||
|
|
||||||
- **IP** — слой `entry` (`IP_RANGES`, `CIDR.Contains`) и слой `snapshot`
|
- **IP** — слой `entry` (`IP_RANGES`, `CIDR.Contains`) и слой `snapshot`
|
||||||
(все module prefix snapshots, `Prefix.Contains`);
|
(все module prefix snapshots, `Prefix.Contains`);
|
||||||
|
- **CIDR** — слой `entry` (`IP_RANGES`, равенство masked-префикса или покрытие
|
||||||
|
запрошенной сети) и слой `snapshot` (то же правило);
|
||||||
- **Domain** — слой `entry` (нормализованный FQDN в `DOMAINS`) и слой `snapshot`
|
- **Domain** — слой `entry` (нормализованный FQDN в `DOMAINS`) и слой `snapshot`
|
||||||
(префиксы `source=domain` у matched DOMAINS-модулей, если snapshot есть);
|
(префиксы `source=domain` у matched DOMAINS-модулей, если snapshot есть);
|
||||||
затем **live DNS resolve** (A/AAAA через системный резолвер) и проверка
|
затем **live DNS resolve** (A/AAAA через системный резолвер) и проверка
|
||||||
@@ -2054,7 +2056,7 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
minLength: 1
|
minLength: 1
|
||||||
maxLength: 253
|
maxLength: 253
|
||||||
description: IP-адрес или FQDN для проверки.
|
description: IP-адрес, CIDR или FQDN для проверки.
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Результат проверки (в т.ч. matched=false при отсутствии совпадений).
|
description: Результат проверки (в т.ч. matched=false при отсутствии совпадений).
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ func (s *Server) handleLookup(w http.ResponseWriter, r *http.Request) {
|
|||||||
res, err := lookup.Lookup(r.Context(), s.store, a.TenantID, q)
|
res, err := lookup.Lookup(r.Context(), s.store, a.TenantID, q)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, store.ErrInvalidInput) {
|
if errors.Is(err, store.ErrInvalidInput) {
|
||||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "query must be an IP address or FQDN")
|
writeProblem(w, http.StatusBadRequest, "Bad Request", "query must be an IP address, CIDR, or FQDN")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type QueryKind string
|
|||||||
const (
|
const (
|
||||||
KindIP QueryKind = "ip"
|
KindIP QueryKind = "ip"
|
||||||
KindDomain QueryKind = "domain"
|
KindDomain QueryKind = "domain"
|
||||||
|
KindCIDR QueryKind = "cidr"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Layer identifies which data source produced a match.
|
// Layer identifies which data source produced a match.
|
||||||
@@ -69,7 +70,7 @@ type Result struct {
|
|||||||
// DomainResolver resolves a hostname to IP addresses (A/AAAA).
|
// DomainResolver resolves a hostname to IP addresses (A/AAAA).
|
||||||
type DomainResolver func(ctx context.Context, host string) ([]netip.Addr, error)
|
type DomainResolver func(ctx context.Context, host string) ([]netip.Addr, error)
|
||||||
|
|
||||||
// Lookup checks whether q (IP or FQDN) is present in tenant lists (entries + snapshots).
|
// Lookup checks whether q (IP, CIDR, or FQDN) is present in tenant lists (entries + snapshots).
|
||||||
// For domains, FQDN membership is checked first, then live DNS resolve and IP membership.
|
// For domains, FQDN membership is checked first, then live DNS resolve and IP membership.
|
||||||
func Lookup(ctx context.Context, st store.Backend, tenantID, q string) (*Result, error) {
|
func Lookup(ctx context.Context, st store.Backend, tenantID, q string) (*Result, error) {
|
||||||
return LookupWithResolver(ctx, st, tenantID, q, systemDNSResolver)
|
return LookupWithResolver(ctx, st, tenantID, q, systemDNSResolver)
|
||||||
@@ -109,10 +110,17 @@ func LookupWithResolver(
|
|||||||
if err := lookupIP(st, tenantID, addr, out, commByID, ""); err != nil {
|
if err := lookupIP(st, tenantID, addr, out, commByID, ""); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
} else if pfx, err := netip.ParsePrefix(raw); err == nil {
|
||||||
|
masked := pfx.Masked()
|
||||||
|
out.QueryKind = KindCIDR
|
||||||
|
out.Normalized = masked.String()
|
||||||
|
if err := lookupCIDR(st, tenantID, masked, out, commByID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
fqdn, ok := normalizeFQDN(raw)
|
fqdn, ok := normalizeFQDN(raw)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("%w: query must be an IP address or FQDN", store.ErrInvalidInput)
|
return nil, fmt.Errorf("%w: query must be an IP address, CIDR, or FQDN", store.ErrInvalidInput)
|
||||||
}
|
}
|
||||||
out.QueryKind = KindDomain
|
out.QueryKind = KindDomain
|
||||||
out.Normalized = fqdn
|
out.Normalized = fqdn
|
||||||
@@ -250,6 +258,86 @@ func lookupIP(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// prefixCoversQuery reports whether listPfx equals query or fully contains it.
|
||||||
|
func prefixCoversQuery(listPfx, query netip.Prefix) bool {
|
||||||
|
listPfx = listPfx.Masked()
|
||||||
|
query = query.Masked()
|
||||||
|
if listPfx == query {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return listPfx.Contains(query.Addr()) && listPfx.Bits() <= query.Bits()
|
||||||
|
}
|
||||||
|
|
||||||
|
func lookupCIDR(
|
||||||
|
st store.Backend,
|
||||||
|
tenantID string,
|
||||||
|
query netip.Prefix,
|
||||||
|
out *Result,
|
||||||
|
commByID map[string]*store.Community,
|
||||||
|
) error {
|
||||||
|
for _, mod := range st.ListModules(tenantID) {
|
||||||
|
if mod == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if mod.Type == "IP_RANGES" {
|
||||||
|
entries, err := st.ListIPRangeEntries(tenantID, mod.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if e == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pfx, err := netip.ParsePrefix(strings.TrimSpace(e.Prefix))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !prefixCoversQuery(pfx, query) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.Matches = append(out.Matches, decorateMatch(Match{
|
||||||
|
Layer: LayerEntry,
|
||||||
|
ModuleID: mod.ID,
|
||||||
|
ModuleName: mod.Name,
|
||||||
|
ModuleType: mod.Type,
|
||||||
|
MatchKind: MatchIPRange,
|
||||||
|
MatchedValue: e.Prefix,
|
||||||
|
EntryID: e.ID,
|
||||||
|
CommunityID: resolveCommunityID(e.CommunityID, mod.DefaultCommunityID),
|
||||||
|
}, commByID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
snap, ok, err := st.GetModulePrefixSnapshot(tenantID, mod.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !ok || snap == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, row := range snap.Prefixes {
|
||||||
|
pfx, err := netip.ParsePrefix(strings.TrimSpace(row.Prefix))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !prefixCoversQuery(pfx, query) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.Matches = append(out.Matches, decorateMatch(Match{
|
||||||
|
Layer: LayerSnapshot,
|
||||||
|
ModuleID: mod.ID,
|
||||||
|
ModuleName: mod.Name,
|
||||||
|
ModuleType: mod.Type,
|
||||||
|
MatchKind: MatchPrefix,
|
||||||
|
MatchedValue: row.Prefix,
|
||||||
|
Source: row.Source,
|
||||||
|
CommunityID: row.CommunityID,
|
||||||
|
}, commByID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func lookupDomain(st store.Backend, tenantID, fqdn string, out *Result, commByID map[string]*store.Community) error {
|
func lookupDomain(st store.Backend, tenantID, fqdn string, out *Result, commByID map[string]*store.Community) error {
|
||||||
matchedModuleIDs := make(map[string]*store.Module)
|
matchedModuleIDs := make(map[string]*store.Module)
|
||||||
|
|
||||||
|
|||||||
@@ -220,6 +220,60 @@ func TestLookupDomainResolvedIPAgainstRanges(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLookupCIDREntryAndSnapshot(t *testing.T) {
|
||||||
|
m := store.NewMemory()
|
||||||
|
m.SeedDemo()
|
||||||
|
tenant, _, modIP, _, _ := m.DemoIDs()
|
||||||
|
comms, _ := m.ListCommunities(tenant)
|
||||||
|
cid := comms[0].ID
|
||||||
|
|
||||||
|
e, err := m.CreateIPRangeEntry(tenant, modIP, &store.IPRangeEntry{
|
||||||
|
Prefix: "203.0.113.0/24",
|
||||||
|
CommunityID: &cid,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.SetModulePrefixSnapshot(tenant, modIP, "hash-cidr", []store.PrefixRow{
|
||||||
|
{Prefix: "203.0.113.0/24", CommunityID: &cid, Source: "ip_range"},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := Lookup(context.Background(), m, tenant, "203.0.113.0/24")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.QueryKind != KindCIDR || res.Normalized != "203.0.113.0/24" {
|
||||||
|
t.Fatalf("kind/normalized: %+v", res)
|
||||||
|
}
|
||||||
|
if !res.Matched || res.MatchCount < 2 {
|
||||||
|
t.Fatalf("expected entry+snapshot, got %+v", res)
|
||||||
|
}
|
||||||
|
|
||||||
|
var entryHit, snapHit bool
|
||||||
|
for _, hit := range res.Matches {
|
||||||
|
if hit.Layer == LayerEntry && hit.EntryID == e.ID {
|
||||||
|
entryHit = true
|
||||||
|
}
|
||||||
|
if hit.Layer == LayerSnapshot && hit.MatchedValue == "203.0.113.0/24" {
|
||||||
|
snapHit = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !entryHit || !snapHit {
|
||||||
|
t.Fatalf("entry=%v snap=%v matches=%+v", entryHit, snapHit, res.Matches)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Narrower query covered by wider entry.
|
||||||
|
res2, err := Lookup(context.Background(), m, tenant, "203.0.113.128/25")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res2.QueryKind != KindCIDR || !res2.Matched {
|
||||||
|
t.Fatalf("expected cover match: %+v", res2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLookupNoMatch(t *testing.T) {
|
func TestLookupNoMatch(t *testing.T) {
|
||||||
m := store.NewMemory()
|
m := store.NewMemory()
|
||||||
m.SeedDemo()
|
m.SeedDemo()
|
||||||
|
|||||||
Reference in New Issue
Block a user