Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f861d0fa8c | ||
|
|
2f914bcc65 | ||
|
|
7a10138990 | ||
|
|
391f34a53d | ||
|
|
ecd48642de | ||
|
|
09580bbcab | ||
|
|
4d54dacd05 | ||
|
|
a3562cc11e | ||
|
|
95d4305241 | ||
|
|
dc803bcb34 |
@@ -52,8 +52,10 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- if: ${{ inputs.is_pull_request == false }}
|
- if: ${{ inputs.is_pull_request == false }}
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
# Полная история: BEFORE_SHA = github.event.before при multi-commit push
|
||||||
|
# лежит глубже shallow-среза, и git diff падает с "bad object".
|
||||||
with:
|
with:
|
||||||
fetch-depth: 2
|
fetch-depth: 0
|
||||||
- id: detect
|
- id: detect
|
||||||
name: Detect changed paths per module
|
name: Detect changed paths per module
|
||||||
env:
|
env:
|
||||||
@@ -94,7 +96,14 @@ jobs:
|
|||||||
after="${HEAD_SHA:-$(git rev-parse HEAD)}"
|
after="${HEAD_SHA:-$(git rev-parse HEAD)}"
|
||||||
before="$BEFORE_SHA"
|
before="$BEFORE_SHA"
|
||||||
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; then
|
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; then
|
||||||
|
# Force-push мог отбросить before; отсутствие объекта — не ошибка,
|
||||||
|
# а сигнал уйти в полный прогон через пустой diff.
|
||||||
|
if git cat-file -e "$before^{commit}" 2>/dev/null; then
|
||||||
FILES="$(git diff --name-only "$before" "$after")"
|
FILES="$(git diff --name-only "$before" "$after")"
|
||||||
|
else
|
||||||
|
echo "BEFORE_SHA $before not found in checkout — full pipeline fallback"
|
||||||
|
FILES=""
|
||||||
|
fi
|
||||||
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
|
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
|
||||||
FILES="$(git diff --name-only HEAD~1 HEAD)"
|
FILES="$(git diff --name-only HEAD~1 HEAD)"
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
"react-hook-form": "^7.60.0",
|
"react-hook-form": "^7.60.0",
|
||||||
"recharts": "3.8.0",
|
"recharts": "3.8.0",
|
||||||
"shadcn": "^4.19.0",
|
"shadcn": "^4.19.0",
|
||||||
|
"shiki": "^4.4.3",
|
||||||
"sonner": "^1.7.0",
|
"sonner": "^1.7.0",
|
||||||
"zod": "^3.25.0"
|
"zod": "^3.25.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function AnalyticsCardShell({
|
|||||||
actions={actions}
|
actions={actions}
|
||||||
footer={footer}
|
footer={footer}
|
||||||
className={cn('overflow-hidden', className)}
|
className={cn('overflow-hidden', className)}
|
||||||
contentClassName="flex flex-col gap-5 py-5"
|
contentClassName="flex flex-col gap-5 px-5 py-5"
|
||||||
footerClassName={footer ? 'gap-2 px-5 py-4' : undefined}
|
footerClassName={footer ? 'gap-2 px-5 py-4' : undefined}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
import { AlertTriangle, CheckCircle, Info } from 'lucide-react'
|
|
||||||
|
|
||||||
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
|
|
||||||
import { Badge } from '@/components/reui/badge'
|
|
||||||
import {
|
|
||||||
Timeline,
|
|
||||||
TimelineContent,
|
|
||||||
TimelineHeader,
|
|
||||||
TimelineIndicator,
|
|
||||||
TimelineItem,
|
|
||||||
TimelineSeparator,
|
|
||||||
TimelineTitle,
|
|
||||||
} from '@/components/reui/timeline'
|
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
|
||||||
|
|
||||||
import { recentPlatformActivity } from '@/lib/metrics'
|
|
||||||
import type { JobRow, PeerRow, RevisionRow, SpeakerRow } from '@/types/api'
|
|
||||||
|
|
||||||
const KIND_META = {
|
|
||||||
job: { icon: Info, className: 'text-info' },
|
|
||||||
revision: { icon: CheckCircle, className: 'text-success' },
|
|
||||||
network: { icon: AlertTriangle, className: 'text-warning' },
|
|
||||||
} as const
|
|
||||||
|
|
||||||
function statusBadgeVariant(status: string) {
|
|
||||||
const s = status.toLowerCase()
|
|
||||||
if (['ok', 'success', 'completed', 'done'].includes(s)) return 'success-light' as const
|
|
||||||
if (['running', 'queued', 'pending'].includes(s)) return 'info-light' as const
|
|
||||||
if (['warning', 'mismatch'].includes(s)) return 'warning-light' as const
|
|
||||||
if (['failed', 'error', 'cancelled'].includes(s)) return 'destructive-light' as const
|
|
||||||
return 'outline' as const
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DashboardActivityTimeline({
|
|
||||||
jobs,
|
|
||||||
revisions,
|
|
||||||
peers,
|
|
||||||
speakers,
|
|
||||||
loading,
|
|
||||||
}: {
|
|
||||||
jobs: JobRow[]
|
|
||||||
revisions: RevisionRow[]
|
|
||||||
peers: PeerRow[]
|
|
||||||
speakers: SpeakerRow[]
|
|
||||||
loading?: boolean
|
|
||||||
}) {
|
|
||||||
const items = recentPlatformActivity(jobs, revisions, peers, speakers, 6)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DashboardFramePanel
|
|
||||||
title="Недавняя активность"
|
|
||||||
description="Задачи, ревизии и сетевые события"
|
|
||||||
className="h-full min-w-0"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<p className="text-muted-foreground px-4 py-6 text-sm">Загрузка…</p>
|
|
||||||
) : items.length === 0 ? (
|
|
||||||
<p className="text-muted-foreground px-4 py-6 text-sm">Нет недавних событий</p>
|
|
||||||
) : (
|
|
||||||
<div className="px-4 py-4">
|
|
||||||
<Timeline defaultValue={items.length}>
|
|
||||||
{items.map((item, index) => {
|
|
||||||
const meta = KIND_META[item.kind]
|
|
||||||
const Icon = meta.icon
|
|
||||||
return (
|
|
||||||
<TimelineItem
|
|
||||||
key={item.id}
|
|
||||||
step={index + 1}
|
|
||||||
className="group-data-[orientation=vertical]/timeline:ms-8 group-data-[orientation=vertical]/timeline:not-last:pb-4"
|
|
||||||
>
|
|
||||||
<TimelineHeader>
|
|
||||||
<TimelineSeparator className="bg-border! group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:top-2 group-data-[orientation=vertical]/timeline:h-[calc(100%-1.5rem)] group-data-[orientation=vertical]/timeline:translate-y-5" />
|
|
||||||
<TimelineIndicator className="border-none bg-transparent group-data-[orientation=vertical]/timeline:-left-6">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'bg-muted/70 flex size-7 items-center justify-center rounded-full',
|
|
||||||
meta.className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon className="size-3.5" aria-hidden />
|
|
||||||
</span>
|
|
||||||
</TimelineIndicator>
|
|
||||||
</TimelineHeader>
|
|
||||||
<TimelineContent className="min-w-0 pb-1 text-foreground">
|
|
||||||
<TimelineTitle className="text-sm leading-snug font-normal break-words">
|
|
||||||
{item.message}
|
|
||||||
</TimelineTitle>
|
|
||||||
<div className="mt-2">
|
|
||||||
<Badge variant={statusBadgeVariant(item.status)} size="sm">
|
|
||||||
{item.statusLabel ?? item.status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</TimelineContent>
|
|
||||||
</TimelineItem>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Timeline>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DashboardFramePanel>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import type { ReactNode } from 'react'
|
|
||||||
|
|
||||||
import {
|
|
||||||
FrameSection,
|
|
||||||
panelCardContentFlushClassName,
|
|
||||||
} from '@/components/reui-kit'
|
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
|
||||||
|
|
||||||
/** Frame panel for dashboard sections. Preview: https://reui.io/docs/components/base/frame */
|
|
||||||
export function DashboardFramePanel({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
actions,
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
contentClassName,
|
|
||||||
}: {
|
|
||||||
title?: string
|
|
||||||
description?: string
|
|
||||||
actions?: ReactNode
|
|
||||||
children: ReactNode
|
|
||||||
className?: string
|
|
||||||
contentClassName?: string
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<FrameSection
|
|
||||||
title={title}
|
|
||||||
description={description}
|
|
||||||
actions={actions}
|
|
||||||
className={cn('h-full', className)}
|
|
||||||
contentClassName={cn(panelCardContentFlushClassName, contentClassName)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</FrameSection>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -3,8 +3,6 @@ import {
|
|||||||
Boxes,
|
Boxes,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
Network,
|
Network,
|
||||||
ServerCog,
|
|
||||||
Share2,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
@@ -16,6 +14,11 @@ import type { JobRow, ModuleRow, PeerRow, SpeakerRow } from '@/types/api'
|
|||||||
|
|
||||||
type KpiCard = KpiStatItem & { icon: ReactNode }
|
type KpiCard = KpiStatItem & { icon: ReactNode }
|
||||||
|
|
||||||
|
function ratioPercent(part: number, total: number): number | undefined {
|
||||||
|
if (total <= 0) return undefined
|
||||||
|
return Math.round((part / total) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
function buildKpis({
|
function buildKpis({
|
||||||
modules,
|
modules,
|
||||||
peers,
|
peers,
|
||||||
@@ -40,17 +43,34 @@ function buildKpis({
|
|||||||
).length
|
).length
|
||||||
const offlineSpeakers = Math.max(0, speakers.length - network.speakersOnline)
|
const offlineSpeakers = Math.max(0, speakers.length - network.speakersOnline)
|
||||||
const riskCount = network.peersMismatch + failedJobs + offlineSpeakers
|
const riskCount = network.peersMismatch + failedJobs + offlineSpeakers
|
||||||
|
const disabledModules = Math.max(0, modules.length - enabledModules)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: 'modules',
|
id: 'modules',
|
||||||
icon: <Boxes aria-hidden />,
|
icon: <Boxes aria-hidden />,
|
||||||
iconClassName: 'text-primary',
|
iconClassName: 'text-primary',
|
||||||
value: loading ? '—' : `${enabledModules}/${modules.length || 0}`,
|
value: loading ? '—' : enabledModules,
|
||||||
label: 'Модули активны',
|
label: 'Модули активны',
|
||||||
|
progress: loading ? undefined : ratioPercent(enabledModules, modules.length),
|
||||||
footer: (
|
footer: (
|
||||||
<Badge variant="primary-light" size="sm">
|
<Badge
|
||||||
{loading ? '…' : `${modules.length} всего`}
|
variant={
|
||||||
|
loading || modules.length === 0
|
||||||
|
? 'outline'
|
||||||
|
: disabledModules === 0
|
||||||
|
? 'success-light'
|
||||||
|
: 'warning-light'
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{loading
|
||||||
|
? '…'
|
||||||
|
: modules.length === 0
|
||||||
|
? 'нет модулей'
|
||||||
|
: disabledModules === 0
|
||||||
|
? 'все активны'
|
||||||
|
: `${disabledModules} выкл`}
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -60,6 +80,7 @@ function buildKpis({
|
|||||||
iconClassName: 'text-info',
|
iconClassName: 'text-info',
|
||||||
value: loading || bgpPct === null ? '—' : `${bgpPct}%`,
|
value: loading || bgpPct === null ? '—' : `${bgpPct}%`,
|
||||||
label: 'BGP готовность',
|
label: 'BGP готовность',
|
||||||
|
progress: loading || bgpPct === null ? undefined : bgpPct,
|
||||||
footer: (
|
footer: (
|
||||||
<Badge
|
<Badge
|
||||||
variant={
|
variant={
|
||||||
@@ -73,37 +94,12 @@ function buildKpis({
|
|||||||
>
|
>
|
||||||
{loading || bgpPct === null
|
{loading || bgpPct === null
|
||||||
? 'нет включённых пиров'
|
? 'нет включённых пиров'
|
||||||
|
: bgpPct >= 90
|
||||||
|
? 'сессии в норме'
|
||||||
: `${network.peersEstablished} установлено`}
|
: `${network.peersEstablished} установлено`}
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'peers',
|
|
||||||
icon: <Share2 aria-hidden />,
|
|
||||||
iconClassName: 'text-success',
|
|
||||||
value: loading ? '—' : `${network.peersEstablished}/${peersEnabled}`,
|
|
||||||
label: 'Пиры установлены',
|
|
||||||
footer: (
|
|
||||||
<Badge variant="success-light" size="sm">
|
|
||||||
{loading ? '…' : `${network.peersTotal} в каталоге`}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'speakers',
|
|
||||||
icon: <ServerCog aria-hidden />,
|
|
||||||
iconClassName: 'text-warning',
|
|
||||||
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
|
|
||||||
label: 'Спикеры в сети',
|
|
||||||
footer: (
|
|
||||||
<Badge
|
|
||||||
variant={network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light'}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{loading ? '…' : 'в сети'}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'jobs',
|
id: 'jobs',
|
||||||
icon: <ListChecks aria-hidden />,
|
icon: <ListChecks aria-hidden />,
|
||||||
@@ -112,7 +108,7 @@ function buildKpis({
|
|||||||
label: 'Активные задачи',
|
label: 'Активные задачи',
|
||||||
footer: (
|
footer: (
|
||||||
<Badge variant={running > 0 ? 'info-light' : 'outline'} size="sm">
|
<Badge variant={running > 0 ? 'info-light' : 'outline'} size="sm">
|
||||||
{loading ? '…' : `${jobs.length} в выборке`}
|
{loading ? '…' : running > 0 ? 'выполняются' : 'очередь пуста'}
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
import { useMemo } from 'react'
|
|
||||||
|
|
||||||
import { KpiSparklineCard, type KpiSparklineMetric } from '@/components/patterns/kpi-sparkline-card'
|
|
||||||
import { kpiGridClassName } from '@/lib/ui-surface'
|
|
||||||
import { aggregateNetworkMetrics, runningJobCount } from '@/queries/overview'
|
|
||||||
import type { JobRow, ModuleRow, PeerRow, SpeakerRow } from '@/types/api'
|
|
||||||
|
|
||||||
function syntheticSparkline(seed: number, points = 9): number[] {
|
|
||||||
const base = Math.max(4, seed)
|
|
||||||
return Array.from({ length: points }, (_, i) =>
|
|
||||||
Math.round(base * (0.82 + (i / points) * 0.18 + Math.sin(i + seed) * 0.04)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildMetrics({
|
|
||||||
modules,
|
|
||||||
peers,
|
|
||||||
speakers,
|
|
||||||
jobs,
|
|
||||||
loading,
|
|
||||||
}: {
|
|
||||||
modules: ModuleRow[]
|
|
||||||
peers: PeerRow[]
|
|
||||||
speakers: SpeakerRow[]
|
|
||||||
jobs: JobRow[]
|
|
||||||
loading?: boolean
|
|
||||||
}): KpiSparklineMetric[] {
|
|
||||||
const enabledModules = modules.filter((m) => m.enabled !== false).length
|
|
||||||
const network = aggregateNetworkMetrics(peers, speakers)
|
|
||||||
const bgpPct =
|
|
||||||
network.peersEnabled > 0
|
|
||||||
? Math.round((network.peersEstablished / network.peersEnabled) * 100)
|
|
||||||
: 0
|
|
||||||
const running = runningJobCount(jobs)
|
|
||||||
const failedJobs = jobs.filter((j) =>
|
|
||||||
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
|
||||||
).length
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: 'bgp',
|
|
||||||
title: 'BGP готовность',
|
|
||||||
label: 'Установлено / включено',
|
|
||||||
value: loading || network.peersEnabled === 0 ? '—' : `${bgpPct}%`,
|
|
||||||
delta: loading ? '…' : bgpPct >= 90 ? 'стабильно' : 'внимание',
|
|
||||||
deltaVariant: bgpPct >= 90 ? 'success-light' : bgpPct >= 50 ? 'warning-light' : 'destructive-light',
|
|
||||||
detail: loading ? '' : `${network.peersEstablished} сессий`,
|
|
||||||
tone: bgpPct >= 90 ? 'success' : bgpPct >= 50 ? 'warning' : 'danger',
|
|
||||||
sparkline: syntheticSparkline(bgpPct || 40),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'modules',
|
|
||||||
title: 'Модули',
|
|
||||||
label: 'Активные списки',
|
|
||||||
value: loading ? '—' : `${enabledModules}`,
|
|
||||||
delta: loading ? '…' : `${modules.length} всего`,
|
|
||||||
deltaVariant: 'primary-light',
|
|
||||||
detail: loading ? '' : 'маршрутизация',
|
|
||||||
tone: 'info',
|
|
||||||
sparkline: syntheticSparkline(enabledModules || 3),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'speakers',
|
|
||||||
title: 'Спикеры',
|
|
||||||
label: 'В сети / всего',
|
|
||||||
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
|
|
||||||
delta:
|
|
||||||
loading || network.speakersTotal === 0
|
|
||||||
? '…'
|
|
||||||
: network.speakersOnline === network.speakersTotal
|
|
||||||
? 'все в сети'
|
|
||||||
: 'частично',
|
|
||||||
deltaVariant:
|
|
||||||
network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light',
|
|
||||||
detail: loading ? '' : 'live-снимок',
|
|
||||||
tone: network.speakersOnline === network.speakersTotal ? 'success' : 'warning',
|
|
||||||
sparkline: syntheticSparkline(network.speakersOnline || 2),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'jobs',
|
|
||||||
title: 'Задачи',
|
|
||||||
label: 'Активные / ошибки',
|
|
||||||
value: loading ? '—' : String(running),
|
|
||||||
delta: loading ? '…' : failedJobs > 0 ? `${failedJobs} ошибок` : 'без сбоев',
|
|
||||||
deltaVariant: failedJobs > 0 ? 'destructive-light' : 'success-light',
|
|
||||||
detail: loading ? '' : `${jobs.length} в выборке`,
|
|
||||||
tone: failedJobs > 0 ? 'danger' : running > 0 ? 'info' : 'success',
|
|
||||||
sparkline: syntheticSparkline(running + failedJobs || 1),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DashboardKpiSparklineRow({
|
|
||||||
modules,
|
|
||||||
peers,
|
|
||||||
speakers,
|
|
||||||
jobs,
|
|
||||||
loading,
|
|
||||||
}: {
|
|
||||||
modules: ModuleRow[]
|
|
||||||
peers: PeerRow[]
|
|
||||||
speakers: SpeakerRow[]
|
|
||||||
jobs: JobRow[]
|
|
||||||
loading?: boolean
|
|
||||||
}) {
|
|
||||||
const metrics = useMemo(
|
|
||||||
() => buildMetrics({ modules, peers, speakers, jobs, loading }),
|
|
||||||
[modules, peers, speakers, jobs, loading],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section aria-label="KPI обзора" className={kpiGridClassName}>
|
|
||||||
{metrics.map((metric) => (
|
|
||||||
<KpiSparklineCard key={metric.id} metric={metric} />
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
import { useMemo, useState } from 'react'
|
|
||||||
import { Link, useNavigate } from '@tanstack/react-router'
|
|
||||||
import { BoxesIcon, PlusIcon, SearchIcon } from 'lucide-react'
|
|
||||||
|
|
||||||
import { CategoryBadge } from '@/components/category-badge'
|
|
||||||
import { DataGridNameCell } from '@/components/data-grid-cell'
|
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
|
||||||
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
|
||||||
import {
|
|
||||||
ResourcePage,
|
|
||||||
createTextFilterQuery,
|
|
||||||
type DataGridColumnDef,
|
|
||||||
} from '@/components/reui-kit'
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
|
||||||
import { moduleTypeRu } from '@/lib/ui-labels'
|
|
||||||
import type { ModuleRow } from '@/types/api'
|
|
||||||
|
|
||||||
const MODULE_TABS = [
|
|
||||||
{ id: 'all', label: 'Все' },
|
|
||||||
{ id: 'enabled', label: 'Вкл' },
|
|
||||||
{ id: 'disabled', label: 'Выкл' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const filterFields: FilterField[] = [
|
|
||||||
{
|
|
||||||
id: 'search',
|
|
||||||
label: 'Поиск',
|
|
||||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
|
||||||
type: 'text',
|
|
||||||
placeholder: 'Поиск модулей…',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
function getFilterFieldValue(item: ModuleRow, field: string): unknown {
|
|
||||||
if (field === 'search') {
|
|
||||||
return `${item.name} ${item.type} ${moduleTypeRu(item.type)}`
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function tabFilter(item: ModuleRow, tabId: string): boolean {
|
|
||||||
if (tabId === 'enabled') return item.enabled !== false
|
|
||||||
if (tabId === 'disabled') return item.enabled === false
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DashboardModulesGrid({
|
|
||||||
modules,
|
|
||||||
isLoading = false,
|
|
||||||
}: {
|
|
||||||
modules: ModuleRow[]
|
|
||||||
isLoading?: boolean
|
|
||||||
}) {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
|
|
||||||
createTextFilterQuery('search'),
|
|
||||||
)
|
|
||||||
|
|
||||||
const columns = useMemo<DataGridColumnDef<ModuleRow>[]>(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
accessorKey: 'name',
|
|
||||||
id: 'name',
|
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Модуль" />,
|
|
||||||
cell: ({ row }) => <DataGridNameCell icon={BoxesIcon} title={row.original.name} />,
|
|
||||||
minSize: 180,
|
|
||||||
meta: { headerTitle: 'Модуль' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'type',
|
|
||||||
id: 'type',
|
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Тип" />,
|
|
||||||
cell: ({ row }) => <CategoryBadge>{moduleTypeRu(row.original.type)}</CategoryBadge>,
|
|
||||||
meta: { headerTitle: 'Тип' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'priority',
|
|
||||||
id: 'priority',
|
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Приоритет" />,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="font-mono text-sm tabular-nums">{row.original.priority}</span>
|
|
||||||
),
|
|
||||||
size: 88,
|
|
||||||
meta: { headerTitle: 'Приоритет' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ResourcePage
|
|
||||||
title="Модули"
|
|
||||||
description="Поиск и быстрый переход к настройке"
|
|
||||||
tabs={MODULE_TABS}
|
|
||||||
tabFilter={tabFilter}
|
|
||||||
filterFields={filterFields}
|
|
||||||
filterQuery={filterQuery}
|
|
||||||
onFilterQueryChange={setFilterQuery}
|
|
||||||
onClearFilters={() => setFilterQuery(createTextFilterQuery('search'))}
|
|
||||||
getFilterFieldValue={getFilterFieldValue}
|
|
||||||
columns={columns}
|
|
||||||
data={modules}
|
|
||||||
getRowId={(row) => row.id}
|
|
||||||
isLoading={isLoading}
|
|
||||||
primaryAction={
|
|
||||||
<Button variant="outline" size="sm" render={<Link to="/modules" search={{ create: true }} />}>
|
|
||||||
<PlusIcon />
|
|
||||||
Создать
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
onRowClick={(row) =>
|
|
||||||
void navigate({ to: '/modules/$moduleId', params: { moduleId: row.id } })
|
|
||||||
}
|
|
||||||
emptyState={{ title: 'Нет модулей по выбранным фильтрам.' }}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -60,8 +60,28 @@ export function DashboardNetworkHealth({
|
|||||||
label: mode === 'peers' ? 'Утилизация пиров' : 'Спикеры в сети',
|
label: mode === 'peers' ? 'Утилизация пиров' : 'Спикеры в сети',
|
||||||
percent: loading ? 0 : utilization,
|
percent: loading ? 0 : utilization,
|
||||||
badge: (
|
badge: (
|
||||||
<Badge variant="outline" radius="full" className="h-6 px-2 text-[10px]">
|
<Badge
|
||||||
{loading ? '…' : `${established}/${total}`}
|
variant={
|
||||||
|
loading || total === 0
|
||||||
|
? 'outline'
|
||||||
|
: offline === 0
|
||||||
|
? 'success-light'
|
||||||
|
: 'warning-light'
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
radius="full"
|
||||||
|
>
|
||||||
|
{loading
|
||||||
|
? '…'
|
||||||
|
: total === 0
|
||||||
|
? 'нет данных'
|
||||||
|
: offline === 0
|
||||||
|
? mode === 'peers'
|
||||||
|
? 'все установлены'
|
||||||
|
: 'все в сети'
|
||||||
|
: mode === 'peers'
|
||||||
|
? `${offline} не установлены`
|
||||||
|
: `${offline} офлайн`}
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
import { useMemo } from 'react'
|
|
||||||
import { ListTodo } from 'lucide-react'
|
|
||||||
|
|
||||||
import { DataGridNameCell } from '@/components/data-grid-cell'
|
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
|
||||||
import { FrameDataGrid, type DataGridColumnDef } from '@/components/reui-kit'
|
|
||||||
import { jobKindRu } from '@/lib/ui-labels'
|
|
||||||
import type { JobRow } from '@/types/api'
|
|
||||||
|
|
||||||
export function DashboardRecentJobsGrid({
|
|
||||||
jobs,
|
|
||||||
nameById,
|
|
||||||
isLoading = false,
|
|
||||||
}: {
|
|
||||||
jobs: JobRow[]
|
|
||||||
nameById: Map<string, string>
|
|
||||||
isLoading?: boolean
|
|
||||||
}) {
|
|
||||||
const data = useMemo(() => jobs.slice(0, 8), [jobs])
|
|
||||||
|
|
||||||
const columns = useMemo<DataGridColumnDef<JobRow>[]>(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
accessorKey: 'kind',
|
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<DataGridNameCell
|
|
||||||
icon={ListTodo}
|
|
||||||
title={jobKindRu(row.original.kind)}
|
|
||||||
subtitle={
|
|
||||||
row.original.meta?.module_id
|
|
||||||
? (nameById.get(String(row.original.meta.module_id)) ?? undefined)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
meta: { headerTitle: 'Вид' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'status',
|
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
|
|
||||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
|
||||||
meta: { headerTitle: 'Статус' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[nameById],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FrameDataGrid
|
|
||||||
title="Недавние задачи"
|
|
||||||
description="Последние фоновые операции"
|
|
||||||
columns={columns}
|
|
||||||
data={data}
|
|
||||||
rowId={(row) => row.job_id}
|
|
||||||
emptyTitle="Нет задач"
|
|
||||||
pagination={false}
|
|
||||||
isLoading={isLoading}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { useMemo } from 'react'
|
|
||||||
import { GitCommitHorizontal } from 'lucide-react'
|
|
||||||
|
|
||||||
import { DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell'
|
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
|
||||||
import { FrameDataGrid, type DataGridColumnDef } from '@/components/reui-kit'
|
|
||||||
import type { RevisionRow } from '@/types/api'
|
|
||||||
|
|
||||||
export function DashboardRecentRevisionsGrid({
|
|
||||||
revisions,
|
|
||||||
isLoading = false,
|
|
||||||
}: {
|
|
||||||
revisions: RevisionRow[]
|
|
||||||
isLoading?: boolean
|
|
||||||
}) {
|
|
||||||
const data = useMemo(() => revisions.slice(0, 8), [revisions])
|
|
||||||
|
|
||||||
const columns = useMemo<DataGridColumnDef<RevisionRow>[]>(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
id: 'id',
|
|
||||||
accessorFn: (row) => row.id,
|
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="ID" />,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<DataGridNameCell icon={GitCommitHorizontal} title={`${row.original.id.slice(0, 10)}…`} />
|
|
||||||
),
|
|
||||||
meta: { headerTitle: 'ID' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'created_at',
|
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Создана" />,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<DataGridMutedCell>
|
|
||||||
{new Date(row.original.created_at).toLocaleString('ru-RU')}
|
|
||||||
</DataGridMutedCell>
|
|
||||||
),
|
|
||||||
meta: { headerTitle: 'Создана' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FrameDataGrid
|
|
||||||
title="Последние ревизии"
|
|
||||||
description="История конфигураций"
|
|
||||||
columns={columns}
|
|
||||||
data={data}
|
|
||||||
rowId={(row) => row.id}
|
|
||||||
emptyTitle="Нет ревизий"
|
|
||||||
pagination={false}
|
|
||||||
isLoading={isLoading}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
import type { DohProfile } from '@/types/api'
|
import type { DohProfile } from '@/types/api'
|
||||||
|
|
||||||
const filterFields: FilterField[] = [
|
const filterFields: FilterField[] = [
|
||||||
createSearchFilterField('search', 'Поиск', 'Поиск DoH профилей…'),
|
createSearchFilterField('search', 'Поиск', 'Поиск DoH-профилей…'),
|
||||||
]
|
]
|
||||||
|
|
||||||
export function DirectoriesDohGrid({
|
export function DirectoriesDohGrid({
|
||||||
@@ -81,7 +81,7 @@ export function DirectoriesDohGrid({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ResourcePage
|
<ResourcePage
|
||||||
title="DoH профили"
|
title="DoH-профили"
|
||||||
description="Резолверы DNS-over-HTTPS для доменных модулей"
|
description="Резолверы DNS-over-HTTPS для доменных модулей"
|
||||||
filterFields={filterFields}
|
filterFields={filterFields}
|
||||||
filterQuery={filterQuery}
|
filterQuery={filterQuery}
|
||||||
@@ -94,7 +94,7 @@ export function DirectoriesDohGrid({
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
primaryAction={actions}
|
primaryAction={actions}
|
||||||
pinLastColumn={Boolean(canWrite && onEdit)}
|
pinLastColumn={Boolean(canWrite && onEdit)}
|
||||||
emptyState={{ title: 'Нет DoH профилей', action: actions }}
|
emptyState={{ title: 'Нет DoH-профилей', action: actions }}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import { CascaderInput, CascaderValue } from '@/components/reui/cascader/cascader-nav'
|
import { CascaderInput, CascaderValue } from '@/components/reui/cascader/cascader-nav'
|
||||||
import type { CascaderNode } from '@/components/reui/cascader/cascader-types'
|
import type { CascaderNode } from '@/components/reui/cascader/cascader-types'
|
||||||
import { ApiError, apiMutate } from '@/lib/api-client'
|
import { ApiError, apiMutate } from '@/lib/api-client'
|
||||||
|
import { CASCADER_LABELS_RU } from '@/lib/reui-i18n-ru'
|
||||||
import type {
|
import type {
|
||||||
BgpCommunity,
|
BgpCommunity,
|
||||||
LookupQueryKind,
|
LookupQueryKind,
|
||||||
@@ -202,9 +203,7 @@ export function LookupAddStep({
|
|||||||
selectable={allowEmptyCommunity ? 'any' : 'leaf'}
|
selectable={allowEmptyCommunity ? 'any' : 'leaf'}
|
||||||
mode="columns"
|
mode="columns"
|
||||||
labels={{
|
labels={{
|
||||||
search: 'Поиск',
|
...CASCADER_LABELS_RU,
|
||||||
empty: 'Ничего не найдено',
|
|
||||||
back: 'Назад',
|
|
||||||
rootLevel: 'Модули',
|
rootLevel: 'Модули',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps
|
|||||||
label="Роль"
|
label="Роль"
|
||||||
items={[
|
items={[
|
||||||
{ value: 'replica', label: 'Реплика' },
|
{ value: 'replica', label: 'Реплика' },
|
||||||
{ value: 'master', label: 'Мастер (плоскость)' },
|
{ value: 'master', label: 'Основной' },
|
||||||
]}
|
]}
|
||||||
value={role}
|
value={role}
|
||||||
onValueChange={(v) => setRole(v ?? 'replica')}
|
onValueChange={(v) => setRole(v ?? 'replica')}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import {
|
||||||
|
Timeline,
|
||||||
|
TimelineContent,
|
||||||
|
TimelineDate,
|
||||||
|
TimelineHeader,
|
||||||
|
TimelineIndicator,
|
||||||
|
TimelineItem,
|
||||||
|
TimelineSeparator,
|
||||||
|
TimelineTitle,
|
||||||
|
} from '@/components/reui/timeline'
|
||||||
|
import { jobStatusRu, jobTriggerRu } from '@/lib/ui-labels'
|
||||||
|
import type { JobRow } from '@/types/api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ReUI Timeline в раскрытии строки задач: создана → взята в работу → результат.
|
||||||
|
* Активный шаг выводится из фактических отметок времени задачи.
|
||||||
|
* @see https://reui.io/docs/components/base/timeline
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ruDateTime = (value: string | null | undefined) =>
|
||||||
|
value ? new Date(value).toLocaleString('ru-RU') : undefined
|
||||||
|
|
||||||
|
function resultSubtitle(job: JobRow): string | undefined {
|
||||||
|
if (job.error) return job.error
|
||||||
|
const summary = job.meta?.apply_summary
|
||||||
|
if (summary && typeof summary === 'object') {
|
||||||
|
const message = (summary as Record<string, unknown>).message
|
||||||
|
if (typeof message === 'string' && message !== '') return message
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JobTimeline({ job }: { job: JobRow }) {
|
||||||
|
const trigger = jobTriggerRu(job.meta?.trigger)
|
||||||
|
const completed = job.finished_at ? 3 : job.started_at ? 2 : 1
|
||||||
|
const failed = ['failed', 'error'].includes(job.status.toLowerCase())
|
||||||
|
const subtitle = resultSubtitle(job)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-4 ps-3 pe-2">
|
||||||
|
<Timeline defaultValue={completed}>
|
||||||
|
<TimelineItem step={1}>
|
||||||
|
<TimelineHeader>
|
||||||
|
<TimelineDate>{ruDateTime(job.created_at)}</TimelineDate>
|
||||||
|
<TimelineTitle>Создана{trigger ? ` (${trigger})` : ''}</TimelineTitle>
|
||||||
|
</TimelineHeader>
|
||||||
|
<TimelineIndicator />
|
||||||
|
<TimelineSeparator />
|
||||||
|
<TimelineContent>Идентификатор: {job.job_id}</TimelineContent>
|
||||||
|
</TimelineItem>
|
||||||
|
<TimelineItem step={2}>
|
||||||
|
<TimelineHeader>
|
||||||
|
<TimelineDate>{ruDateTime(job.started_at)}</TimelineDate>
|
||||||
|
<TimelineTitle>Взята в работу</TimelineTitle>
|
||||||
|
</TimelineHeader>
|
||||||
|
<TimelineIndicator />
|
||||||
|
<TimelineSeparator />
|
||||||
|
</TimelineItem>
|
||||||
|
<TimelineItem step={3}>
|
||||||
|
<TimelineHeader>
|
||||||
|
<TimelineDate>{ruDateTime(job.finished_at)}</TimelineDate>
|
||||||
|
<TimelineTitle className={failed ? 'text-destructive' : undefined}>
|
||||||
|
{job.finished_at ? jobStatusRu(job.status) : 'Ещё выполняется'}
|
||||||
|
</TimelineTitle>
|
||||||
|
</TimelineHeader>
|
||||||
|
<TimelineIndicator />
|
||||||
|
<TimelineSeparator />
|
||||||
|
{subtitle ? <TimelineContent>{subtitle}</TimelineContent> : null}
|
||||||
|
</TimelineItem>
|
||||||
|
</Timeline>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,8 +4,10 @@ import { ListTodo, SearchIcon } from 'lucide-react'
|
|||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell'
|
import { DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
|
import { JobTimeline } from '@/components/operations/job-timeline'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
||||||
import {
|
import {
|
||||||
@@ -14,7 +16,7 @@ import {
|
|||||||
type DataGridColumnDef,
|
type DataGridColumnDef,
|
||||||
} from '@/components/reui-kit'
|
} from '@/components/reui-kit'
|
||||||
import { apiMutate } from '@/lib/api-client'
|
import { apiMutate } from '@/lib/api-client'
|
||||||
import { jobKindRu } from '@/lib/ui-labels'
|
import { jobKindRu, jobSubtitleRu, jobTriggerRu } from '@/lib/ui-labels'
|
||||||
import type { JobRow } from '@/types/api'
|
import type { JobRow } from '@/types/api'
|
||||||
import type { QueryClient } from '@tanstack/react-query'
|
import type { QueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
@@ -71,19 +73,27 @@ export function OperationsJobsGrid({
|
|||||||
{
|
{
|
||||||
accessorKey: 'kind',
|
accessorKey: 'kind',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => {
|
||||||
|
const trigger = jobTriggerRu(row.original.meta?.trigger)
|
||||||
|
return (
|
||||||
<DataGridNameCell
|
<DataGridNameCell
|
||||||
icon={ListTodo}
|
icon={ListTodo}
|
||||||
title={jobKindRu(row.original.kind)}
|
title={
|
||||||
subtitle={
|
<span className="flex min-w-0 items-center gap-1.5">
|
||||||
row.original.meta?.module_id
|
<span className="truncate">{jobKindRu(row.original.kind)}</span>
|
||||||
? (nameById.get(String(row.original.meta.module_id)) ??
|
{trigger ? (
|
||||||
String(row.original.meta.module_id))
|
<Badge variant="outline" size="sm" className="shrink-0 font-normal">
|
||||||
: undefined
|
{trigger}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
}
|
}
|
||||||
|
subtitle={jobSubtitleRu(row.original.kind, row.original.meta, nameById)}
|
||||||
/>
|
/>
|
||||||
),
|
)
|
||||||
|
},
|
||||||
meta: { headerTitle: 'Вид' },
|
meta: { headerTitle: 'Вид' },
|
||||||
|
size: 320,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
@@ -96,6 +106,7 @@ export function OperationsJobsGrid({
|
|||||||
return <StatusBadge status={row.original.status} hint={hint} />
|
return <StatusBadge status={row.original.status} hint={hint} />
|
||||||
},
|
},
|
||||||
meta: { headerTitle: 'Статус' },
|
meta: { headerTitle: 'Статус' },
|
||||||
|
size: 150,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'created_at',
|
id: 'created_at',
|
||||||
@@ -109,6 +120,7 @@ export function OperationsJobsGrid({
|
|||||||
</DataGridMutedCell>
|
</DataGridMutedCell>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Создана' },
|
meta: { headerTitle: 'Создана' },
|
||||||
|
size: 190,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'finished_at',
|
id: 'finished_at',
|
||||||
@@ -122,6 +134,7 @@ export function OperationsJobsGrid({
|
|||||||
</DataGridMutedCell>
|
</DataGridMutedCell>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Завершена' },
|
meta: { headerTitle: 'Завершена' },
|
||||||
|
size: 190,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
@@ -156,10 +169,9 @@ export function OperationsJobsGrid({
|
|||||||
onClearFilters={() => setFilterQuery(createTextFilterQuery('search'))}
|
onClearFilters={() => setFilterQuery(createTextFilterQuery('search'))}
|
||||||
getFilterFieldValue={(item, field) => {
|
getFilterFieldValue={(item, field) => {
|
||||||
if (field !== 'search') return undefined
|
if (field !== 'search') return undefined
|
||||||
const moduleName = item.meta?.module_id
|
const subtitle = jobSubtitleRu(item.kind, item.meta, nameById)
|
||||||
? (nameById.get(String(item.meta.module_id)) ?? String(item.meta.module_id))
|
const trigger = jobTriggerRu(item.meta?.trigger)
|
||||||
: ''
|
return `${jobKindRu(item.kind)} ${item.status} ${item.job_id} ${subtitle ?? ''} ${trigger ?? ''}`
|
||||||
return `${jobKindRu(item.kind)} ${item.status} ${item.job_id} ${moduleName}`
|
|
||||||
}}
|
}}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={items}
|
data={items}
|
||||||
@@ -168,6 +180,8 @@ export function OperationsJobsGrid({
|
|||||||
pinLastColumn
|
pinLastColumn
|
||||||
virtualization={items.length > 80}
|
virtualization={items.length > 80}
|
||||||
emptyState={{ title: 'Нет задач' }}
|
emptyState={{ title: 'Нет задач' }}
|
||||||
|
getRowCanExpand={() => true}
|
||||||
|
expandedContent={(row) => <JobTimeline job={row} />}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import type { JobRow } from '@/types/api'
|
|||||||
const COLUMN_ORDER = ['queued', 'running', 'succeeded', 'failed'] as const
|
const COLUMN_ORDER = ['queued', 'running', 'succeeded', 'failed'] as const
|
||||||
|
|
||||||
const COLUMN_LABELS: Record<(typeof COLUMN_ORDER)[number], string> = {
|
const COLUMN_LABELS: Record<(typeof COLUMN_ORDER)[number], string> = {
|
||||||
queued: 'Очередь',
|
queued: 'В очереди',
|
||||||
running: 'Выполняются',
|
running: 'Выполняются',
|
||||||
succeeded: 'Успешные',
|
succeeded: 'Успешные',
|
||||||
failed: 'Ошибки',
|
failed: 'Ошибки',
|
||||||
|
|||||||
@@ -56,9 +56,10 @@ export function OperationsRevisionsGrid({
|
|||||||
accessorFn: (row) => row.id,
|
accessorFn: (row) => row.id,
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="ID" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="ID" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<DataGridNameCell icon={GitCommitHorizontal} title={`${row.original.id.slice(0, 12)}…`} />
|
<DataGridNameCell icon={GitCommitHorizontal} title={row.original.id} />
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'ID' },
|
meta: { headerTitle: 'ID' },
|
||||||
|
size: 340,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'created_at',
|
accessorKey: 'created_at',
|
||||||
@@ -69,6 +70,7 @@ export function OperationsRevisionsGrid({
|
|||||||
</DataGridMutedCell>
|
</DataGridMutedCell>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Создана' },
|
meta: { headerTitle: 'Создана' },
|
||||||
|
size: 210,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'materialized_prefix_count',
|
accessorKey: 'materialized_prefix_count',
|
||||||
@@ -79,6 +81,7 @@ export function OperationsRevisionsGrid({
|
|||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Префиксов' },
|
meta: { headerTitle: 'Префиксов' },
|
||||||
|
size: 150,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
@@ -91,7 +94,7 @@ export function OperationsRevisionsGrid({
|
|||||||
<RefreshCw className="size-3.5" />
|
<RefreshCw className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
title={`Откатиться к ревизии ${row.original.id.slice(0, 8)}…?`}
|
title={`Откатиться к ревизии ${row.original.id}?`}
|
||||||
description="Будет создана новая ревизия на основе выбранной. Требуется роль оператора."
|
description="Будет создана новая ревизия на основе выбранной. Требуется роль оператора."
|
||||||
confirmLabel="Откатить"
|
confirmLabel="Откатить"
|
||||||
destructive
|
destructive
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ import {
|
|||||||
FrameTitle,
|
FrameTitle,
|
||||||
} from '@/components/reui/frame'
|
} from '@/components/reui/frame'
|
||||||
import { EmptyState } from '@/components/empty-state'
|
import { EmptyState } from '@/components/empty-state'
|
||||||
import { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults'
|
import { DATA_GRID_I18N_RU } from '@/lib/reui-i18n-ru'
|
||||||
|
|
||||||
export type DataGridColumnDef<TData extends object> = ColumnDef<DataGridFeatures, TData>
|
export type DataGridColumnDef<TData extends object> = ColumnDef<DataGridFeatures, TData>
|
||||||
|
|
||||||
@@ -298,6 +298,7 @@ function FrameDataGridBody<TData extends object>({
|
|||||||
recordCount={data.length}
|
recordCount={data.length}
|
||||||
onRowClick={onRowClick}
|
onRowClick={onRowClick}
|
||||||
emptyMessage={emptyTitle}
|
emptyMessage={emptyTitle}
|
||||||
|
i18n={DATA_GRID_I18N_RU}
|
||||||
tableLayout={kitDataGridTableLayout({
|
tableLayout={kitDataGridTableLayout({
|
||||||
dense,
|
dense,
|
||||||
width: tableWidth,
|
width: tableWidth,
|
||||||
@@ -317,7 +318,7 @@ function FrameDataGridBody<TData extends object>({
|
|||||||
<>
|
<>
|
||||||
<Separator />
|
<Separator />
|
||||||
<FrameFooter>
|
<FrameFooter>
|
||||||
<DataGridPagination {...DATA_GRID_PAGINATION_RU} />
|
<DataGridPagination sizes={[10, 25, 50]} />
|
||||||
</FrameFooter>
|
</FrameFooter>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Link } from '@tanstack/react-router'
|
|||||||
|
|
||||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { Progress } from '@evobgp/ui/components/progress'
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
import { kpiCols } from './kpi-cols'
|
import { kpiCols } from './kpi-cols'
|
||||||
import { IconTile } from '@/components/reui/icon-tile'
|
import { IconTile } from '@/components/reui/icon-tile'
|
||||||
@@ -29,6 +30,8 @@ export type KpiStatItem = {
|
|||||||
iconClassName?: string
|
iconClassName?: string
|
||||||
variant?: KpiStatVariant
|
variant?: KpiStatVariant
|
||||||
footer?: ReactNode
|
footer?: ReactNode
|
||||||
|
/** 0–100 completion bar under the value (stats-4). Omit to hide. */
|
||||||
|
progress?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** CFDM-compatible card shape (id required). */
|
/** CFDM-compatible card shape (id required). */
|
||||||
@@ -48,6 +51,17 @@ const VALUE_VARIANT_CLASS: Record<KpiStatVariant, string> = {
|
|||||||
destructive: 'text-destructive',
|
destructive: 'text-destructive',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PROGRESS_TONE_CLASS: Record<KpiStatVariant, string> = {
|
||||||
|
default: '',
|
||||||
|
warning: '[&_[data-slot=progress-indicator]]:bg-warning',
|
||||||
|
destructive: '[&_[data-slot=progress-indicator]]:bg-destructive',
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampProgress(value: number): number {
|
||||||
|
if (Number.isNaN(value)) return 0
|
||||||
|
return Math.min(100, Math.max(0, value))
|
||||||
|
}
|
||||||
|
|
||||||
function handleCardKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
|
function handleCardKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -111,6 +125,20 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) {
|
|||||||
>
|
>
|
||||||
{item.value}
|
{item.value}
|
||||||
</div>
|
</div>
|
||||||
|
{item.progress !== undefined ? (
|
||||||
|
<Progress
|
||||||
|
value={clampProgress(item.progress)}
|
||||||
|
className={cn(
|
||||||
|
'mt-1.5 w-full gap-0 **:data-[slot=progress-track]:h-1.5',
|
||||||
|
PROGRESS_TONE_CLASS[valueVariant],
|
||||||
|
)}
|
||||||
|
aria-label={
|
||||||
|
typeof item.label === 'string'
|
||||||
|
? `${item.label}: ${clampProgress(item.progress)}%`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{footer ? (
|
{footer ? (
|
||||||
<div className="min-w-0 max-w-full @[20rem]:hidden">{footer}</div>
|
<div className="min-w-0 max-w-full @[20rem]:hidden">{footer}</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ interface OpsDashboardProps {
|
|||||||
/** Slot after KPI (QuickActionGrid). Preview: stats-12 · card-12 */
|
/** Slot after KPI (QuickActionGrid). Preview: stats-12 · card-12 */
|
||||||
afterKpi?: ReactNode
|
afterKpi?: ReactNode
|
||||||
charts: ReactNode
|
charts: ReactNode
|
||||||
queue: ReactNode
|
queue?: ReactNode
|
||||||
queueTitle?: string
|
queueTitle?: string
|
||||||
queueDescription?: string
|
queueDescription?: string
|
||||||
headerActions?: ReactNode
|
headerActions?: ReactNode
|
||||||
@@ -31,10 +31,12 @@ function OpsDashboardSkeleton() {
|
|||||||
</header>
|
</header>
|
||||||
<KpiStatGrid cards={[]} isLoading skeletonCount={4} />
|
<KpiStatGrid cards={[]} isLoading skeletonCount={4} />
|
||||||
<div className="flex min-w-0 flex-col gap-4">
|
<div className="flex min-w-0 flex-col gap-4">
|
||||||
<Skeleton className="h-64 w-full rounded-xl" />
|
<Skeleton className="h-40 w-full rounded-xl" />
|
||||||
<Skeleton className="h-64 w-full rounded-xl" />
|
<div className="grid min-w-0 grid-cols-1 gap-4 @5xl:grid-cols-2">
|
||||||
|
<Skeleton className="h-56 w-full rounded-xl" />
|
||||||
|
<Skeleton className="h-56 w-full rounded-xl" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Skeleton className="h-48 w-full rounded-xl" />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -76,6 +78,7 @@ export function OpsDashboard({
|
|||||||
{charts}
|
{charts}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{queue ? (
|
||||||
<section aria-label={queueTitle} className="flex min-w-0 flex-col gap-4">
|
<section aria-label={queueTitle} className="flex min-w-0 flex-col gap-4">
|
||||||
<div className="flex min-w-0 flex-col gap-1">
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
<h2 className="text-sm font-semibold tracking-tight">{queueTitle}</h2>
|
<h2 className="text-sm font-semibold tracking-tight">{queueTitle}</h2>
|
||||||
@@ -85,6 +88,7 @@ export function OpsDashboard({
|
|||||||
</div>
|
</div>
|
||||||
{queue}
|
{queue}
|
||||||
</section>
|
</section>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useMemo, useState, type ReactNode } from 'react'
|
import { useCallback, useMemo, useState, type ReactNode } from 'react'
|
||||||
import {
|
import {
|
||||||
useTable,
|
useTable,
|
||||||
|
type ExpandedState,
|
||||||
type PaginationState,
|
type PaginationState,
|
||||||
type RowSelectionState,
|
type RowSelectionState,
|
||||||
type SortingState,
|
type SortingState,
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
||||||
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
||||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||||
|
import { DataGridTableRowExpand } from '@/components/reui/data-grid/data-grid-table'
|
||||||
import { Filters } from '@/components/reui/filters/filters'
|
import { Filters } from '@/components/reui/filters/filters'
|
||||||
import { flattenFilterConditions } from '@/components/reui/filters/filters-query'
|
import { flattenFilterConditions } from '@/components/reui/filters/filters-query'
|
||||||
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
||||||
@@ -41,7 +43,7 @@ import {
|
|||||||
} from '@evobgp/ui/components/input-group'
|
} from '@evobgp/ui/components/input-group'
|
||||||
import { Separator } from '@evobgp/ui/components/separator'
|
import { Separator } from '@evobgp/ui/components/separator'
|
||||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||||
import { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults'
|
import { DATA_GRID_I18N_RU } from '@/lib/reui-i18n-ru'
|
||||||
import { FILTERS_LABELS_RU, FILTERS_OPERATOR_LABELS_RU } from '@/lib/filters-i18n'
|
import { FILTERS_LABELS_RU, FILTERS_OPERATOR_LABELS_RU } from '@/lib/filters-i18n'
|
||||||
import {
|
import {
|
||||||
applyFiltersToData,
|
applyFiltersToData,
|
||||||
@@ -94,6 +96,8 @@ type SimpleGridPassthrough<T extends object> = Pick<
|
|||||||
| 'horizontalScroll'
|
| 'horizontalScroll'
|
||||||
| 'pinLeftColumnIds'
|
| 'pinLeftColumnIds'
|
||||||
| 'columnPinControls'
|
| 'columnPinControls'
|
||||||
|
| 'expandedContent'
|
||||||
|
| 'getRowCanExpand'
|
||||||
>
|
>
|
||||||
|
|
||||||
export interface ResourcePageProps<T extends object> extends SimpleGridPassthrough<T> {
|
export interface ResourcePageProps<T extends object> extends SimpleGridPassthrough<T> {
|
||||||
@@ -218,6 +222,8 @@ function ResourcePageSimple<T extends object>({
|
|||||||
horizontalScroll,
|
horizontalScroll,
|
||||||
pinLeftColumnIds,
|
pinLeftColumnIds,
|
||||||
columnPinControls,
|
columnPinControls,
|
||||||
|
expandedContent,
|
||||||
|
getRowCanExpand,
|
||||||
}: ResourcePageProps<T>) {
|
}: ResourcePageProps<T>) {
|
||||||
if (isLoading) return <ResourcePageSkeleton />
|
if (isLoading) return <ResourcePageSkeleton />
|
||||||
if (isError) return <ResourceLoadError error={error} onRetry={onRetry} />
|
if (isError) return <ResourceLoadError error={error} onRetry={onRetry} />
|
||||||
@@ -256,6 +262,8 @@ function ResourcePageSimple<T extends object>({
|
|||||||
height={height}
|
height={height}
|
||||||
onRowSelectionChange={onRowSelectionChange}
|
onRowSelectionChange={onRowSelectionChange}
|
||||||
enableColumnVisibility={enableColumnVisibility}
|
enableColumnVisibility={enableColumnVisibility}
|
||||||
|
expandedContent={expandedContent}
|
||||||
|
getRowCanExpand={getRowCanExpand}
|
||||||
columnVisibility={columnVisibility}
|
columnVisibility={columnVisibility}
|
||||||
onColumnVisibilityChange={onColumnVisibilityChange}
|
onColumnVisibilityChange={onColumnVisibilityChange}
|
||||||
columnVisibilityTrigger={columnVisibilityTrigger}
|
columnVisibilityTrigger={columnVisibilityTrigger}
|
||||||
@@ -304,6 +312,8 @@ function ResourcePageFiltered<T extends object>({
|
|||||||
virtualization = false,
|
virtualization = false,
|
||||||
height = 480,
|
height = 480,
|
||||||
horizontalScroll = false,
|
horizontalScroll = false,
|
||||||
|
expandedContent,
|
||||||
|
getRowCanExpand,
|
||||||
}: ResourcePageProps<T>) {
|
}: ResourcePageProps<T>) {
|
||||||
const headerActions = primaryAction ?? actions
|
const headerActions = primaryAction ?? actions
|
||||||
const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all')
|
const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all')
|
||||||
@@ -339,6 +349,7 @@ function ResourcePageFiltered<T extends object>({
|
|||||||
|
|
||||||
const [sorting, setSorting] = useState<SortingState>([])
|
const [sorting, setSorting] = useState<SortingState>([])
|
||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||||
|
const [expanded, setExpanded] = useState<ExpandedState>({})
|
||||||
const [pagination, setPagination] = useState<PaginationState>({
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -376,9 +387,28 @@ function ResourcePageFiltered<T extends object>({
|
|||||||
|
|
||||||
const selectedCount = selectedIds.length
|
const selectedCount = selectedIds.length
|
||||||
|
|
||||||
|
// Колонка раскрытия — тот же приём, что во FrameDataGrid: рендерер
|
||||||
|
// подстроки живёт в meta колонки, DataGridTable читает его оттуда.
|
||||||
const tableColumns = useMemo(
|
const tableColumns = useMemo(
|
||||||
() => applyKitActionColumn(columns, { pinLastColumn }),
|
() =>
|
||||||
[columns, pinLastColumn],
|
applyKitActionColumn(
|
||||||
|
expandedContent
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: 'expand',
|
||||||
|
header: () => null,
|
||||||
|
cell: ({ row }) => <DataGridTableRowExpand row={row} />,
|
||||||
|
enableSorting: false,
|
||||||
|
enableHiding: false,
|
||||||
|
size: 40,
|
||||||
|
meta: { cellClassName: 'w-10', expandedContent },
|
||||||
|
},
|
||||||
|
...columns,
|
||||||
|
]
|
||||||
|
: columns,
|
||||||
|
{ pinLastColumn },
|
||||||
|
),
|
||||||
|
[columns, pinLastColumn, expandedContent],
|
||||||
)
|
)
|
||||||
const { enablePinning, columnPinning } = kitColumnPinning({
|
const { enablePinning, columnPinning } = kitColumnPinning({
|
||||||
pinLastColumn,
|
pinLastColumn,
|
||||||
@@ -399,6 +429,7 @@ function ResourcePageFiltered<T extends object>({
|
|||||||
sorting,
|
sorting,
|
||||||
rowSelection,
|
rowSelection,
|
||||||
pagination,
|
pagination,
|
||||||
|
expanded,
|
||||||
...(enablePinning ? { columnPinning } : {}),
|
...(enablePinning ? { columnPinning } : {}),
|
||||||
},
|
},
|
||||||
initialState: enablePinning ? { columnPinning } : undefined,
|
initialState: enablePinning ? { columnPinning } : undefined,
|
||||||
@@ -406,6 +437,10 @@ function ResourcePageFiltered<T extends object>({
|
|||||||
onSortingChange: setSorting,
|
onSortingChange: setSorting,
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
|
onExpandedChange: setExpanded,
|
||||||
|
getRowCanExpand: expandedContent
|
||||||
|
? (row) => (getRowCanExpand ? getRowCanExpand(row.original) : true)
|
||||||
|
: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleTabChange = useCallback(
|
const handleTabChange = useCallback(
|
||||||
@@ -495,6 +530,7 @@ function ResourcePageFiltered<T extends object>({
|
|||||||
recordCount={filteredData.length}
|
recordCount={filteredData.length}
|
||||||
emptyMessage="Нет записей по выбранным фильтрам."
|
emptyMessage="Нет записей по выбранным фильтрам."
|
||||||
onRowClick={onRowClick}
|
onRowClick={onRowClick}
|
||||||
|
i18n={DATA_GRID_I18N_RU}
|
||||||
tableLayout={kitDataGridTableLayout({
|
tableLayout={kitDataGridTableLayout({
|
||||||
dense: true,
|
dense: true,
|
||||||
width: 'fixed',
|
width: 'fixed',
|
||||||
@@ -610,7 +646,7 @@ function ResourcePageFiltered<T extends object>({
|
|||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<FrameFooter>
|
<FrameFooter>
|
||||||
<DataGridPagination {...DATA_GRID_PAGINATION_RU} sizes={[5, 10, 20, 50]} />
|
<DataGridPagination sizes={[5, 10, 20, 50]} />
|
||||||
</FrameFooter>
|
</FrameFooter>
|
||||||
</FramePanel>
|
</FramePanel>
|
||||||
</Frame>
|
</Frame>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import {
|
import {
|
||||||
useCascaderActions,
|
useCascaderActions,
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { CascaderColumnPanel } from "@/components/reui/cascader/cascader-columns"
|
import { CascaderColumnPanel } from "@/components/reui/cascader/cascader-columns"
|
||||||
import {
|
import {
|
||||||
@@ -81,7 +79,8 @@ export function useCascaderVirtualizer({
|
|||||||
|
|
||||||
const measureEstimate = React.useCallback(() => estimateSize, [estimateSize])
|
const measureEstimate = React.useCallback(() => estimateSize, [estimateSize])
|
||||||
|
|
||||||
// React Compiler bails on `useVirtualizer`; harmless, rows memoise one by one.
|
// React Compiler bails on `useVirtualizer` HERE, and only here - the bail does not
|
||||||
|
// propagate to the components that call this hook. They opt out themselves.
|
||||||
const virtualizer = useVirtualizer<HTMLElement, HTMLElement>({
|
const virtualizer = useVirtualizer<HTMLElement, HTMLElement>({
|
||||||
count,
|
count,
|
||||||
getScrollElement,
|
getScrollElement,
|
||||||
@@ -225,6 +224,11 @@ function CascaderVirtualRows({
|
|||||||
estimateSize,
|
estimateSize,
|
||||||
overscan,
|
overscan,
|
||||||
}: CascaderVirtualItemsProps) {
|
}: CascaderVirtualItemsProps) {
|
||||||
|
/* The `useCascaderVirtualizer` bail does NOT reach here: the compiler caches
|
||||||
|
`getVirtualItems()` on the virtualizer, whose identity never changes, so the
|
||||||
|
window freezes on scroll. Inert where no compiler runs. */
|
||||||
|
"use no memo"
|
||||||
|
|
||||||
const {
|
const {
|
||||||
estimateRowSize,
|
estimateRowSize,
|
||||||
overscan: rootOverscan,
|
overscan: rootOverscan,
|
||||||
@@ -375,6 +379,11 @@ function CascaderVirtualColumnRows({
|
|||||||
overscan,
|
overscan,
|
||||||
activeIndex,
|
activeIndex,
|
||||||
}: CascaderVirtualColumnProps & { activeIndex: number }) {
|
}: CascaderVirtualColumnProps & { activeIndex: number }) {
|
||||||
|
/* The `useCascaderVirtualizer` bail does NOT reach here: the compiler caches
|
||||||
|
`getVirtualItems()` on the virtualizer, whose identity never changes, so the
|
||||||
|
window freezes on scroll. Inert where no compiler runs. */
|
||||||
|
"use no memo"
|
||||||
|
|
||||||
const {
|
const {
|
||||||
estimateRowSize,
|
estimateRowSize,
|
||||||
overscan: rootOverscan,
|
overscan: rootOverscan,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import {
|
import {
|
||||||
useCascaderLoader,
|
useCascaderLoader,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { Badge } from "@/components/reui/badge"
|
import { Badge } from "@/components/reui/badge"
|
||||||
|
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||||
import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
|
import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
|
||||||
import type { Column } from "@tanstack/react-table"
|
import type { Column } from "@tanstack/react-table"
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ function DataGridColumnFilter<TData extends object, TValue>({
|
|||||||
title,
|
title,
|
||||||
options,
|
options,
|
||||||
}: DataGridColumnFilterProps<TData, TValue>) {
|
}: DataGridColumnFilterProps<TData, TValue>) {
|
||||||
|
const { i18n } = useDataGrid()
|
||||||
const facets = column?.getFacetedUniqueValues()
|
const facets = column?.getFacetedUniqueValues()
|
||||||
const filterValue = column?.getFilterValue()
|
const filterValue = column?.getFilterValue()
|
||||||
const selectedValues = new Set(
|
const selectedValues = new Set(
|
||||||
@@ -64,7 +66,7 @@ function DataGridColumnFilter<TData extends object, TValue>({
|
|||||||
<div className="hidden space-x-1 lg:flex">
|
<div className="hidden space-x-1 lg:flex">
|
||||||
{selectedValues.size > 2 ? (
|
{selectedValues.size > 2 ? (
|
||||||
<Badge variant="secondary" className="px-1 font-normal">
|
<Badge variant="secondary" className="px-1 font-normal">
|
||||||
{selectedValues.size} selected
|
{i18n.labels.filterSelectedCount(selectedValues.size)}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
options
|
options
|
||||||
@@ -97,7 +99,7 @@ function DataGridColumnFilter<TData extends object, TValue>({
|
|||||||
<div className="max-h-[300px] overflow-y-auto">
|
<div className="max-h-[300px] overflow-y-auto">
|
||||||
{filteredOptions.length === 0 ? (
|
{filteredOptions.length === 0 ? (
|
||||||
<div className="text-muted-foreground py-6 text-center text-sm">
|
<div className="text-muted-foreground py-6 text-center text-sm">
|
||||||
No results found.
|
{i18n.labels.filterNoResults}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-1">
|
<div className="p-1">
|
||||||
@@ -173,7 +175,7 @@ function DataGridColumnFilter<TData extends object, TValue>({
|
|||||||
}}
|
}}
|
||||||
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground rounded-md relative flex cursor-pointer items-center justify-center px-2 py-1.5 text-sm outline-hidden select-none"
|
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground rounded-md relative flex cursor-pointer items-center justify-center px-2 py-1.5 text-sm outline-hidden select-none"
|
||||||
>
|
>
|
||||||
Clear filters
|
{i18n.labels.filterClear}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -47,16 +47,22 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
|
|||||||
filter,
|
filter,
|
||||||
visibility = false,
|
visibility = false,
|
||||||
}: DataGridColumnHeaderProps<TData, TValue>) {
|
}: DataGridColumnHeaderProps<TData, TValue>) {
|
||||||
const { isLoading, table, props } = useDataGrid()
|
const { i18n, isLoading, table, props } = useDataGrid()
|
||||||
const resolvedTitle = title ?? getColumnHeaderLabel(column)
|
const resolvedTitle = title ?? getColumnHeaderLabel(column)
|
||||||
|
|
||||||
// TanStack's columnOrder defaults to [] until a consumer seeds it; fall
|
// The order a move rewrites: the consumer's columnOrder (TanStack defaults it
|
||||||
// back to the definition order so Move Left/Right work out of the box.
|
// to []), then every leaf it leaves out, in definition order - the same
|
||||||
|
// completion TanStack applies when rendering, so a rendered neighbour is
|
||||||
|
// always present to re-seat beside, even after columns are added later.
|
||||||
const columnOrderState = table.state.columnOrder
|
const columnOrderState = table.state.columnOrder
|
||||||
const columnOrder =
|
const definitionOrder = table
|
||||||
columnOrderState.length > 0
|
.getAllColumns()
|
||||||
? columnOrderState
|
.flatMap((topColumn) => topColumn.getLeafColumns())
|
||||||
: table.getAllLeafColumns().map((leafColumn) => leafColumn.id)
|
.map((leafColumn) => leafColumn.id)
|
||||||
|
const columnOrder = [
|
||||||
|
...columnOrderState,
|
||||||
|
...definitionOrder.filter((id) => !columnOrderState.includes(id)),
|
||||||
|
]
|
||||||
const columnVisibilityKey =
|
const columnVisibilityKey =
|
||||||
props.tableLayout?.columnsVisibility && visibility
|
props.tableLayout?.columnsVisibility && visibility
|
||||||
? JSON.stringify(table.state.columnVisibility)
|
? JSON.stringify(table.state.columnVisibility)
|
||||||
@@ -67,9 +73,46 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
|
|||||||
const canPin = column.getCanPin()
|
const canPin = column.getCanPin()
|
||||||
const canResize = column.getCanResize()
|
const canResize = column.getCanResize()
|
||||||
|
|
||||||
const columnIndex = columnOrder.indexOf(column.id)
|
// Move neighbours come from what is RENDERED: the column's own pin bucket,
|
||||||
const canMoveLeft = columnIndex > 0
|
// visible columns only. Stepping through the raw columnOrder would trade
|
||||||
const canMoveRight = columnIndex < columnOrder.length - 1
|
// places with a hidden or pinned column - an enabled click that moves nothing.
|
||||||
|
// With grouping in TanStack's default "reorder" mode, grouped columns render
|
||||||
|
// first whatever columnOrder says: they neither move nor serve as a target.
|
||||||
|
const groupedColumnMode = (
|
||||||
|
table.options as { groupedColumnMode?: false | "reorder" | "remove" }
|
||||||
|
).groupedColumnMode
|
||||||
|
const isHoistedByGrouping = (target: object) =>
|
||||||
|
groupedColumnMode !== false &&
|
||||||
|
typeof (target as { getIsGrouped?: unknown }).getIsGrouped === "function" &&
|
||||||
|
(target as { getIsGrouped: () => boolean }).getIsGrouped()
|
||||||
|
const renderedPeers = (
|
||||||
|
isPinned === "start"
|
||||||
|
? table.getStartVisibleLeafColumns()
|
||||||
|
: isPinned === "end"
|
||||||
|
? table.getEndVisibleLeafColumns()
|
||||||
|
: table.getCenterVisibleLeafColumns()
|
||||||
|
)
|
||||||
|
.filter((leafColumn) => !isHoistedByGrouping(leafColumn))
|
||||||
|
.map((leafColumn) => leafColumn.id)
|
||||||
|
const renderedIndex = renderedPeers.indexOf(column.id)
|
||||||
|
const leftNeighbour =
|
||||||
|
renderedIndex > 0 ? renderedPeers[renderedIndex - 1] : undefined
|
||||||
|
const rightNeighbour =
|
||||||
|
renderedIndex !== -1 && renderedIndex < renderedPeers.length - 1
|
||||||
|
? renderedPeers[renderedIndex + 1]
|
||||||
|
: undefined
|
||||||
|
const canMoveLeft = leftNeighbour !== undefined
|
||||||
|
const canMoveRight = rightNeighbour !== undefined
|
||||||
|
|
||||||
|
/** Re-seats this column beside a rendered neighbour; every other column,
|
||||||
|
* hidden or pinned ones included, keeps its place in the full order. */
|
||||||
|
const moveBeside = (neighbourId: string, side: "before" | "after") => {
|
||||||
|
const newOrder = columnOrder.filter((id) => id !== column.id)
|
||||||
|
const at = newOrder.indexOf(neighbourId)
|
||||||
|
if (at === -1) return
|
||||||
|
newOrder.splice(side === "before" ? at : at + 1, 0, column.id)
|
||||||
|
table.setColumnOrder(newOrder)
|
||||||
|
}
|
||||||
|
|
||||||
const handleSort = () => {
|
const handleSort = () => {
|
||||||
if (isSorted === "asc") {
|
if (isSorted === "asc") {
|
||||||
@@ -139,7 +182,7 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
|
|||||||
disabled={!canSort}
|
disabled={!canSort}
|
||||||
>
|
>
|
||||||
<ArrowUpIcon className="size-3.5!" />
|
<ArrowUpIcon className="size-3.5!" />
|
||||||
<span className="grow">Asc</span>
|
<span className="grow">{i18n.labels.sortAscending}</span>
|
||||||
{isSorted === "asc" && (
|
{isSorted === "asc" && (
|
||||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||||
)}
|
)}
|
||||||
@@ -156,7 +199,7 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
|
|||||||
disabled={!canSort}
|
disabled={!canSort}
|
||||||
>
|
>
|
||||||
<ArrowDownIcon className="size-3.5!" />
|
<ArrowDownIcon className="size-3.5!" />
|
||||||
<span className="grow">Desc</span>
|
<span className="grow">{i18n.labels.sortDescending}</span>
|
||||||
{isSorted === "desc" && (
|
{isSorted === "desc" && (
|
||||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||||
)}
|
)}
|
||||||
@@ -176,7 +219,7 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
|
|||||||
onClick={() => column.pin(isPinned === "start" ? false : "start")}
|
onClick={() => column.pin(isPinned === "start" ? false : "start")}
|
||||||
>
|
>
|
||||||
<ArrowLeftToLineIcon className="size-3.5!" aria-hidden="true" />
|
<ArrowLeftToLineIcon className="size-3.5!" aria-hidden="true" />
|
||||||
<span className="grow">Pin to left</span>
|
<span className="grow">{i18n.labels.pinColumnStart}</span>
|
||||||
{isPinned === "start" && (
|
{isPinned === "start" && (
|
||||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||||
)}
|
)}
|
||||||
@@ -186,7 +229,7 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
|
|||||||
onClick={() => column.pin(isPinned === "end" ? false : "end")}
|
onClick={() => column.pin(isPinned === "end" ? false : "end")}
|
||||||
>
|
>
|
||||||
<ArrowRightToLineIcon className="size-3.5!" aria-hidden="true" />
|
<ArrowRightToLineIcon className="size-3.5!" aria-hidden="true" />
|
||||||
<span className="grow">Pin to right</span>
|
<span className="grow">{i18n.labels.pinColumnEnd}</span>
|
||||||
{isPinned === "end" && (
|
{isPinned === "end" && (
|
||||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||||
)}
|
)}
|
||||||
@@ -204,32 +247,22 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key="move-left"
|
key="move-left"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (columnIndex > 0) {
|
if (leftNeighbour) moveBeside(leftNeighbour, "before")
|
||||||
const newOrder = [...columnOrder]
|
|
||||||
const [movedColumn] = newOrder.splice(columnIndex, 1)
|
|
||||||
newOrder.splice(columnIndex - 1, 0, movedColumn)
|
|
||||||
table.setColumnOrder(newOrder)
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
disabled={!canMoveLeft || isPinned !== false}
|
disabled={!canMoveLeft || isPinned !== false}
|
||||||
>
|
>
|
||||||
<ArrowLeftIcon className="size-3.5!" aria-hidden="true" />
|
<ArrowLeftIcon className="size-3.5!" aria-hidden="true" />
|
||||||
<span>Move to Left</span>
|
<span>{i18n.labels.moveColumnStart}</span>
|
||||||
</DropdownMenuItem>,
|
</DropdownMenuItem>,
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key="move-right"
|
key="move-right"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (columnIndex < columnOrder.length - 1) {
|
if (rightNeighbour) moveBeside(rightNeighbour, "after")
|
||||||
const newOrder = [...columnOrder]
|
|
||||||
const [movedColumn] = newOrder.splice(columnIndex, 1)
|
|
||||||
newOrder.splice(columnIndex + 1, 0, movedColumn)
|
|
||||||
table.setColumnOrder(newOrder)
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
disabled={!canMoveRight || isPinned !== false}
|
disabled={!canMoveRight || isPinned !== false}
|
||||||
>
|
>
|
||||||
<ArrowRightIcon className="size-3.5!" aria-hidden="true" />
|
<ArrowRightIcon className="size-3.5!" aria-hidden="true" />
|
||||||
<span>Move to Right</span>
|
<span>{i18n.labels.moveColumnEnd}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)
|
)
|
||||||
hasPreviousSection = true
|
hasPreviousSection = true
|
||||||
@@ -244,7 +277,7 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
|
|||||||
<DropdownMenuSub key="visibility">
|
<DropdownMenuSub key="visibility">
|
||||||
<DropdownMenuSubTrigger>
|
<DropdownMenuSubTrigger>
|
||||||
<Settings2Icon className="size-3.5!" />
|
<Settings2Icon className="size-3.5!" />
|
||||||
<span>Columns</span>
|
<span>{i18n.labels.columnsMenu}</span>
|
||||||
</DropdownMenuSubTrigger>
|
</DropdownMenuSubTrigger>
|
||||||
<DropdownMenuSubContent side="right">
|
<DropdownMenuSubContent side="right">
|
||||||
{table
|
{table
|
||||||
@@ -282,7 +315,8 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
|
|||||||
canMoveRight,
|
canMoveRight,
|
||||||
visibility,
|
visibility,
|
||||||
table,
|
table,
|
||||||
columnIndex,
|
leftNeighbour,
|
||||||
|
rightNeighbour,
|
||||||
columnOrder,
|
columnOrder,
|
||||||
columnVisibilityKey, // Needed to update checkbox states when visibility changes
|
columnVisibilityKey, // Needed to update checkbox states when visibility changes
|
||||||
])
|
])
|
||||||
@@ -314,8 +348,8 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="rounded-lg -me-1 size-7"
|
className="rounded-lg -me-1 size-7"
|
||||||
onClick={() => column.pin(false)}
|
onClick={() => column.pin(false)}
|
||||||
aria-label={`Unpin ${resolvedTitle} column`}
|
aria-label={i18n.labels.unpinColumn(resolvedTitle)}
|
||||||
title={`Unpin ${resolvedTitle} column`}
|
title={i18n.labels.unpinColumn(resolvedTitle)}
|
||||||
>
|
>
|
||||||
<PinOffIcon className="size-3.5! opacity-50!" aria-hidden="true" />
|
<PinOffIcon className="size-3.5! opacity-50!" aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import type { ReactElement } from "react"
|
import type { ReactElement } from "react"
|
||||||
import { getColumnHeaderLabel } from "@/components/reui/data-grid/data-grid"
|
import {
|
||||||
|
getColumnHeaderLabel,
|
||||||
|
useDataGrid,
|
||||||
|
} from "@/components/reui/data-grid/data-grid"
|
||||||
import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
|
import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
|
||||||
import type { Table } from "@tanstack/react-table"
|
import type { Table } from "@tanstack/react-table"
|
||||||
|
|
||||||
@@ -21,13 +24,15 @@ function DataGridColumnVisibility<TData extends object>({
|
|||||||
table: Table<DataGridFeatures, TData>
|
table: Table<DataGridFeatures, TData>
|
||||||
trigger: ReactElement<Record<string, unknown>>
|
trigger: ReactElement<Record<string, unknown>>
|
||||||
}) {
|
}) {
|
||||||
|
const { i18n } = useDataGrid()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger render={trigger} />
|
<DropdownMenuTrigger render={trigger} />
|
||||||
<DropdownMenuContent align="end" className="min-w-[150px]">
|
<DropdownMenuContent align="end" className="min-w-[150px]">
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuLabel className="font-medium">
|
<DropdownMenuLabel className="font-medium">
|
||||||
Toggle Columns
|
{i18n.labels.toggleColumns}
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
{table
|
{table
|
||||||
.getAllColumns()
|
.getAllColumns()
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
export interface DataGridI18nLabels {
|
||||||
|
/* The column header menu. */
|
||||||
|
sortAscending: string
|
||||||
|
sortDescending: string
|
||||||
|
pinColumnStart: string
|
||||||
|
pinColumnEnd: string
|
||||||
|
moveColumnStart: string
|
||||||
|
moveColumnEnd: string
|
||||||
|
columnsMenu: string
|
||||||
|
unpinColumn: (title: string) => string
|
||||||
|
toggleColumns: string
|
||||||
|
/* Row and cell affordances. */
|
||||||
|
rowCreate: string
|
||||||
|
pinRow: string
|
||||||
|
unpinRow: string
|
||||||
|
selectRow: string
|
||||||
|
selectAll: string
|
||||||
|
expandRow: string
|
||||||
|
collapseRow: string
|
||||||
|
dragToReorder: string
|
||||||
|
dragToReorderRow: string
|
||||||
|
reorderingUnavailable: string
|
||||||
|
/* Grid states. */
|
||||||
|
loading: string
|
||||||
|
empty: string
|
||||||
|
allRowsLoaded: string
|
||||||
|
/* Pagination. */
|
||||||
|
rowsPerPage: string
|
||||||
|
paginationInfo: (info: { from: number; to: number; count: number }) => string
|
||||||
|
previousPage: string
|
||||||
|
nextPage: string
|
||||||
|
goToPage: (page: number) => string
|
||||||
|
paginationEllipsis: string
|
||||||
|
/* The faceted column filter. */
|
||||||
|
filterSelectedCount: (count: number) => string
|
||||||
|
filterNoResults: string
|
||||||
|
filterClear: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataGridI18nConfig {
|
||||||
|
labels: DataGridI18nLabels
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DataGridI18nOverrides = {
|
||||||
|
labels?: Partial<DataGridI18nLabels>
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_DATA_GRID_LABELS: DataGridI18nLabels = {
|
||||||
|
sortAscending: "Asc",
|
||||||
|
sortDescending: "Desc",
|
||||||
|
pinColumnStart: "Pin to left",
|
||||||
|
pinColumnEnd: "Pin to right",
|
||||||
|
moveColumnStart: "Move to left",
|
||||||
|
moveColumnEnd: "Move to right",
|
||||||
|
columnsMenu: "Columns",
|
||||||
|
unpinColumn: (title) => `Unpin ${title} column`,
|
||||||
|
toggleColumns: "Toggle Columns",
|
||||||
|
rowCreate: "Add row",
|
||||||
|
pinRow: "Pin row",
|
||||||
|
unpinRow: "Unpin row",
|
||||||
|
selectRow: "Select row",
|
||||||
|
selectAll: "Select all",
|
||||||
|
expandRow: "Expand row",
|
||||||
|
collapseRow: "Collapse row",
|
||||||
|
dragToReorder: "Drag to reorder",
|
||||||
|
dragToReorderRow: "Drag to reorder row",
|
||||||
|
reorderingUnavailable: "Reordering unavailable",
|
||||||
|
loading: "Loading...",
|
||||||
|
empty: "No data available",
|
||||||
|
allRowsLoaded: "All records loaded",
|
||||||
|
rowsPerPage: "Rows per page",
|
||||||
|
paginationInfo: ({ from, to, count }) => `${from} - ${to} of ${count}`,
|
||||||
|
previousPage: "Go to previous page",
|
||||||
|
nextPage: "Go to next page",
|
||||||
|
goToPage: (page) => `Go to page ${page}`,
|
||||||
|
paginationEllipsis: "...",
|
||||||
|
filterSelectedCount: (count) => `${count} selected`,
|
||||||
|
filterNoResults: "No results found.",
|
||||||
|
filterClear: "Clear filters",
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_DATA_GRID_I18N: DataGridI18nConfig = Object.freeze({
|
||||||
|
labels: Object.freeze(DEFAULT_DATA_GRID_LABELS),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A shallow merge per section, deliberately: a deep merge would leak a
|
||||||
|
* default back into a function-valued label the consumer replaced. With no
|
||||||
|
* overrides the frozen default is returned as-is, so the merge is free to
|
||||||
|
* run on every render without producing a new identity.
|
||||||
|
*/
|
||||||
|
export function mergeDataGridI18n(
|
||||||
|
overrides?: DataGridI18nOverrides
|
||||||
|
): DataGridI18nConfig {
|
||||||
|
if (!overrides?.labels) return DEFAULT_DATA_GRID_I18N
|
||||||
|
return {
|
||||||
|
labels: { ...DEFAULT_DATA_GRID_LABELS, ...overrides.labels },
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ interface DataGridPaginationProps {
|
|||||||
sizesDescription?: string
|
sizesDescription?: string
|
||||||
sizesSkeleton?: ReactNode
|
sizesSkeleton?: ReactNode
|
||||||
more?: boolean
|
more?: boolean
|
||||||
|
/** Page buttons the truncated middle shows; the row adds first, last and
|
||||||
|
* an ellipsis for each hidden stretch. Clamped to a minimum of 3. */
|
||||||
moreLimit?: number
|
moreLimit?: number
|
||||||
info?: string
|
info?: string
|
||||||
infoSkeleton?: ReactNode
|
infoSkeleton?: ReactNode
|
||||||
@@ -30,19 +32,113 @@ interface DataGridPaginationProps {
|
|||||||
ellipsisText?: string
|
ellipsisText?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One slot in the page row: a page button, or an ellipsis that jumps over the
|
||||||
|
* pages it stands for.
|
||||||
|
*/
|
||||||
|
type DataGridPaginationItem =
|
||||||
|
| { type: "page"; index: number }
|
||||||
|
| { type: "ellipsis"; direction: "previous"; target: number }
|
||||||
|
| { type: "ellipsis"; direction: "next"; target: number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The adaptive window: first page, the run around the current one, last page,
|
||||||
|
* with an ellipsis standing in for each hidden stretch. It replaced fixed
|
||||||
|
* BLOCKS (1-5, then 6-10) because a block never shows the last page, so on a
|
||||||
|
* 50-page grid there was no way to reach the end - the primitive wires only
|
||||||
|
* `previousPage()` / `nextPage()`, so there is no last-page arrow either.
|
||||||
|
*
|
||||||
|
* `limit` is `moreLimit`: how many page buttons the truncated middle shows.
|
||||||
|
* At the default of 5 this emits exactly the same rows as the block version
|
||||||
|
* did at `moreLimit: 5`, so the prop keeps both its meaning and its default.
|
||||||
|
* An ellipsis never stands for a single page, because the early return covers
|
||||||
|
* every count a full row could hold.
|
||||||
|
*/
|
||||||
|
function getDataGridPaginationItems(
|
||||||
|
pageIndex: number,
|
||||||
|
pageCount: number,
|
||||||
|
limit: number
|
||||||
|
): DataGridPaginationItem[] {
|
||||||
|
// Pages flanking the current one, per side. The run is rebuilt from it so
|
||||||
|
// the count is always odd: an even `limit` would make the head and tail
|
||||||
|
// rows one slot wider than the middle, and the footer would twitch as the
|
||||||
|
// user pages through.
|
||||||
|
const sibling = Math.max(0, Math.floor(((Math.floor(limit) || 3) - 3) / 2))
|
||||||
|
const pages = sibling * 2 + 3
|
||||||
|
|
||||||
|
// Two ellipses cost the width of two pages, so below this there is nothing
|
||||||
|
// to gain by hiding any.
|
||||||
|
if (pageCount <= pages + 2) {
|
||||||
|
return Array.from({ length: pageCount }, (_, index) => ({
|
||||||
|
type: "page" as const,
|
||||||
|
index,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageIndex <= sibling + 2) {
|
||||||
|
return [
|
||||||
|
...Array.from({ length: pages }, (_, index) => ({
|
||||||
|
type: "page" as const,
|
||||||
|
index,
|
||||||
|
})),
|
||||||
|
{ type: "ellipsis", direction: "next", target: pages },
|
||||||
|
{ type: "page", index: pageCount - 1 },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageIndex >= pageCount - sibling - 3) {
|
||||||
|
return [
|
||||||
|
{ type: "page", index: 0 },
|
||||||
|
{
|
||||||
|
type: "ellipsis",
|
||||||
|
direction: "previous",
|
||||||
|
target: pageCount - pages - 1,
|
||||||
|
},
|
||||||
|
...Array.from({ length: pages }, (_, offset) => ({
|
||||||
|
type: "page" as const,
|
||||||
|
index: pageCount - pages + offset,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ type: "page", index: 0 },
|
||||||
|
{
|
||||||
|
type: "ellipsis",
|
||||||
|
direction: "previous",
|
||||||
|
target: pageIndex - sibling - 1,
|
||||||
|
},
|
||||||
|
...Array.from({ length: sibling * 2 + 1 }, (_, offset) => ({
|
||||||
|
type: "page" as const,
|
||||||
|
index: pageIndex - sibling + offset,
|
||||||
|
})),
|
||||||
|
{ type: "ellipsis", direction: "next", target: pageIndex + sibling + 1 },
|
||||||
|
{ type: "page", index: pageCount - 1 },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A page button is square at one digit and grows from there, so the row does
|
||||||
|
* not reflow when the count crosses 10 or 100. The floor is each style's own
|
||||||
|
* `icon-sm` square, which is also its `sm` HEIGHT, so the numbers stay square
|
||||||
|
* and keep the arrows' height. Hardcoding one value would be right in three
|
||||||
|
* styles and wrong in five.
|
||||||
|
*/
|
||||||
|
const PAGE_BUTTON_WIDTH_CLASS =
|
||||||
|
"min-w-7"
|
||||||
|
|
||||||
function DataGridPagination(props: DataGridPaginationProps): JSX.Element {
|
function DataGridPagination(props: DataGridPaginationProps): JSX.Element {
|
||||||
const { table, recordCount, isLoading } = useDataGrid()
|
const { i18n, table, recordCount, isLoading } = useDataGrid()
|
||||||
|
|
||||||
const defaultProps: Partial<DataGridPaginationProps> = {
|
const defaultProps: Partial<DataGridPaginationProps> = {
|
||||||
sizes: [5, 10, 25, 50, 100],
|
sizes: [5, 10, 25, 50, 100],
|
||||||
sizesSkeleton: <Skeleton className="h-8 w-44" />,
|
sizesSkeleton: <Skeleton className="h-8 w-44" />,
|
||||||
moreLimit: 5,
|
moreLimit: 5,
|
||||||
info: "{from} - {to} of {count}",
|
|
||||||
infoSkeleton: <Skeleton className="h-8 w-60" />,
|
infoSkeleton: <Skeleton className="h-8 w-60" />,
|
||||||
rowsPerPageLabel: "Rows per page",
|
rowsPerPageLabel: i18n.labels.rowsPerPage,
|
||||||
previousPageLabel: "Go to previous page",
|
previousPageLabel: i18n.labels.previousPage,
|
||||||
nextPageLabel: "Go to next page",
|
nextPageLabel: i18n.labels.nextPage,
|
||||||
ellipsisText: "...",
|
ellipsisText: i18n.labels.paginationEllipsis,
|
||||||
}
|
}
|
||||||
|
|
||||||
const mergedProps: DataGridPaginationProps = { ...defaultProps, ...props }
|
const mergedProps: DataGridPaginationProps = { ...defaultProps, ...props }
|
||||||
@@ -55,84 +151,21 @@ function DataGridPagination(props: DataGridPaginationProps): JSX.Element {
|
|||||||
const to = Math.min((pageIndex + 1) * pageSize, recordCount)
|
const to = Math.min((pageIndex + 1) * pageSize, recordCount)
|
||||||
const pageCount = table.getPageCount()
|
const pageCount = table.getPageCount()
|
||||||
|
|
||||||
// Replace placeholders in paginationInfo
|
// A supplied `info` keeps its placeholder-template contract; the default
|
||||||
|
// routes through the i18n label function, where word order is free.
|
||||||
const paginationInfo = mergedProps.info
|
const paginationInfo = mergedProps.info
|
||||||
? mergedProps.info
|
? mergedProps.info
|
||||||
.replaceAll("{from}", from.toString())
|
.replaceAll("{from}", from.toString())
|
||||||
.replaceAll("{to}", to.toString())
|
.replaceAll("{to}", to.toString())
|
||||||
.replaceAll("{count}", recordCount.toString())
|
.replaceAll("{count}", recordCount.toString())
|
||||||
: `${from} - ${to} of ${recordCount}`
|
: i18n.labels.paginationInfo({ from, to, count: recordCount })
|
||||||
|
|
||||||
// Pagination limit logic
|
const paginationItems = getDataGridPaginationItems(
|
||||||
const paginationMoreLimit = mergedProps.moreLimit || 5
|
pageIndex,
|
||||||
|
pageCount,
|
||||||
// Determine the start and end of the pagination group
|
mergedProps.moreLimit ?? 5
|
||||||
const currentGroupStart =
|
|
||||||
Math.floor(pageIndex / paginationMoreLimit) * paginationMoreLimit
|
|
||||||
const currentGroupEnd = Math.min(
|
|
||||||
currentGroupStart + paginationMoreLimit,
|
|
||||||
pageCount
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Render page buttons based on the current group
|
|
||||||
const renderPageButtons = () => {
|
|
||||||
const buttons = []
|
|
||||||
for (let i = currentGroupStart; i < currentGroupEnd; i++) {
|
|
||||||
buttons.push(
|
|
||||||
<Button
|
|
||||||
key={i}
|
|
||||||
size="icon-sm"
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(btnBaseClasses, "text-muted-foreground", {
|
|
||||||
"bg-accent text-accent-foreground": pageIndex === i,
|
|
||||||
})}
|
|
||||||
onClick={() => {
|
|
||||||
if (pageIndex !== i) {
|
|
||||||
table.setPageIndex(i)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{i + 1}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return buttons
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render a "previous" ellipsis button if there are previous pages to show
|
|
||||||
const renderEllipsisPrevButton = () => {
|
|
||||||
if (currentGroupStart > 0) {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
size="icon-sm"
|
|
||||||
className={btnBaseClasses}
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => table.setPageIndex(currentGroupStart - 1)}
|
|
||||||
>
|
|
||||||
{mergedProps.ellipsisText}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render a "next" ellipsis button if there are more pages to show after the current group
|
|
||||||
const renderEllipsisNextButton = () => {
|
|
||||||
if (currentGroupEnd < pageCount) {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
className={btnBaseClasses}
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
onClick={() => table.setPageIndex(currentGroupEnd)}
|
|
||||||
>
|
|
||||||
{mergedProps.ellipsisText}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="data-grid-pagination"
|
data-slot="data-grid-pagination"
|
||||||
@@ -156,7 +189,15 @@ function DataGridPagination(props: DataGridPaginationProps): JSX.Element {
|
|||||||
table.setPageSize(newPageSize)
|
table.setPageSize(newPageSize)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-16" size="sm">
|
{/* w-fit with a min, never a fixed width: a fixed w-16 clipped
|
||||||
|
the value "100" by 1px at nova's paddings, while fit-content
|
||||||
|
grows the trigger for 3-digit sizes and the min keeps the
|
||||||
|
1-2 digit ones from collapsing narrower than 64px. */}
|
||||||
|
<SelectTrigger
|
||||||
|
aria-label={mergedProps.rowsPerPageLabel}
|
||||||
|
className="w-fit min-w-16"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent
|
<SelectContent
|
||||||
@@ -183,7 +224,7 @@ function DataGridPagination(props: DataGridPaginationProps): JSX.Element {
|
|||||||
{paginationInfo}
|
{paginationInfo}
|
||||||
</div>
|
</div>
|
||||||
{pageCount > 1 && (
|
{pageCount > 1 && (
|
||||||
<div className="order-1 flex items-center space-x-1">
|
<div className="order-1 flex flex-wrap items-center justify-center gap-1 sm:flex-nowrap">
|
||||||
<Button
|
<Button
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -197,11 +238,49 @@ function DataGridPagination(props: DataGridPaginationProps): JSX.Element {
|
|||||||
<ChevronLeftIcon className="size-4" />
|
<ChevronLeftIcon className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{renderEllipsisPrevButton()}
|
{paginationItems.map((item) =>
|
||||||
|
item.type === "page" ? (
|
||||||
{renderPageButtons()}
|
<Button
|
||||||
|
key={`page-${item.index}`}
|
||||||
{renderEllipsisNextButton()}
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={i18n.labels.goToPage(item.index + 1)}
|
||||||
|
aria-current={
|
||||||
|
pageIndex === item.index ? "page" : undefined
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
PAGE_BUTTON_WIDTH_CLASS,
|
||||||
|
"px-1.5 text-sm",
|
||||||
|
"text-muted-foreground",
|
||||||
|
{
|
||||||
|
"bg-accent text-accent-foreground":
|
||||||
|
pageIndex === item.index,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (pageIndex !== item.index) {
|
||||||
|
table.setPageIndex(item.index)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.index + 1}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
/* Clickable, unlike the shadcn PaginationEllipsis, which is
|
||||||
|
an aria-hidden span. This one MOVES the user, so it needs
|
||||||
|
a name saying where. */
|
||||||
|
<Button
|
||||||
|
key={`ellipsis-${item.direction}`}
|
||||||
|
size="icon-sm"
|
||||||
|
className={btnBaseClasses}
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={i18n.labels.goToPage(item.target + 1)}
|
||||||
|
onClick={() => table.setPageIndex(item.target)}
|
||||||
|
>
|
||||||
|
{mergedProps.ellipsisText}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
|
|||||||
@@ -19,8 +19,11 @@ const INITIAL_METRICS = {
|
|||||||
trackHeight: 0,
|
trackHeight: 0,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
// Track footprint, measured: horizontal is 8px tall and vertical 6px wide, and
|
||||||
|
// each loses 1px to its transparent border plus 2px to p-px, so the thumbs land
|
||||||
|
// at 5px and 3px. Shrink these further and the thumb stops being a grab target.
|
||||||
const SCROLLBAR_CLASSNAME =
|
const SCROLLBAR_CLASSNAME =
|
||||||
"flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
|
"flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
|
||||||
|
|
||||||
const SCROLLBAR_THUMB_CLASSNAME = "bg-border rounded-full relative flex-1"
|
const SCROLLBAR_THUMB_CLASSNAME = "bg-border rounded-full relative flex-1"
|
||||||
|
|
||||||
@@ -454,12 +457,12 @@ function DataGridScrollArea({
|
|||||||
className="pointer-events-none absolute inset-e-0 top-(--data-grid-scrollbar-header-height) z-20 h-(--data-grid-scrollbar-track-height)"
|
className="pointer-events-none absolute inset-e-0 top-(--data-grid-scrollbar-header-height) z-20 h-(--data-grid-scrollbar-track-height)"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="pointer-events-auto relative h-full w-2 touch-none p-px"
|
className="pointer-events-auto relative h-full w-1.5 touch-none p-px"
|
||||||
onPointerDown={handleTrackPointerDown}
|
onPointerDown={handleTrackPointerDown}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-border absolute end-px w-2",
|
"bg-border absolute end-px w-1.5",
|
||||||
"top-(--data-grid-scrollbar-thumb-top) h-(--data-grid-scrollbar-thumb-height)",
|
"top-(--data-grid-scrollbar-thumb-top) h-(--data-grid-scrollbar-thumb-height)",
|
||||||
"rounded-full"
|
"rounded-full"
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
memo,
|
memo,
|
||||||
@@ -105,7 +107,7 @@ type DataGridTableDndRowDecoration<TData extends object> = (context: {
|
|||||||
function DataGridTableDndRowHandle({
|
function DataGridTableDndRowHandle({
|
||||||
className,
|
className,
|
||||||
disabled,
|
disabled,
|
||||||
disabledLabel = "Reordering unavailable",
|
disabledLabel,
|
||||||
}: {
|
}: {
|
||||||
className?: string
|
className?: string
|
||||||
/**
|
/**
|
||||||
@@ -118,7 +120,10 @@ function DataGridTableDndRowHandle({
|
|||||||
/** Announced and shown on hover in place of the drag affordance. */
|
/** Announced and shown on hover in place of the drag affordance. */
|
||||||
disabledLabel?: string
|
disabledLabel?: string
|
||||||
}) {
|
}) {
|
||||||
|
const { i18n } = useDataGrid()
|
||||||
const context = useContext(SortableRowContext)
|
const context = useContext(SortableRowContext)
|
||||||
|
const resolvedDisabledLabel =
|
||||||
|
disabledLabel ?? i18n.labels.reorderingUnavailable
|
||||||
|
|
||||||
if (!context || disabled) {
|
if (!context || disabled) {
|
||||||
return (
|
return (
|
||||||
@@ -133,8 +138,8 @@ function DataGridTableDndRowHandle({
|
|||||||
disabled && "cursor-not-allowed",
|
disabled && "cursor-not-allowed",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
aria-label={disabled ? disabledLabel : "Drag to reorder row"}
|
aria-label={disabled ? resolvedDisabledLabel : i18n.labels.dragToReorderRow}
|
||||||
title={disabled ? disabledLabel : undefined}
|
title={disabled ? resolvedDisabledLabel : undefined}
|
||||||
disabled
|
disabled
|
||||||
>
|
>
|
||||||
<GripHorizontalIcon aria-hidden="true" />
|
<GripHorizontalIcon aria-hidden="true" />
|
||||||
@@ -150,7 +155,7 @@ function DataGridTableDndRowHandle({
|
|||||||
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
aria-label="Drag to reorder row"
|
aria-label={i18n.labels.dragToReorderRow}
|
||||||
{...context.attributes}
|
{...context.attributes}
|
||||||
{...context.listeners}
|
{...context.listeners}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Fragment,
|
Fragment,
|
||||||
memo,
|
memo,
|
||||||
@@ -69,7 +67,7 @@ function DataGridTableDndHeader<TData extends object>({
|
|||||||
}: {
|
}: {
|
||||||
header: Header<DataGridFeatures, TData, unknown>
|
header: Header<DataGridFeatures, TData, unknown>
|
||||||
}) {
|
}) {
|
||||||
const { props } = useDataGrid()
|
const { i18n, props } = useDataGrid()
|
||||||
const { column } = header
|
const { column } = header
|
||||||
|
|
||||||
// Check if column ordering is enabled for this column
|
// Check if column ordering is enabled for this column
|
||||||
@@ -115,7 +113,7 @@ function DataGridTableDndHeader<TData extends object>({
|
|||||||
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
|
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
aria-label="Drag to reorder"
|
aria-label={i18n.labels.dragToReorder}
|
||||||
>
|
>
|
||||||
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
|
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useCallback, useEffect, useRef, useState } from "react"
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import type { CSSProperties, ReactNode } from "react"
|
import type { CSSProperties, ReactNode } from "react"
|
||||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||||
import type {
|
import type {
|
||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
DataGridTableInstance,
|
DataGridTableInstance,
|
||||||
} from "@/components/reui/data-grid/data-grid"
|
} from "@/components/reui/data-grid/data-grid"
|
||||||
import {
|
import {
|
||||||
|
DataGridTableAddRow,
|
||||||
DataGridTableBase,
|
DataGridTableBase,
|
||||||
DataGridTableBody,
|
DataGridTableBody,
|
||||||
DataGridTableEmpty,
|
DataGridTableEmpty,
|
||||||
@@ -49,6 +50,33 @@ type DataGridTableVirtualizerInstance = Virtualizer<
|
|||||||
|
|
||||||
type DataGridTableVirtualScrollAlignment = "auto" | "center" | "start" | "end"
|
type DataGridTableVirtualScrollAlignment = "auto" | "center" | "start" | "end"
|
||||||
|
|
||||||
|
interface DataGridTableColumnVirtualizerOptions {
|
||||||
|
/** Off unless explicitly true; a column window is opt-in. */
|
||||||
|
enabled?: boolean
|
||||||
|
overscan?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type DataGridTableColumnScrollRequest = {
|
||||||
|
align: DataGridTableVirtualScrollAlignment
|
||||||
|
behavior: ScrollBehavior
|
||||||
|
columnId: string | undefined
|
||||||
|
columnIndex: number
|
||||||
|
scrollElement: HTMLElement
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameDataGridTableColumnScrollRequest(
|
||||||
|
previous: DataGridTableColumnScrollRequest | null,
|
||||||
|
next: DataGridTableColumnScrollRequest
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
previous?.align === next.align &&
|
||||||
|
previous.behavior === next.behavior &&
|
||||||
|
previous.columnId === next.columnId &&
|
||||||
|
previous.columnIndex === next.columnIndex &&
|
||||||
|
previous.scrollElement === next.scrollElement
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
type DataGridTableVirtualScrollRequest = {
|
type DataGridTableVirtualScrollRequest = {
|
||||||
align: DataGridTableVirtualScrollAlignment
|
align: DataGridTableVirtualScrollAlignment
|
||||||
behavior: ScrollBehavior
|
behavior: ScrollBehavior
|
||||||
@@ -267,6 +295,17 @@ interface DataGridTableVirtualProps<TData extends object> {
|
|||||||
scrollToRowAlign?: DataGridTableVirtualScrollAlignment
|
scrollToRowAlign?: DataGridTableVirtualScrollAlignment
|
||||||
/** Index within the center (non-pinned) row section to reveal. */
|
/** Index within the center (non-pinned) row section to reveal. */
|
||||||
scrollToRowIndex?: number
|
scrollToRowIndex?: number
|
||||||
|
/**
|
||||||
|
* Opt-in horizontal virtualization of the CENTER columns. Activates only
|
||||||
|
* under a fixed table layout with a single ungrouped header row; pinned
|
||||||
|
* columns stay mounted, and anything else falls back to full-column
|
||||||
|
* rendering. `scrollBehavior` is shared with controlled row scrolling.
|
||||||
|
*/
|
||||||
|
columnVirtualizerOptions?: DataGridTableColumnVirtualizerOptions
|
||||||
|
/** Alignment used when revealing a controlled target column. */
|
||||||
|
scrollToColumnAlign?: DataGridTableVirtualScrollAlignment
|
||||||
|
/** Index within the center (non-pinned) visible leaf columns to reveal. */
|
||||||
|
scrollToColumnIndex?: number
|
||||||
footerContent?: ReactNode
|
footerContent?: ReactNode
|
||||||
renderHeader?: boolean
|
renderHeader?: boolean
|
||||||
onFetchMore?: () => void
|
onFetchMore?: () => void
|
||||||
@@ -290,6 +329,7 @@ interface VirtualBodyProps<TData extends object> {
|
|||||||
loadingMoreMessage: ReactNode
|
loadingMoreMessage: ReactNode
|
||||||
allRowsLoadedMessage: ReactNode
|
allRowsLoadedMessage: ReactNode
|
||||||
measureRowRef?: (element: HTMLTableRowElement | null) => void
|
measureRowRef?: (element: HTMLTableRowElement | null) => void
|
||||||
|
centerColumnWindow?: { start: number; end: number }
|
||||||
}
|
}
|
||||||
|
|
||||||
function DataGridTableVirtualPinnedPlaceholderCell<TData extends object>({
|
function DataGridTableVirtualPinnedPlaceholderCell<TData extends object>({
|
||||||
@@ -424,6 +464,24 @@ function DataGridTableVirtualStatusRow<TData extends object>({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A scroll frame only shifts the window, so every surviving row's inputs are
|
||||||
|
* identical and its last render is reused; the per-frame cost is the rows
|
||||||
|
* entering the window, not all mounted rows. Cell-level state (selection,
|
||||||
|
* focus, row checks) repaints through each cell's own Subscribe, and any real
|
||||||
|
* data change rebuilds the row wrappers, so identity comparison is safe.
|
||||||
|
*/
|
||||||
|
const MemoizedRenderedRow = memo(
|
||||||
|
DataGridTableRenderedRow,
|
||||||
|
(prev, next) =>
|
||||||
|
prev.row === next.row &&
|
||||||
|
prev.rowIndex === next.rowIndex &&
|
||||||
|
prev.rowRef === next.rowRef &&
|
||||||
|
prev.pinnedBoundary === next.pinnedBoundary &&
|
||||||
|
prev.centerWindow?.start === next.centerWindow?.start &&
|
||||||
|
prev.centerWindow?.end === next.centerWindow?.end
|
||||||
|
) as typeof DataGridTableRenderedRow
|
||||||
|
|
||||||
function DataGridTableVirtualBody<TData extends object>({
|
function DataGridTableVirtualBody<TData extends object>({
|
||||||
table,
|
table,
|
||||||
topRows,
|
topRows,
|
||||||
@@ -438,6 +496,7 @@ function DataGridTableVirtualBody<TData extends object>({
|
|||||||
loadingMoreMessage,
|
loadingMoreMessage,
|
||||||
allRowsLoadedMessage,
|
allRowsLoadedMessage,
|
||||||
measureRowRef,
|
measureRowRef,
|
||||||
|
centerColumnWindow,
|
||||||
}: VirtualBodyProps<TData>) {
|
}: VirtualBodyProps<TData>) {
|
||||||
const { isLoading } = useDataGrid()
|
const { isLoading } = useDataGrid()
|
||||||
const totalRows = topRows.length + centerRows.length + bottomRows.length
|
const totalRows = topRows.length + centerRows.length + bottomRows.length
|
||||||
@@ -479,9 +538,10 @@ function DataGridTableVirtualBody<TData extends object>({
|
|||||||
|
|
||||||
topRows.forEach((row, index) => {
|
topRows.forEach((row, index) => {
|
||||||
renderedRows.push(
|
renderedRows.push(
|
||||||
<DataGridTableRenderedRow
|
<MemoizedRenderedRow
|
||||||
key={row.id}
|
key={row.id}
|
||||||
row={row}
|
row={row}
|
||||||
|
centerWindow={centerColumnWindow}
|
||||||
pinnedBoundary={
|
pinnedBoundary={
|
||||||
index === topRows.length - 1 && hasMiddleSection ? "top" : undefined
|
index === topRows.length - 1 && hasMiddleSection ? "top" : undefined
|
||||||
}
|
}
|
||||||
@@ -506,11 +566,12 @@ function DataGridTableVirtualBody<TData extends object>({
|
|||||||
if (!row) return
|
if (!row) return
|
||||||
|
|
||||||
renderedRows.push(
|
renderedRows.push(
|
||||||
<DataGridTableRenderedRow
|
<MemoizedRenderedRow
|
||||||
key={row.id}
|
key={row.id}
|
||||||
row={row}
|
row={row}
|
||||||
rowRef={measureRowRef}
|
rowRef={measureRowRef}
|
||||||
rowIndex={virtualRow.index}
|
rowIndex={virtualRow.index}
|
||||||
|
centerWindow={centerColumnWindow}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -527,7 +588,12 @@ function DataGridTableVirtualBody<TData extends object>({
|
|||||||
} else {
|
} else {
|
||||||
centerRows.forEach((row, rowIndex) => {
|
centerRows.forEach((row, rowIndex) => {
|
||||||
renderedRows.push(
|
renderedRows.push(
|
||||||
<DataGridTableRenderedRow key={row.id} row={row} rowIndex={rowIndex} />
|
<MemoizedRenderedRow
|
||||||
|
key={row.id}
|
||||||
|
row={row}
|
||||||
|
rowIndex={rowIndex}
|
||||||
|
centerWindow={centerColumnWindow}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -557,9 +623,10 @@ function DataGridTableVirtualBody<TData extends object>({
|
|||||||
|
|
||||||
bottomRows.forEach((row, index) => {
|
bottomRows.forEach((row, index) => {
|
||||||
renderedRows.push(
|
renderedRows.push(
|
||||||
<DataGridTableRenderedRow
|
<MemoizedRenderedRow
|
||||||
key={row.id}
|
key={row.id}
|
||||||
row={row}
|
row={row}
|
||||||
|
centerWindow={centerColumnWindow}
|
||||||
pinnedBoundary={
|
pinnedBoundary={
|
||||||
index === 0 && (topRows.length > 0 || hasMiddleSection)
|
index === 0 && (topRows.length > 0 || hasMiddleSection)
|
||||||
? "bottom"
|
? "bottom"
|
||||||
@@ -576,10 +643,15 @@ function DataGridTableVirtualBody<TData extends object>({
|
|||||||
* Memoized virtual body: skip re-renders during active column resize.
|
* Memoized virtual body: skip re-renders during active column resize.
|
||||||
* Column widths update via CSS variables on the <table> element,
|
* Column widths update via CSS variables on the <table> element,
|
||||||
* so the browser handles width changes without React re-renders.
|
* so the browser handles width changes without React re-renders.
|
||||||
|
* A cell-selection drag gets the same treatment: painting goes through each
|
||||||
|
* cell's own Subscribe, and the virtualizer re-renders itself from inside the
|
||||||
|
* memo boundary, so parent-driven reconciliation during the drag is waste.
|
||||||
*/
|
*/
|
||||||
const MemoizedVirtualBody = memo(
|
const MemoizedVirtualBody = memo(
|
||||||
DataGridTableVirtualBody,
|
DataGridTableVirtualBody,
|
||||||
(_prev, next) => !!next.table.state.columnResizing.isResizingColumn
|
(_prev, next) =>
|
||||||
|
!!next.table.state.columnResizing.isResizingColumn ||
|
||||||
|
next.table._isSelectingCells === true
|
||||||
) as typeof DataGridTableVirtualBody
|
) as typeof DataGridTableVirtualBody
|
||||||
|
|
||||||
function DataGridTableVirtual<TData extends object>({
|
function DataGridTableVirtual<TData extends object>({
|
||||||
@@ -589,6 +661,9 @@ function DataGridTableVirtual<TData extends object>({
|
|||||||
scrollBehavior = "auto",
|
scrollBehavior = "auto",
|
||||||
scrollToRowAlign = "auto",
|
scrollToRowAlign = "auto",
|
||||||
scrollToRowIndex,
|
scrollToRowIndex,
|
||||||
|
columnVirtualizerOptions,
|
||||||
|
scrollToColumnAlign = "auto",
|
||||||
|
scrollToColumnIndex,
|
||||||
footerContent,
|
footerContent,
|
||||||
renderHeader = true,
|
renderHeader = true,
|
||||||
onFetchMore,
|
onFetchMore,
|
||||||
@@ -597,9 +672,22 @@ function DataGridTableVirtual<TData extends object>({
|
|||||||
fetchMoreOffset = 0,
|
fetchMoreOffset = 0,
|
||||||
virtualizerOptions,
|
virtualizerOptions,
|
||||||
}: DataGridTableVirtualProps<TData>) {
|
}: DataGridTableVirtualProps<TData>) {
|
||||||
const { table, props } = useDataGrid<TData>()
|
const { i18n, table, props } = useDataGrid<TData>()
|
||||||
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
|
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
|
||||||
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
|
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
|
||||||
|
const centerVisibleColumns = table.getCenterVisibleLeafColumns()
|
||||||
|
// Column windows only where the geometry is provable: a fixed table
|
||||||
|
// layout (undefined defaults to fixed; the colgroup then owns every
|
||||||
|
// width, so colSpan spacers cannot drift) and a single ungrouped header
|
||||||
|
// row (a colSpan group cannot be windowed). Anything else falls back to
|
||||||
|
// full-column rendering silently, the same posture as row virtualization
|
||||||
|
// toward unsupported shapes.
|
||||||
|
const columnVirtualizationActive =
|
||||||
|
columnVirtualizerOptions?.enabled === true &&
|
||||||
|
props.tableLayout?.width !== "auto" &&
|
||||||
|
mergedHeaderGroups.length === 1 &&
|
||||||
|
(mergedHeaderGroups[0]?.headers.every((header) => header.colSpan <= 1) ??
|
||||||
|
false)
|
||||||
const { topRows, centerRows, bottomRows } = getDataGridTableRowSections(
|
const { topRows, centerRows, bottomRows } = getDataGridTableRowSections(
|
||||||
table,
|
table,
|
||||||
props.tableLayout?.rowsPinnable
|
props.tableLayout?.rowsPinnable
|
||||||
@@ -622,9 +710,9 @@ function DataGridTableVirtual<TData extends object>({
|
|||||||
|
|
||||||
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
|
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
|
||||||
const loadingMoreMessage =
|
const loadingMoreMessage =
|
||||||
props.fetchingMoreMessage || props.loadingMessage || "Loading..."
|
props.fetchingMoreMessage || props.loadingMessage || i18n.labels.loading
|
||||||
const allRowsLoadedMessage =
|
const allRowsLoadedMessage =
|
||||||
props.allRowsLoadedMessage || "All records loaded"
|
props.allRowsLoadedMessage || i18n.labels.allRowsLoaded
|
||||||
|
|
||||||
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
|
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
|
||||||
setViewportElements({
|
setViewportElements({
|
||||||
@@ -679,6 +767,69 @@ function DataGridTableVirtual<TData extends object>({
|
|||||||
...virtualizerOptionsRest,
|
...virtualizerOptionsRest,
|
||||||
}) as DataGridTableVirtualizerInstance
|
}) as DataGridTableVirtualizerInstance
|
||||||
|
|
||||||
|
// Horizontal offsets invert in RTL; the virtualizer must be told.
|
||||||
|
const isRtl = useMemo(
|
||||||
|
() =>
|
||||||
|
viewportElements.containerElement
|
||||||
|
? getComputedStyle(viewportElements.containerElement).direction ===
|
||||||
|
"rtl"
|
||||||
|
: false,
|
||||||
|
[viewportElements.containerElement]
|
||||||
|
)
|
||||||
|
|
||||||
|
const resolveColumnKey = useCallback(
|
||||||
|
(index: number) => centerVisibleColumns[index]?.id ?? index,
|
||||||
|
[centerVisibleColumns]
|
||||||
|
)
|
||||||
|
|
||||||
|
const resolveColumnEstimateSize = useCallback(
|
||||||
|
(index: number) => centerVisibleColumns[index]?.getSize() ?? 0,
|
||||||
|
[centerVisibleColumns]
|
||||||
|
)
|
||||||
|
|
||||||
|
const columnVirtualizer = useVirtualizer({
|
||||||
|
horizontal: true,
|
||||||
|
isRtl,
|
||||||
|
count: columnVirtualizationActive ? centerVisibleColumns.length : 0,
|
||||||
|
getScrollElement: resolveScrollElement,
|
||||||
|
getItemKey: resolveColumnKey,
|
||||||
|
estimateSize: resolveColumnEstimateSize,
|
||||||
|
overscan: columnVirtualizerOptions?.overscan ?? 3,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Column sizes are exact reads of getSize(), never DOM-measured, so a
|
||||||
|
// resize commit or a visibility/order/pinning change must resync the
|
||||||
|
// virtualizer's cached sizes by hand.
|
||||||
|
useEffect(() => {
|
||||||
|
if (columnVirtualizationActive) columnVirtualizer.measure()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [
|
||||||
|
columnVirtualizationActive,
|
||||||
|
columnVirtualizer,
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
table.state.columnSizing,
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
table.state.columnVisibility,
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
table.state.columnOrder,
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
table.state.columnPinning,
|
||||||
|
])
|
||||||
|
|
||||||
|
const virtualColumns = columnVirtualizationActive
|
||||||
|
? columnVirtualizer.getVirtualItems()
|
||||||
|
: []
|
||||||
|
// The window is a contiguous inclusive [start, end] over the center
|
||||||
|
// columns; before the scroll element resolves the item list is empty and
|
||||||
|
// the grid renders full-width for that first frame.
|
||||||
|
const centerColumnWindow =
|
||||||
|
columnVirtualizationActive && virtualColumns.length > 0
|
||||||
|
? {
|
||||||
|
start: virtualColumns[0]!.index,
|
||||||
|
end: virtualColumns[virtualColumns.length - 1]!.index,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
const virtualItems = isVirtualizationEnabled
|
const virtualItems = isVirtualizationEnabled
|
||||||
? virtualizer.getVirtualItems()
|
? virtualizer.getVirtualItems()
|
||||||
: []
|
: []
|
||||||
@@ -705,6 +856,51 @@ function DataGridTableVirtual<TData extends object>({
|
|||||||
// before the consumer flips isFetchingMore, and loops at end-of-data when
|
// before the consumer flips isFetchingMore, and loops at end-of-data when
|
||||||
// hasMore is never set.
|
// hasMore is never set.
|
||||||
const fetchMoreFiredAtCountRef = useRef<number | null>(null)
|
const fetchMoreFiredAtCountRef = useRef<number | null>(null)
|
||||||
|
const lastColumnScrollRequestRef =
|
||||||
|
useRef<DataGridTableColumnScrollRequest | null>(null)
|
||||||
|
|
||||||
|
// Controlled column reveal: same dedupe posture as the row request - the
|
||||||
|
// signature keeps ordinary renders from re-scrolling, and exact column
|
||||||
|
// sizes mean no post-measure follow-up pass is needed.
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!columnVirtualizationActive ||
|
||||||
|
scrollToColumnIndex === undefined ||
|
||||||
|
scrollToColumnIndex < 0 ||
|
||||||
|
scrollToColumnIndex >= centerVisibleColumns.length ||
|
||||||
|
!viewportElements.scrollElement
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const request: DataGridTableColumnScrollRequest = {
|
||||||
|
align: scrollToColumnAlign,
|
||||||
|
behavior: scrollBehavior,
|
||||||
|
columnId: centerVisibleColumns[scrollToColumnIndex]?.id,
|
||||||
|
columnIndex: scrollToColumnIndex,
|
||||||
|
scrollElement: viewportElements.scrollElement,
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
isSameDataGridTableColumnScrollRequest(
|
||||||
|
lastColumnScrollRequestRef.current,
|
||||||
|
request
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastColumnScrollRequestRef.current = request
|
||||||
|
columnVirtualizer.scrollToIndex(scrollToColumnIndex, {
|
||||||
|
align: scrollToColumnAlign,
|
||||||
|
behavior: scrollBehavior === "smooth" ? "smooth" : "auto",
|
||||||
|
})
|
||||||
|
}, [
|
||||||
|
columnVirtualizationActive,
|
||||||
|
columnVirtualizer,
|
||||||
|
centerVisibleColumns,
|
||||||
|
scrollToColumnAlign,
|
||||||
|
scrollToColumnIndex,
|
||||||
|
scrollBehavior,
|
||||||
|
viewportElements.scrollElement,
|
||||||
|
])
|
||||||
|
|
||||||
// Resolve after every commit so a stable getter can expose a replaced ref;
|
// Resolve after every commit so a stable getter can expose a replaced ref;
|
||||||
// the request signature prevents duplicate scrolling on ordinary renders.
|
// the request signature prevents duplicate scrolling on ordinary renders.
|
||||||
@@ -847,30 +1043,79 @@ function DataGridTableVirtual<TData extends object>({
|
|||||||
virtualItems,
|
virtualItems,
|
||||||
])
|
])
|
||||||
|
|
||||||
return (
|
// The header re-renders only when its real inputs move: the table
|
||||||
<DataGridTableViewport
|
// wrapper (any table state change recreates it), the layout props, and
|
||||||
viewportRef={handleViewportRef}
|
// the column window. A scroll frame changes none of them, so the whole
|
||||||
className={!usesExternalScrollArea ? "block" : undefined}
|
// sortable-header subtree is reused instead of rebuilt per frame.
|
||||||
style={
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
usesExternalScrollArea
|
const headerNode = useMemo(
|
||||||
? undefined
|
() =>
|
||||||
: {
|
renderHeader && (
|
||||||
height,
|
|
||||||
overflow: "auto",
|
|
||||||
position: "relative",
|
|
||||||
// Standalone mode: this node IS the scroll container, so it
|
|
||||||
// must stay at its parent's width (not the resizable table
|
|
||||||
// width) or horizontal scrolling becomes impossible.
|
|
||||||
width: "auto",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DataGridTableBase>
|
|
||||||
{renderHeader && (
|
|
||||||
<DataGridTableHead>
|
<DataGridTableHead>
|
||||||
{mergedHeaderGroups.map((headerGroup) => (
|
{mergedHeaderGroups.map((headerGroup) => (
|
||||||
<DataGridTableHeadRow key={headerGroup.id} rowId={headerGroup.id}>
|
<DataGridTableHeadRow key={headerGroup.id} rowId={headerGroup.id}>
|
||||||
{headerGroup.headers
|
{/* Under an active column window the single ungrouped header
|
||||||
|
row is bucketed start / windowed center / end, with each
|
||||||
|
off-window flank one colSpan spacer sized by the intact
|
||||||
|
colgroup - the same shape the body rows take. */}
|
||||||
|
{centerColumnWindow
|
||||||
|
? headerGroup.headers
|
||||||
|
.filter((header) => header.column.getIsPinned() === "start")
|
||||||
|
.map((header) => (
|
||||||
|
<DataGridTableHeadRowCell header={header} key={header.id}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
{props.tableLayout?.columnsResizable &&
|
||||||
|
header.column.getCanResize() && (
|
||||||
|
<DataGridTableHeadRowCellResize header={header} />
|
||||||
|
)}
|
||||||
|
</DataGridTableHeadRowCell>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
|
{centerColumnWindow && centerColumnWindow.start > 0 ? (
|
||||||
|
<th
|
||||||
|
aria-hidden="true"
|
||||||
|
data-slot="data-grid-table-virtual-col-spacer"
|
||||||
|
colSpan={centerColumnWindow.start}
|
||||||
|
className="p-0"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{centerColumnWindow
|
||||||
|
? headerGroup.headers
|
||||||
|
.filter((header) => !header.column.getIsPinned())
|
||||||
|
.slice(centerColumnWindow.start, centerColumnWindow.end + 1)
|
||||||
|
.map((header) => (
|
||||||
|
<DataGridTableHeadRowCell header={header} key={header.id}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
{props.tableLayout?.columnsResizable &&
|
||||||
|
header.column.getCanResize() && (
|
||||||
|
<DataGridTableHeadRowCellResize header={header} />
|
||||||
|
)}
|
||||||
|
</DataGridTableHeadRowCell>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
|
{centerColumnWindow &&
|
||||||
|
centerVisibleColumns.length - 1 - centerColumnWindow.end > 0 ? (
|
||||||
|
<th
|
||||||
|
aria-hidden="true"
|
||||||
|
data-slot="data-grid-table-virtual-col-spacer"
|
||||||
|
colSpan={
|
||||||
|
centerVisibleColumns.length - 1 - centerColumnWindow.end
|
||||||
|
}
|
||||||
|
className="p-0"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{!centerColumnWindow &&
|
||||||
|
headerGroup.headers
|
||||||
.filter((header) => header.column.getIsPinned() !== "end")
|
.filter((header) => header.column.getIsPinned() !== "end")
|
||||||
.map((header) => {
|
.map((header) => {
|
||||||
const { column } = header
|
const { column } = header
|
||||||
@@ -890,8 +1135,7 @@ function DataGridTableVirtual<TData extends object>({
|
|||||||
</DataGridTableHeadRowCell>
|
</DataGridTableHeadRowCell>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{props.tableLayout?.columnsResizable &&
|
{props.tableLayout?.columnsResizable && hasRightPinnedColumns ? (
|
||||||
hasRightPinnedColumns ? (
|
|
||||||
<DataGridTableFillHeadCell />
|
<DataGridTableFillHeadCell />
|
||||||
) : null}
|
) : null}
|
||||||
{headerGroup.headers
|
{headerGroup.headers
|
||||||
@@ -914,14 +1158,43 @@ function DataGridTableVirtual<TData extends object>({
|
|||||||
</DataGridTableHeadRowCell>
|
</DataGridTableHeadRowCell>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{props.tableLayout?.columnsResizable &&
|
{props.tableLayout?.columnsResizable && !hasRightPinnedColumns ? (
|
||||||
!hasRightPinnedColumns ? (
|
|
||||||
<DataGridTableFillHeadCell />
|
<DataGridTableFillHeadCell />
|
||||||
) : null}
|
) : null}
|
||||||
</DataGridTableHeadRow>
|
</DataGridTableHeadRow>
|
||||||
))}
|
))}
|
||||||
</DataGridTableHead>
|
</DataGridTableHead>
|
||||||
)}
|
),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[
|
||||||
|
renderHeader,
|
||||||
|
table,
|
||||||
|
props.tableLayout,
|
||||||
|
centerColumnWindow,
|
||||||
|
hasRightPinnedColumns,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGridTableViewport
|
||||||
|
viewportRef={handleViewportRef}
|
||||||
|
className={!usesExternalScrollArea ? "block" : undefined}
|
||||||
|
style={
|
||||||
|
usesExternalScrollArea
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
height,
|
||||||
|
overflow: "auto",
|
||||||
|
position: "relative",
|
||||||
|
// Standalone mode: this node IS the scroll container, so it
|
||||||
|
// must stay at its parent's width (not the resizable table
|
||||||
|
// width) or horizontal scrolling becomes impossible.
|
||||||
|
width: "auto",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DataGridTableBase>
|
||||||
|
{headerNode}
|
||||||
|
|
||||||
{renderHeader &&
|
{renderHeader &&
|
||||||
(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
||||||
@@ -943,7 +1216,12 @@ function DataGridTableVirtual<TData extends object>({
|
|||||||
loadingMoreMessage={loadingMoreMessage}
|
loadingMoreMessage={loadingMoreMessage}
|
||||||
allRowsLoadedMessage={allRowsLoadedMessage}
|
allRowsLoadedMessage={allRowsLoadedMessage}
|
||||||
measureRowRef={measureRowRef}
|
measureRowRef={measureRowRef}
|
||||||
|
centerColumnWindow={centerColumnWindow}
|
||||||
/>
|
/>
|
||||||
|
{/* Same appended region as the standard body, so onRowCreate and
|
||||||
|
a consumer draft work in the virtual layout too. */}
|
||||||
|
{props.appendRow}
|
||||||
|
<DataGridTableAddRow />
|
||||||
</DataGridTableBody>
|
</DataGridTableBody>
|
||||||
|
|
||||||
{footerContent && (
|
{footerContent && (
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,19 @@
|
|||||||
import { createContext, useContext, useEffect, useMemo, useRef } from "react"
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useId,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
} from "react"
|
||||||
import type { ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
import {
|
import {
|
||||||
|
mergeDataGridI18n,
|
||||||
|
type DataGridI18nConfig,
|
||||||
|
type DataGridI18nOverrides,
|
||||||
|
} from "@/components/reui/data-grid/data-grid-i18n"
|
||||||
|
import {
|
||||||
|
cellSelectionFeature,
|
||||||
columnFacetingFeature,
|
columnFacetingFeature,
|
||||||
columnFilteringFeature,
|
columnFilteringFeature,
|
||||||
columnOrderingFeature,
|
columnOrderingFeature,
|
||||||
@@ -30,6 +43,7 @@ import {
|
|||||||
tableFeatures,
|
tableFeatures,
|
||||||
} from "@tanstack/react-table"
|
} from "@tanstack/react-table"
|
||||||
import type {
|
import type {
|
||||||
|
Cell,
|
||||||
Column,
|
Column,
|
||||||
ColumnFiltersState,
|
ColumnFiltersState,
|
||||||
ReactTable,
|
ReactTable,
|
||||||
@@ -49,6 +63,39 @@ import { cn } from "@evobgp/ui/lib/utils"
|
|||||||
* installing the data grid no longer widens `ColumnMeta` for every other
|
* installing the data grid no longer widens `ColumnMeta` for every other
|
||||||
* table in the consuming app.
|
* table in the consuming app.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Per-column write contract for the spreadsheet operations (paste, fill,
|
||||||
|
* clear, cut). Presence marks the column writable; every write still flows
|
||||||
|
* through `onCellsChange`, so the consumer's state stays the only data owner.
|
||||||
|
*/
|
||||||
|
export interface DataGridColumnCellEdit<TData> {
|
||||||
|
/**
|
||||||
|
* Set false for a column that formats clipboard output but is never
|
||||||
|
* written; a function decides per row, so locked rows (archived, another
|
||||||
|
* user's, a totals row) reject like any read-only cell. Defaults to true.
|
||||||
|
*/
|
||||||
|
editable?: boolean | ((row: TData) => boolean)
|
||||||
|
/**
|
||||||
|
* Clipboard or fill string to typed value. Return undefined to reject the
|
||||||
|
* cell into `rejected`. Without it the raw string passes through unchanged -
|
||||||
|
* a deliberate non-coercion, so a number column receives strings only when
|
||||||
|
* the consumer chose not to parse.
|
||||||
|
*/
|
||||||
|
parse?: (raw: string, row: TData) => unknown
|
||||||
|
/** Typed value to clipboard string. Fallback: String(value ?? ""). */
|
||||||
|
format?: (value: unknown, row: TData) => string
|
||||||
|
/** Value dispatched by Delete/Backspace and cut. Defaults to null. */
|
||||||
|
clearValue?: unknown
|
||||||
|
/**
|
||||||
|
* Opt into the grid's built-in free-text editor: an overlay input (or
|
||||||
|
* auto-growing textarea) flush over the focused cell, opened by Enter, F2,
|
||||||
|
* typing, or double-click, committing through `onCellsChange` as source
|
||||||
|
* `"edit"` with `parse` applied. Columns without it stay consumer-edited
|
||||||
|
* via `onCellEditRequest` or their own cell renderers.
|
||||||
|
*/
|
||||||
|
control?: "text" | "textarea"
|
||||||
|
}
|
||||||
|
|
||||||
export interface DataGridColumnMeta<TData> {
|
export interface DataGridColumnMeta<TData> {
|
||||||
headerTitle?: string
|
headerTitle?: string
|
||||||
headerClassName?: string
|
headerClassName?: string
|
||||||
@@ -56,6 +103,15 @@ export interface DataGridColumnMeta<TData> {
|
|||||||
skeleton?: ReactNode
|
skeleton?: ReactNode
|
||||||
expandedContent?: (row: TData) => ReactNode
|
expandedContent?: (row: TData) => ReactNode
|
||||||
autoSize?: boolean
|
autoSize?: boolean
|
||||||
|
cellEdit?: DataGridColumnCellEdit<TData>
|
||||||
|
/**
|
||||||
|
* Under `columnsResizable`, this column absorbs the free space the filler
|
||||||
|
* strip would otherwise hold, so the grid always reads full-width and the
|
||||||
|
* built-in editor covers the whole cell. One column per grid; while free
|
||||||
|
* space remains, manual resizing of this column is visually a no-op (the
|
||||||
|
* absorbed space compensates), the flex-column trade-off.
|
||||||
|
*/
|
||||||
|
fillWidth?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -105,6 +161,10 @@ export const dataGridFeatures = tableFeatures({
|
|||||||
rowSelectionFeature,
|
rowSelectionFeature,
|
||||||
rowExpandingFeature,
|
rowExpandingFeature,
|
||||||
rowPinningFeature,
|
rowPinningFeature,
|
||||||
|
// Registration alone is inert: it seeds a `cellSelection: []` slice and
|
||||||
|
// prototype methods but binds no DOM handlers, so grids without
|
||||||
|
// `tableLayout.cellSelection` render and behave exactly as before.
|
||||||
|
cellSelectionFeature,
|
||||||
sortedRowModel: createSortedRowModel(),
|
sortedRowModel: createSortedRowModel(),
|
||||||
filteredRowModel: createFilteredRowModel(),
|
filteredRowModel: createFilteredRowModel(),
|
||||||
paginatedRowModel: createPaginatedRowModel(),
|
paginatedRowModel: createPaginatedRowModel(),
|
||||||
@@ -156,6 +216,131 @@ export function getColumnHeaderLabel<TData extends RowData, TValue>(
|
|||||||
return String(column.id)
|
return String(column.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The td contract for spreadsheet selection. Call inside a
|
||||||
|
* `Subscribe source={table.atoms.cellSelection}` render only: the reads are
|
||||||
|
* builder calls whose state dependency React Compiler cannot see, exactly the
|
||||||
|
* trap the row-selection checkbox documents.
|
||||||
|
*/
|
||||||
|
export function getDataGridCellSelectionCellAttrs<TData extends object>(
|
||||||
|
cell: Cell<DataGridFeatures, TData, unknown>
|
||||||
|
): {
|
||||||
|
"aria-selected": boolean
|
||||||
|
"data-col-id": string
|
||||||
|
"data-cell-selected": true | undefined
|
||||||
|
"data-cell-focused": true | undefined
|
||||||
|
"data-cell-edge-top": true | undefined
|
||||||
|
"data-cell-edge-right": true | undefined
|
||||||
|
"data-cell-edge-bottom": true | undefined
|
||||||
|
"data-cell-edge-left": true | undefined
|
||||||
|
} {
|
||||||
|
const selected = cell.getIsSelected()
|
||||||
|
const edges = cell.getSelectionEdges()
|
||||||
|
return {
|
||||||
|
"aria-selected": selected,
|
||||||
|
"data-col-id": cell.column.id,
|
||||||
|
"data-cell-selected": selected || undefined,
|
||||||
|
"data-cell-focused": cell.getIsFocused() || undefined,
|
||||||
|
"data-cell-edge-top": edges.top || undefined,
|
||||||
|
"data-cell-edge-right": edges.right || undefined,
|
||||||
|
"data-cell-edge-bottom": edges.bottom || undefined,
|
||||||
|
"data-cell-edge-left": edges.left || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selection chrome for a body td, activated by the data attributes above plus
|
||||||
|
* the imperatively toggled `data-cell-fill-target` (fill-drag preview, written
|
||||||
|
* outside React like the resize indicator). A ::before overlay, not outline
|
||||||
|
* or box-shadow: an outline ring hugs the cell box and cannot line up with
|
||||||
|
* the gridlines the perimeter paints on, and the pinned-column dividers
|
||||||
|
* already own the cell's shadow slot (tailwind-merge would collapse a second
|
||||||
|
* shadow-[...] into it).
|
||||||
|
*/
|
||||||
|
export const dataGridCellSelectionCellClasses = cn(
|
||||||
|
"relative select-none",
|
||||||
|
// A light tint: the selection must read as a range without drowning the
|
||||||
|
// gridlines under a heavy fill. The focused cell stays unfilled. Pinned
|
||||||
|
// cells must stay OPAQUE (they hide scrolled content), so they get the
|
||||||
|
// tint pre-mixed over the background, and the focused pinned cell keeps
|
||||||
|
// a solid background instead of turning transparent.
|
||||||
|
"data-[cell-selected]:bg-primary/4",
|
||||||
|
"data-[cell-selected]:data-pinned:bg-[color-mix(in_oklab,var(--primary)_4%,var(--background))]",
|
||||||
|
"data-[cell-focused]:bg-transparent!",
|
||||||
|
"data-[cell-focused]:data-pinned:bg-background!",
|
||||||
|
// Every selection line is one layout-free ::before overlay per cell. It
|
||||||
|
// reaches 1px BEYOND the cell so each line paints exactly ON the shared
|
||||||
|
// gridline it replaces: the range perimeter and the interior dividers
|
||||||
|
// share this geometry, so where they meet they coincide instead of
|
||||||
|
// stacking into a wider edge, every side stays exactly 1px, and
|
||||||
|
// selecting never adds real borders that shift layout. At the table's
|
||||||
|
// own boundary there is no shared gridline and the overshoot leaves the
|
||||||
|
// scrollable content box, where it is CLIPPED - the perimeter simply
|
||||||
|
// vanished on the first column, last column and last row. So every
|
||||||
|
// overshoot that can hit a boundary goes through a variable the
|
||||||
|
// boundary cells zero out. Longhand inset utilities only, logical
|
||||||
|
// start/end
|
||||||
|
// so RTL mirrors for free; a separate override rule cannot do this,
|
||||||
|
// equal-specificity variants leave shorthand-vs-longhand to sort order.
|
||||||
|
"data-[cell-selected]:before:pointer-events-none data-[cell-selected]:before:absolute data-[cell-selected]:before:top-[var(--data-grid-overlay-top,-1px)] data-[cell-selected]:before:start-[var(--data-grid-overlay-start,-1px)] data-[cell-selected]:before:end-[var(--data-grid-overlay-end,-1px)] data-[cell-selected]:before:bottom-[var(--data-grid-overlay-bottom,-1px)] data-[cell-selected]:before:border-primary data-[cell-selected]:before:content-['']",
|
||||||
|
"data-[cell-focused]:before:pointer-events-none data-[cell-focused]:before:absolute data-[cell-focused]:before:top-[var(--data-grid-overlay-top,-1px)] data-[cell-focused]:before:start-[var(--data-grid-overlay-start,-1px)] data-[cell-focused]:before:end-[var(--data-grid-overlay-end,-1px)] data-[cell-focused]:before:bottom-[var(--data-grid-overlay-bottom,-1px)] data-[cell-focused]:before:border-primary data-[cell-focused]:before:content-['']",
|
||||||
|
// Interior dividers: selected cells repaint their own end and bottom
|
||||||
|
// gridlines on the overlay, so gridline-less grids still divide a range.
|
||||||
|
// The gray color is guarded by not- variants (higher specificity), so a
|
||||||
|
// side that belongs to the range perimeter deterministically stays
|
||||||
|
// primary.
|
||||||
|
"data-[cell-selected]:before:border-e data-[cell-selected]:before:border-b",
|
||||||
|
"data-[cell-selected]:not-data-[cell-edge-right]:before:border-e-border",
|
||||||
|
"data-[cell-selected]:not-data-[cell-edge-bottom]:before:border-b-border",
|
||||||
|
// The Sheets model: a single selected cell IS its own range, so the four
|
||||||
|
// edge attributes below already draw its full primary box; inside a
|
||||||
|
// larger range the anchor reads by its unfilled background alone. The
|
||||||
|
// explicit ring only covers a focused cell with no selection at all.
|
||||||
|
// Edge sides are logical (border-e/border-s): the feature derives edges
|
||||||
|
// from display-order column indexes, which mirror in RTL.
|
||||||
|
"data-[cell-focused]:not-data-[cell-selected]:before:border",
|
||||||
|
"data-[cell-edge-top]:before:border-t data-[cell-edge-right]:before:border-e data-[cell-edge-bottom]:before:border-b data-[cell-edge-left]:before:border-s",
|
||||||
|
// While a fill drag is live the preview border is the ONE painter for
|
||||||
|
// the whole pending region, so the source cells' own chrome rests -
|
||||||
|
// otherwise its lines double against the region border at every shared
|
||||||
|
// edge. display is the no-cascade-fight switch (see below).
|
||||||
|
"in-data-[cell-filling]:data-[cell-selected]:before:hidden",
|
||||||
|
"in-data-[cell-filling]:data-[cell-focused]:before:hidden",
|
||||||
|
// While the built-in editor is open its outline is the one border; the
|
||||||
|
// edited cell's own overlay chrome hides so nothing peeks around the
|
||||||
|
// flush editor. display is the switch because no other before: rule
|
||||||
|
// sets it, so there is no cascade order to lose.
|
||||||
|
"in-data-[cell-editing]:data-[cell-focused]:before:hidden",
|
||||||
|
// Clamped to the cell edge at the grid's own boundary, the overlay's
|
||||||
|
// lines sit flush against the container border instead of being clipped
|
||||||
|
// away outside the scrollable content. A custom property has no cascade
|
||||||
|
// fight to lose. First/last-child cover the boundary columns: when a
|
||||||
|
// filler td follows the last data column, that cell's overshoot lands
|
||||||
|
// INSIDE the table and needs no clamp, and the td:first/last-child of a
|
||||||
|
// row are its logical start/end in RTL too.
|
||||||
|
"in-[tr:last-child]:[--data-grid-overlay-bottom:0px]",
|
||||||
|
// The top overshoot lands under a sticky header, which is positioned and
|
||||||
|
// paints over it - so the first row's perimeter lost its top line. Clamp
|
||||||
|
// inside the cell there, exactly like the other three boundaries.
|
||||||
|
"in-[tr:first-child]:[--data-grid-overlay-top:0px]",
|
||||||
|
"first:[--data-grid-overlay-start:0px]",
|
||||||
|
"last:[--data-grid-overlay-end:0px]",
|
||||||
|
// A pinned column is a boundary like the grid's own edges: the sticky
|
||||||
|
// neighbor would cover an on-gridline overlay line, and re-painting it
|
||||||
|
// from the pinned side doubles into a 2px blur whenever subpixel
|
||||||
|
// positions disagree (browser zoom, fractional fill widths). One
|
||||||
|
// painter instead: the overlay clamps inside its own cell there, the
|
||||||
|
// same custom-property clamp the first and last cells use.
|
||||||
|
"[&:has(+td[data-pinned])]:[--data-grid-overlay-end:0px]",
|
||||||
|
"[&:has(+[data-slot=data-grid-table-fill-body-cell]:last-child)]:[--data-grid-overlay-end:0px]",
|
||||||
|
"[&:has(+[data-slot=data-grid-table-fill-body-cell]+td[data-pinned])]:[--data-grid-overlay-end:0px]",
|
||||||
|
"[td[data-pinned]+&]:[--data-grid-overlay-start:0px]",
|
||||||
|
// Tint only: the dashed region outline is one overlay element drawn by
|
||||||
|
// the fill session, so neighboring target cells never double their edges.
|
||||||
|
"data-[cell-fill-target]:bg-primary/4",
|
||||||
|
"data-[cell-fill-target]:data-pinned:bg-[color-mix(in_oklab,var(--primary)_4%,var(--background))]"
|
||||||
|
)
|
||||||
|
|
||||||
export type DataGridApiFetchParams = {
|
export type DataGridApiFetchParams = {
|
||||||
pageIndex: number
|
pageIndex: number
|
||||||
pageSize: number
|
pageSize: number
|
||||||
@@ -188,6 +373,10 @@ export interface DataGridContextProps<TData extends object> {
|
|||||||
table: DataGridTableInstance<TData>
|
table: DataGridTableInstance<TData>
|
||||||
recordCount: number
|
recordCount: number
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
|
/** Stable per-grid prefix for the DOM ids ARIA relations point at. */
|
||||||
|
gridId: string
|
||||||
|
/** The merged label set; components read `i18n.labels.<key>`. */
|
||||||
|
i18n: DataGridI18nConfig
|
||||||
/**
|
/**
|
||||||
* Internal coordinator for `meta.autoSize` columns. Lives at the core level
|
* Internal coordinator for `meta.autoSize` columns. Lives at the core level
|
||||||
* so every table variant and viewport instance shares one application state.
|
* so every table variant and viewport instance shares one application state.
|
||||||
@@ -195,13 +384,66 @@ export interface DataGridContextProps<TData extends object> {
|
|||||||
autoSize?: DataGridAutoSizeController
|
autoSize?: DataGridAutoSizeController
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Row and column ids are consumer strings; DOM ids cannot carry spaces or
|
||||||
|
* quotes. One shared sanitizer so `aria-activedescendant` writers and the id
|
||||||
|
* renderers always agree on the same encoding.
|
||||||
|
*/
|
||||||
|
export function toDataGridDomId(value: string): string {
|
||||||
|
return value.replace(/[^\w-]/g, "_")
|
||||||
|
}
|
||||||
|
|
||||||
export type DataGridAutoSizeController = {
|
export type DataGridAutoSizeController = {
|
||||||
/**
|
/**
|
||||||
* Grows the first visible `meta.autoSize` column by the given free space.
|
* Feeds the controller one viewport measurement: the SIGNED free space
|
||||||
* Applies at most once per column id; safe to call from every viewport
|
* between the scroll container and the table, negative while the table
|
||||||
* measurement. Returns true when a sizing update was dispatched.
|
* overflows. The first sight of a `meta.autoSize` column absorbs the
|
||||||
|
* free space into its width; after that, every measurement REFLOWS the
|
||||||
|
* same growth - a wider window widens the column, a narrower one gives
|
||||||
|
* the space back down to the column's `minSize` (or its starting width
|
||||||
|
* when none is set) - so the grid tracks its container live. Commits
|
||||||
|
* are coalesced for performance: sub-pixel deltas are dropped, a
|
||||||
|
* streaming resize gets one leading commit plus one trailing commit at
|
||||||
|
* settle, and a width the user dragged is never touched. Safe to call
|
||||||
|
* from every viewport measurement; returns true when a sizing update
|
||||||
|
* was dispatched.
|
||||||
*/
|
*/
|
||||||
apply: (fillWidth: number) => boolean
|
apply: (freeSpace: number) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type DataGridAutoSizeReflowState = {
|
||||||
|
applied: { columnId: string; base: number; grown: number } | null
|
||||||
|
settleTimer: ReturnType<typeof setTimeout> | null
|
||||||
|
pendingFreeSpace: number
|
||||||
|
lastCommitAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bookkeeping keyed on the TABLE STORE, never on a React instance: dev
|
||||||
|
* StrictMode double-mounts the tree, so two controller instances serve one
|
||||||
|
* page and only the first would remember what it absorbed - the second
|
||||||
|
* reads the grown width as a user drag and freezes the column. The store
|
||||||
|
* object is the one identity that provably survives that dance (the
|
||||||
|
* absorbed sizing rides inside it), and the WeakMap lets a discarded
|
||||||
|
* table take its bookkeeping with it.
|
||||||
|
*/
|
||||||
|
const dataGridAutoSizeStates = new WeakMap<
|
||||||
|
object,
|
||||||
|
DataGridAutoSizeReflowState
|
||||||
|
>()
|
||||||
|
|
||||||
|
function getDataGridAutoSizeState(store: object): DataGridAutoSizeReflowState {
|
||||||
|
let state = dataGridAutoSizeStates.get(store)
|
||||||
|
if (!state) {
|
||||||
|
state = {
|
||||||
|
applied: null,
|
||||||
|
settleTimer: null,
|
||||||
|
pendingFreeSpace: 0,
|
||||||
|
lastCommitAt: 0,
|
||||||
|
}
|
||||||
|
dataGridAutoSizeStates.set(store, state)
|
||||||
|
}
|
||||||
|
return state
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDataGridAutoSizeController<TData extends object>(
|
function createDataGridAutoSizeController<TData extends object>(
|
||||||
@@ -217,29 +459,95 @@ function createDataGridAutoSizeController<TData extends object>(
|
|||||||
*/
|
*/
|
||||||
getTable: () => DataGridTableInstance<TData>
|
getTable: () => DataGridTableInstance<TData>
|
||||||
): DataGridAutoSizeController {
|
): DataGridAutoSizeController {
|
||||||
let applied: { columnId: string; base: number; grown: number } | null = null
|
// Reflow coalescing: a live window drag streams a measurement per frame,
|
||||||
|
// and every accepted commit re-renders the table. One leading commit
|
||||||
|
// keeps single snaps (a maximize) instant; the rest of the stream parks
|
||||||
|
// its latest value behind one trailing timer, so a continuous drag costs
|
||||||
|
// two commits instead of sixty a second. The trailing timer may fire
|
||||||
|
// after the grid unmounts; it re-reads the table then, and a sizing
|
||||||
|
// write to the consumer's store is inert once nothing renders it.
|
||||||
|
const SETTLE_MS = 150
|
||||||
|
|
||||||
|
const reflow = (freeSpace: number): boolean => {
|
||||||
|
const table = getTable()
|
||||||
|
const state = getDataGridAutoSizeState(table.store)
|
||||||
|
// LIVE state, never the render snapshot: a measurement can run between
|
||||||
|
// our own sizing commit and React's re-render, and the stale snapshot
|
||||||
|
// would hide the width this controller just wrote.
|
||||||
|
const columnSizing =
|
||||||
|
table.atoms.columnSizing?.get() ?? table.state.columnSizing
|
||||||
|
if (!state.applied) return false
|
||||||
|
const autoSizeColumn = table
|
||||||
|
.getVisibleLeafColumns()
|
||||||
|
.find(
|
||||||
|
(column) => column.columnDef.meta?.autoSize && column.getCanResize()
|
||||||
|
)
|
||||||
|
if (!autoSizeColumn || autoSizeColumn.id !== state.applied.columnId)
|
||||||
|
return false
|
||||||
|
// A width this coordinator did not write belongs to the user's drag;
|
||||||
|
// reflow stands down until a reset re-arms the column.
|
||||||
|
const currentSize = columnSizing[state.applied.columnId]
|
||||||
|
if (currentSize !== undefined && currentSize !== state.applied.grown) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// The free space as it would measure with our growth removed; the
|
||||||
|
// target re-absorbs exactly that, floored at minSize so a narrow
|
||||||
|
// window hands space back without ever crushing the column (no
|
||||||
|
// explicit minSize floors at the starting width instead).
|
||||||
|
const freeAtBase = freeSpace + (state.applied.grown - state.applied.base)
|
||||||
|
const floor = autoSizeColumn.columnDef.minSize ?? state.applied.base
|
||||||
|
const target = Math.max(floor, state.applied.base + freeAtBase)
|
||||||
|
if (Math.abs(target - state.applied.grown) < 1) return false
|
||||||
|
state.applied = { ...state.applied, grown: target }
|
||||||
|
table.setColumnSizing((old) => ({
|
||||||
|
...old,
|
||||||
|
[state.applied!.columnId]: target,
|
||||||
|
}))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
apply(fillWidth: number) {
|
apply(freeSpace: number) {
|
||||||
const table = getTable()
|
const table = getTable()
|
||||||
const columnSizing = table.state.columnSizing
|
const state = getDataGridAutoSizeState(table.store)
|
||||||
|
// Same live read as `reflow`; the snapshot race is what made the
|
||||||
|
// re-arm check clear the bookkeeping right after the first absorb.
|
||||||
|
const columnSizing =
|
||||||
|
table.atoms.columnSizing?.get() ?? table.state.columnSizing
|
||||||
|
|
||||||
// Re-arm after reset flows (double-click resetSize, resetColumnSizing,
|
// Re-arm after reset flows (double-click resetSize, resetColumnSizing,
|
||||||
// controlled state replacement) so the column re-fills instead of
|
// controlled state replacement) so the column re-fills instead of
|
||||||
// leaving a dead blank strip.
|
// leaving a dead blank strip.
|
||||||
if (applied && columnSizing[applied.columnId] === undefined) {
|
if (state.applied && columnSizing[state.applied.columnId] === undefined) {
|
||||||
applied = null
|
state.applied = null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fillWidth <= 0) return false
|
|
||||||
|
|
||||||
const autoSizeColumn = table
|
const autoSizeColumn = table
|
||||||
.getVisibleLeafColumns()
|
.getVisibleLeafColumns()
|
||||||
.find(
|
.find(
|
||||||
(column) => column.columnDef.meta?.autoSize && column.getCanResize()
|
(column) => column.columnDef.meta?.autoSize && column.getCanResize()
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!autoSizeColumn || applied?.columnId === autoSizeColumn.id) {
|
if (autoSizeColumn && state.applied?.columnId === autoSizeColumn.id) {
|
||||||
|
state.pendingFreeSpace = freeSpace
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - state.lastCommitAt > SETTLE_MS) {
|
||||||
|
state.lastCommitAt = now
|
||||||
|
return reflow(freeSpace)
|
||||||
|
}
|
||||||
|
if (state.settleTimer !== null) clearTimeout(state.settleTimer)
|
||||||
|
state.settleTimer = setTimeout(() => {
|
||||||
|
state.settleTimer = null
|
||||||
|
state.lastCommitAt = Date.now()
|
||||||
|
reflow(state.pendingFreeSpace)
|
||||||
|
}, SETTLE_MS)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const fillWidth = Math.max(0, freeSpace)
|
||||||
|
if (fillWidth <= 0) return false
|
||||||
|
|
||||||
|
if (!autoSizeColumn) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,13 +557,13 @@ function createDataGridAutoSizeController<TData extends object>(
|
|||||||
// un-resizable: the drag committed, the next viewport measurement
|
// un-resizable: the drag committed, the next viewport measurement
|
||||||
// stamped the fill back on top, and the column snapped to its old width.
|
// stamped the fill back on top, and the column snapped to its old width.
|
||||||
//
|
//
|
||||||
// Deliberately keyed on observed state rather than on `applied`, which
|
// Deliberately keyed on observed state rather than on `state.applied`, which
|
||||||
// is per-coordinator memory: anything that rebuilds the coordinator
|
// is per-coordinator memory: anything that rebuilds the coordinator
|
||||||
// (a remount, a new table store) forgets what it did, and the guard has
|
// (a remount, a new table store) forgets what it did, and the guard has
|
||||||
// to survive that. An explicit reset clears the entry and re-arms the
|
// to survive that. An explicit reset clears the entry and re-arms the
|
||||||
// fill, which is what makes double-click-to-reset still work.
|
// fill, which is what makes double-click-to-reset still work.
|
||||||
const currentSize = columnSizing[autoSizeColumn.id]
|
const currentSize = columnSizing[autoSizeColumn.id]
|
||||||
if (currentSize !== undefined && currentSize !== applied?.grown) {
|
if (currentSize !== undefined && currentSize !== state.applied?.grown) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,13 +572,14 @@ function createDataGridAutoSizeController<TData extends object>(
|
|||||||
// user hasn't manually resized that column since, so visibility
|
// user hasn't manually resized that column since, so visibility
|
||||||
// toggles cannot ratchet the table wider than its container forever.
|
// toggles cannot ratchet the table wider than its container forever.
|
||||||
const revert =
|
const revert =
|
||||||
applied && columnSizing[applied.columnId] === applied.grown
|
state.applied &&
|
||||||
? applied
|
columnSizing[state.applied.columnId] === state.applied.grown
|
||||||
|
? state.applied
|
||||||
: null
|
: null
|
||||||
const base = columnSizing[autoSizeColumn.id] ?? autoSizeColumn.getSize()
|
const base = columnSizing[autoSizeColumn.id] ?? autoSizeColumn.getSize()
|
||||||
const grown = base + fillWidth
|
const grown = base + fillWidth
|
||||||
|
|
||||||
applied = { columnId: autoSizeColumn.id, base, grown }
|
state.applied = { columnId: autoSizeColumn.id, base, grown }
|
||||||
table.setColumnSizing((old) => {
|
table.setColumnSizing((old) => {
|
||||||
const next = { ...old, [autoSizeColumn.id]: grown }
|
const next = { ...old, [autoSizeColumn.id]: grown }
|
||||||
if (revert && next[revert.columnId] === revert.grown) {
|
if (revert && next[revert.columnId] === revert.grown) {
|
||||||
@@ -291,6 +600,104 @@ export type DataGridRequestParams = {
|
|||||||
columnFilters?: ColumnFiltersState
|
columnFilters?: ColumnFiltersState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Which spreadsheet operation produced a cell change batch. */
|
||||||
|
export type DataGridCellsChangeSource =
|
||||||
|
| "paste"
|
||||||
|
| "cut"
|
||||||
|
| "clear"
|
||||||
|
| "fill"
|
||||||
|
| "edit"
|
||||||
|
|
||||||
|
export interface DataGridCellChange<TData> {
|
||||||
|
rowId: string
|
||||||
|
columnId: string
|
||||||
|
/** The consumer's row object, so immutable write-back needs no second lookup. */
|
||||||
|
row: TData
|
||||||
|
/** Accessor value at dispatch time; enables consumer-side undo stacks. */
|
||||||
|
previousValue: unknown
|
||||||
|
/** Typed value produced by `meta.cellEdit.parse`, or `clearValue` for clear/cut. */
|
||||||
|
value: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What onCellsCopy receives after the grid writes the clipboard. */
|
||||||
|
export interface DataGridCopyDetails {
|
||||||
|
/** The TSV text that reached the clipboard. */
|
||||||
|
text: string
|
||||||
|
/** The same content as a row-major grid of formatted fields. */
|
||||||
|
grid: string[][]
|
||||||
|
/** True when the write came from a cut, which clears the region next. */
|
||||||
|
cut: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One resolved positive region, in pre-paginated display indexes. */
|
||||||
|
export interface DataGridCellSelectionBound {
|
||||||
|
minRowIndex: number
|
||||||
|
maxRowIndex: number
|
||||||
|
minColumnIndex: number
|
||||||
|
maxColumnIndex: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What onCellSelectionChange receives, resolved from the live selection. */
|
||||||
|
export interface DataGridCellSelectionSnapshot {
|
||||||
|
/** The virtually focused cell, the active range's anchor. Null when clear. */
|
||||||
|
focused: { rowId: string; columnId: string } | null
|
||||||
|
/** Every positive region, geometric order. */
|
||||||
|
bounds: DataGridCellSelectionBound[]
|
||||||
|
/** The most recent region, the one copy and paste act on. */
|
||||||
|
activeBound: DataGridCellSelectionBound | null
|
||||||
|
/**
|
||||||
|
* Cells of the selection the view can show - the count that matches the
|
||||||
|
* painted range and what a batch will touch.
|
||||||
|
*/
|
||||||
|
visibleCellCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataGridCellRejection {
|
||||||
|
rowId: string
|
||||||
|
columnId: string
|
||||||
|
raw: string
|
||||||
|
reason: "readonly" | "invalid"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifecycle of a row for the optional CRUD indications: "new" and "dirty"
|
||||||
|
* tint the row, "deleted" mutes and strikes it. Purely presentational - the
|
||||||
|
* consumer's state stays the only owner of what the statuses mean.
|
||||||
|
*/
|
||||||
|
export type DataGridRowStatus = "new" | "dirty" | "deleted"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifecycle of a cell for the optional CRUD indications: a corner mark in
|
||||||
|
* the amber tone for "dirty", the destructive tone for "invalid".
|
||||||
|
*/
|
||||||
|
export type DataGridCellStatus = "dirty" | "invalid"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks the consumer to open its editor for the focused cell: Enter or F2, or
|
||||||
|
* a typed printable character (then `initialText` carries it, the
|
||||||
|
* Notion/Airtable type-to-replace flow). Dispatched only for cells whose
|
||||||
|
* column is writable via `meta.cellEdit`.
|
||||||
|
*/
|
||||||
|
export interface DataGridCellEditRequest<TData> {
|
||||||
|
rowId: string
|
||||||
|
columnId: string
|
||||||
|
row: TData
|
||||||
|
previousValue: unknown
|
||||||
|
/** The typed character that started the edit; absent for Enter and F2. */
|
||||||
|
initialText?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One batch per operation, however many cells it spans: a 10k-cell paste is
|
||||||
|
* one callback, one consumer setState, one table rebuild. The grid never
|
||||||
|
* writes data itself.
|
||||||
|
*/
|
||||||
|
export interface DataGridCellsChangeDetails<TData> {
|
||||||
|
source: DataGridCellsChangeSource
|
||||||
|
changes: DataGridCellChange<TData>[]
|
||||||
|
rejected: DataGridCellRejection[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface DataGridProps<
|
export interface DataGridProps<
|
||||||
TFeatures extends TableFeatures,
|
TFeatures extends TableFeatures,
|
||||||
TData extends object,
|
TData extends object,
|
||||||
@@ -300,7 +707,62 @@ export interface DataGridProps<
|
|||||||
recordCount: number
|
recordCount: number
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
onRowClick?: (row: TData) => void
|
onRowClick?: (row: TData) => void
|
||||||
|
/**
|
||||||
|
* Receives every spreadsheet write batch (paste, cut, clear, fill, edit).
|
||||||
|
* Served through the props getter like `onRowClick`, so an inline identity
|
||||||
|
* never republishes the context. Absent: copy still works, writes no-op.
|
||||||
|
*/
|
||||||
|
onCellsChange?: (details: DataGridCellsChangeDetails<TData>) => void
|
||||||
|
/**
|
||||||
|
* Opens the consumer's editor from the keyboard: Enter, F2, or typing on
|
||||||
|
* the focused cell. Absent: Enter keeps moving down, typing does nothing.
|
||||||
|
*/
|
||||||
|
onCellEditRequest?: (request: DataGridCellEditRequest<TData>) => void
|
||||||
|
/**
|
||||||
|
* Fires on every cell-selection change (focus moves, ranges grow or
|
||||||
|
* clear) with a resolved snapshot, so a selection count, formula bar or
|
||||||
|
* context panel needs no reach into TanStack internals.
|
||||||
|
*/
|
||||||
|
onCellSelectionChange?: (snapshot: DataGridCellSelectionSnapshot) => void
|
||||||
|
/**
|
||||||
|
* Fires after the grid writes the clipboard (Cmd/Ctrl+C or X, or the
|
||||||
|
* native copy and cut events), e.g. to confirm with a toast.
|
||||||
|
*/
|
||||||
|
onCellsCopy?: (details: DataGridCopyDetails) => void
|
||||||
|
/**
|
||||||
|
* Renders an "Add row" affordance as the table body's last row; clicking
|
||||||
|
* it is the consumer's cue to start creating a row. Absent: no row.
|
||||||
|
*/
|
||||||
|
onRowCreate?: () => void
|
||||||
|
/** Label of the onRowCreate affordance. Defaults to "Add row". */
|
||||||
|
rowCreateLabel?: ReactNode
|
||||||
|
/**
|
||||||
|
* Rendered inside the table body after the data rows: the slot a DRAFT
|
||||||
|
* row lives in while it is being created, before it joins `data` and the
|
||||||
|
* pagination math. Supply a `<tr>` of cells; the colgroup sizes them.
|
||||||
|
*/
|
||||||
|
appendRow?: ReactNode
|
||||||
|
/**
|
||||||
|
* Optional CRUD indication per row. Return undefined for no indication;
|
||||||
|
* omit the prop entirely to render none anywhere.
|
||||||
|
*/
|
||||||
|
getRowStatus?: (row: TData) => DataGridRowStatus | undefined
|
||||||
|
/**
|
||||||
|
* Optional CRUD indication per cell: the classic corner mark on cells the
|
||||||
|
* consumer tracks as edited or invalid.
|
||||||
|
*/
|
||||||
|
getCellStatus?: (
|
||||||
|
row: TData,
|
||||||
|
columnId: string
|
||||||
|
) => DataGridCellStatus | undefined
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
|
/**
|
||||||
|
* Overrides for every built-in string: menu items, aria labels,
|
||||||
|
* pagination copy, state messages. Merged over the defaults; a more
|
||||||
|
* specific component prop (`rowCreateLabel`, `loadingMessage`, the
|
||||||
|
* pagination label props) still wins over its `i18n` counterpart.
|
||||||
|
*/
|
||||||
|
i18n?: DataGridI18nOverrides
|
||||||
loadingMode?: "skeleton" | "spinner"
|
loadingMode?: "skeleton" | "spinner"
|
||||||
loadingMessage?: ReactNode | string
|
loadingMessage?: ReactNode | string
|
||||||
fetchingMoreMessage?: ReactNode | string
|
fetchingMoreMessage?: ReactNode | string
|
||||||
@@ -325,6 +787,36 @@ export interface DataGridProps<
|
|||||||
columnsDraggable?: boolean
|
columnsDraggable?: boolean
|
||||||
rowsDraggable?: boolean
|
rowsDraggable?: boolean
|
||||||
rowsPinnable?: boolean
|
rowsPinnable?: boolean
|
||||||
|
/** Spreadsheet cell range selection: mouse ranges, keyboard, clipboard chrome. */
|
||||||
|
cellSelection?: boolean
|
||||||
|
/**
|
||||||
|
* "range" (default): drag, Shift and Ctrl/Cmd gestures and Shift+keys
|
||||||
|
* grow multi-cell regions. "single": one focused cell only - every
|
||||||
|
* gesture collapses to it and Ctrl/Cmd+A is inert.
|
||||||
|
*/
|
||||||
|
cellSelectionMode?: "single" | "range"
|
||||||
|
/** Drag-to-fill handle on the selection corner. Requires cellSelection. */
|
||||||
|
cellFillHandle?: boolean
|
||||||
|
/**
|
||||||
|
* How a cell opens its editor from the mouse: "dblclick" (default,
|
||||||
|
* the spreadsheet standard - first click focuses, second edits) or
|
||||||
|
* "click", opening the editor as soon as a plain click lands on the
|
||||||
|
* focused cell; modifier clicks and drags still select.
|
||||||
|
*/
|
||||||
|
cellEditMode?: "dblclick" | "click"
|
||||||
|
/**
|
||||||
|
* When true, committing an edit with Enter moves focus down (Shift+Enter
|
||||||
|
* up), the Sheets and Excel convention. Off by default: the commit keeps
|
||||||
|
* focus on the edited cell. Tab always commits and moves across.
|
||||||
|
*/
|
||||||
|
cellEditEnterAdvance?: boolean
|
||||||
|
/**
|
||||||
|
* The handle's look: "dot" is a solid primary dot kept inside the
|
||||||
|
* cell corner; "ring" is the Sheets-style hollow circle riding the
|
||||||
|
* corner point, clamped at the grid's own boundary; "square" is the
|
||||||
|
* Excel-style solid square inside the corner. Default "dot".
|
||||||
|
*/
|
||||||
|
cellFillHandleVariant?: "dot" | "ring" | "square"
|
||||||
}
|
}
|
||||||
tableClassNames?: {
|
tableClassNames?: {
|
||||||
base?: string
|
base?: string
|
||||||
@@ -335,6 +827,20 @@ export interface DataGridProps<
|
|||||||
bodyRow?: string
|
bodyRow?: string
|
||||||
footer?: string
|
footer?: string
|
||||||
edgeCell?: string
|
edgeCell?: string
|
||||||
|
/** Override the getRowStatus tints; defaults are the warning-muted set. */
|
||||||
|
rowNew?: string
|
||||||
|
rowDirty?: string
|
||||||
|
rowDeleted?: string
|
||||||
|
/**
|
||||||
|
* Override the pinned-row chrome (muted tint, boundary shadow). A grid
|
||||||
|
* that pins a draft row for quick-create wants it to read as an active
|
||||||
|
* row, not a muted one.
|
||||||
|
*/
|
||||||
|
rowPinned?: string
|
||||||
|
/** The onRowCreate affordance row, e.g. to match the grid's row height. */
|
||||||
|
rowCreate?: string
|
||||||
|
/** The fill handle, e.g. to restyle or resize it beyond the built-in variants. */
|
||||||
|
cellFillHandle?: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,11 +878,16 @@ function DataGridProvider<TData extends object>({
|
|||||||
}) {
|
}) {
|
||||||
// Latest-props ref: context reads always resolve fresh props through the
|
// Latest-props ref: context reads always resolve fresh props through the
|
||||||
// getter below without the memoized context value depending on unstable
|
// getter below without the memoized context value depending on unstable
|
||||||
|
// Stable for the grid's lifetime; ARIA ids and relations hang off it.
|
||||||
|
const gridId = useId()
|
||||||
// ReactNode/function prop identities (inline emptyMessage/onRowClick would
|
// ReactNode/function prop identities (inline emptyMessage/onRowClick would
|
||||||
// otherwise publish a new context value on every consumer render - at
|
// otherwise publish a new context value on every consumer render - at
|
||||||
// mousemove rate during a resize drag, piercing the body-rows memo).
|
// mousemove rate during a resize drag, piercing the body-rows memo).
|
||||||
const propsRef = useRef(props)
|
const propsRef = useRef(props)
|
||||||
propsRef.current = props
|
propsRef.current = props
|
||||||
|
const i18n = mergeDataGridI18n(props.i18n)
|
||||||
|
const i18nRef = useRef(i18n)
|
||||||
|
i18nRef.current = i18n
|
||||||
|
|
||||||
// Same treatment for the table itself, which v9 - unlike v8 - re-creates on
|
// Same treatment for the table itself, which v9 - unlike v8 - re-creates on
|
||||||
// every state change. Depending on it directly would republish the context
|
// every state change. Depending on it directly would republish the context
|
||||||
@@ -401,6 +912,32 @@ function DataGridProvider<TData extends object>({
|
|||||||
table.setOptions((old) => ({ ...old, columnResizeMode: resizeMode }))
|
table.setOptions((old) => ({ ...old, columnResizeMode: resizeMode }))
|
||||||
}, [table, resizeMode])
|
}, [table, resizeMode])
|
||||||
|
|
||||||
|
// With the selection UI on, every paste/fill/clear batch replaces `data`,
|
||||||
|
// and the feature's default autoResetCellSelection would wipe the selection
|
||||||
|
// after each write. Ranges are row-id based and unresolvable ids drop out
|
||||||
|
// of the bounds safely, so keeping state across data edits is the correct
|
||||||
|
// default here.
|
||||||
|
const cellSelectionOn = !!props.tableLayout?.cellSelection
|
||||||
|
useEffect(() => {
|
||||||
|
if (!cellSelectionOn || table.atoms.cellSelection == null) return
|
||||||
|
if (table.options.autoResetCellSelection === false) return
|
||||||
|
table.setOptions((old) => ({ ...old, autoResetCellSelection: false }))
|
||||||
|
}, [table, cellSelectionOn])
|
||||||
|
|
||||||
|
// Rendered row indexes re-base per page, so a range kept across a page flip
|
||||||
|
// re-lights the same positions over different rows. A page change clears
|
||||||
|
// the selection instead - the rule Excel-family grids apply to paginated
|
||||||
|
// range selection.
|
||||||
|
const pageKey = `${table.state.pagination?.pageIndex}:${table.state.pagination?.pageSize}`
|
||||||
|
const previousPageKeyRef = useRef(pageKey)
|
||||||
|
useEffect(() => {
|
||||||
|
if (previousPageKeyRef.current === pageKey) return
|
||||||
|
previousPageKeyRef.current = pageKey
|
||||||
|
if (!cellSelectionOn || table.atoms.cellSelection == null) return
|
||||||
|
tableRef.current.resetCellSelection(true)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [pageKey, cellSelectionOn])
|
||||||
|
|
||||||
// One autoSize coordinator per table instance so split header/body viewports
|
// One autoSize coordinator per table instance so split header/body viewports
|
||||||
// cannot apply the growth twice. Keyed on `table.store`, which v9 keeps
|
// cannot apply the growth twice. Keyed on `table.store`, which v9 keeps
|
||||||
// stable for the life of the table, rather than on `table` itself: the
|
// stable for the life of the table, rather than on `table` itself: the
|
||||||
@@ -419,6 +956,9 @@ function DataGridProvider<TData extends object>({
|
|||||||
// ReactNode/function props (messages, onRowClick) are also excluded: they
|
// ReactNode/function props (messages, onRowClick) are also excluded: they
|
||||||
// are served fresh through the props getter, so unstable inline identities
|
// are served fresh through the props getter, so unstable inline identities
|
||||||
// cannot invalidate the context value.
|
// cannot invalidate the context value.
|
||||||
|
// `cellSelection` state is excluded on purpose too: a drag writes it once
|
||||||
|
// per cell crossed, and nothing reads it through context - cells and the
|
||||||
|
// selection bar subscribe to `table.atoms.cellSelection` directly.
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
get props() {
|
get props() {
|
||||||
@@ -427,8 +967,12 @@ function DataGridProvider<TData extends object>({
|
|||||||
get table() {
|
get table() {
|
||||||
return tableRef.current
|
return tableRef.current
|
||||||
},
|
},
|
||||||
|
get i18n() {
|
||||||
|
return i18nRef.current
|
||||||
|
},
|
||||||
recordCount: props.recordCount,
|
recordCount: props.recordCount,
|
||||||
isLoading: props.isLoading || false,
|
isLoading: props.isLoading || false,
|
||||||
|
gridId,
|
||||||
autoSize,
|
autoSize,
|
||||||
}),
|
}),
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -495,6 +1039,8 @@ function DataGrid<TFeatures extends TableFeatures, TData extends object>({
|
|||||||
columnsDraggable: false,
|
columnsDraggable: false,
|
||||||
rowsDraggable: false,
|
rowsDraggable: false,
|
||||||
rowsPinnable: false,
|
rowsPinnable: false,
|
||||||
|
cellSelection: false,
|
||||||
|
cellFillHandle: false,
|
||||||
},
|
},
|
||||||
tableClassNames: {
|
tableClassNames: {
|
||||||
base: "",
|
base: "",
|
||||||
@@ -555,7 +1101,9 @@ function DataGridContainer({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="data-grid"
|
data-slot="data-grid"
|
||||||
className={cn("w-full overflow-hidden", className)}
|
// relative: anchors floating chrome composed inside the container,
|
||||||
|
// like the fill-drag preview outline.
|
||||||
|
className={cn("relative w-full overflow-hidden", className)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { FilterFieldPicker } from "@/components/reui/filters/filters-builder"
|
import { FilterFieldPicker } from "@/components/reui/filters/filters-builder"
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import {
|
import {
|
||||||
Cascader,
|
Cascader,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import {
|
import {
|
||||||
filterControlSizes,
|
filterControlSizes,
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import type { FilterDraftAction } from "@/components/reui/filters/filters-draft"
|
import type { FilterDraftAction } from "@/components/reui/filters/filters-draft"
|
||||||
import type { FilterPathCollapse } from "@/components/reui/filters/filters-lib"
|
import type { FilterPathCollapse } from "@/components/reui/filters/filters-lib"
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import {
|
import {
|
||||||
Cascader,
|
Cascader,
|
||||||
|
|||||||
@@ -162,6 +162,14 @@ export interface FilterField<V = unknown, O = unknown> {
|
|||||||
operators?:
|
operators?:
|
||||||
| FilterOperator[]
|
| FilterOperator[]
|
||||||
| ((field: FilterField<V, O>) => FilterOperator[])
|
| ((field: FilterField<V, O>) => FilterOperator[])
|
||||||
|
/**
|
||||||
|
* The operator this field FALLS BACK to, not a way to preselect one. Both
|
||||||
|
* create paths deliberately start a rule on `operator: ""` so the condition
|
||||||
|
* menu opens with nothing chosen, so this is read when an existing rule
|
||||||
|
* changes field, and it is the value `FilterFieldPicker` hands to `onSelect`.
|
||||||
|
* Ignored, with a dev warning, when it names an operator the field does not
|
||||||
|
* offer; the first visible operator wins instead.
|
||||||
|
*/
|
||||||
defaultOperator?: string
|
defaultOperator?: string
|
||||||
|
|
||||||
/** Overrides the editor chosen from `type`. See `FilterEditorProps`. */
|
/** Overrides the editor chosen from `type`. See `FilterEditorProps`. */
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { FiltersAdvanced } from "@/components/reui/filters/filters-advanced"
|
import { FiltersAdvanced } from "@/components/reui/filters/filters-advanced"
|
||||||
import { FiltersBuilder } from "@/components/reui/filters/filters-builder"
|
import { FiltersBuilder } from "@/components/reui/filters/filters-builder"
|
||||||
|
|||||||
@@ -3,36 +3,30 @@ import { cva, type VariantProps } from "class-variance-authority"
|
|||||||
import { cn } from "@evobgp/ui/lib/utils"
|
import { cn } from "@evobgp/ui/lib/utils"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSS variable architecture for FramePanel theming:
|
* Frame sets --frame-panel-bg and --frame-panel-border-color; FramePanel reads
|
||||||
*
|
* them back as bg-(--frame-panel-bg) and border-(--frame-panel-border-color).
|
||||||
* The Frame parent sets --frame-panel-bg and --frame-panel-border-color.
|
* So variant="inverse" re-points every panel from one place, and a consumer's
|
||||||
* FramePanel consumes them directly via bg-(--frame-panel-bg) and
|
* own bg-* on a panel still wins on source order, with no :not() and no `!`.
|
||||||
* border-(--frame-panel-border-color). This means:
|
|
||||||
*
|
|
||||||
* - variant="inverse" overrides those vars on Frame → all panels pick it up
|
|
||||||
* - <FramePanel className="bg-blue-50"> adds a direct utility on the element
|
|
||||||
* which wins over bg-(--frame-panel-bg) by Tailwind source order — no
|
|
||||||
* :not() or !important needed
|
|
||||||
*/
|
*/
|
||||||
const frameVariants = cva(
|
const frameVariants = cva(
|
||||||
[
|
[
|
||||||
"relative flex flex-col bg-muted/50 gap-(--frame-gap) px-(--frame-px) py-(--frame-py) rounded-(--frame-radius)",
|
"relative flex flex-col bg-muted/50 gap-(--frame-gap) px-(--frame-px) py-(--frame-py) rounded-(--frame-radius)",
|
||||||
"(--radius-xl)] [--frame-radius:var(--radius-xl)]",
|
// Each rung is the radius that style's own .cn-card resolves through, so a
|
||||||
"(--radius-none)] (--radius-2xl)] (--radius-lg)] (--radius-none)]",
|
// Frame and a Card side by side agree. lyra/sera are 0px, NOT
|
||||||
|
// var(--radius-none): no such token exists, and it only reached 0 by being
|
||||||
|
// invalid, which also left --frame-radius empty for anything reading it.
|
||||||
|
"[--frame-radius:var(--radius-xl)]",
|
||||||
"[--frame-gap:--spacing(0.75)] [--frame-px:--spacing(0.75)] [--frame-py:--spacing(0.75)] [--frame-panel-header-gap:0rem] [--frame-panel-footer-gap:--spacing(1)]",
|
"[--frame-gap:--spacing(0.75)] [--frame-px:--spacing(0.75)] [--frame-py:--spacing(0.75)] [--frame-panel-header-gap:0rem] [--frame-panel-footer-gap:--spacing(1)]",
|
||||||
"[--frame-panel-px-adjust:0px] [--frame-panel-py-adjust:0px] [--frame-panel-header-px-adjust:0px] [--frame-panel-header-py-adjust:0px] [--frame-panel-footer-px-adjust:0px] [--frame-panel-footer-py-adjust:0px]",
|
"[--frame-panel-px-adjust:0px] [--frame-panel-py-adjust:0px] [--frame-panel-header-px-adjust:0px] [--frame-panel-header-py-adjust:0px] [--frame-panel-footer-px-adjust:0px] [--frame-panel-footer-py-adjust:0px]",
|
||||||
"[--frame-panel-px:calc(var(--frame-panel-px-base)_+_var(--frame-panel-px-adjust))] [--frame-panel-py:calc(var(--frame-panel-py-base)_+_var(--frame-panel-py-adjust))] [--frame-panel-header-px:calc(var(--frame-panel-header-px-base)_+_var(--frame-panel-header-px-adjust))] [--frame-panel-header-py:calc(var(--frame-panel-header-py-base)_+_var(--frame-panel-header-py-adjust))] [--frame-panel-footer-px:calc(var(--frame-panel-footer-px-base)_+_var(--frame-panel-footer-px-adjust))] [--frame-panel-footer-py:calc(var(--frame-panel-footer-py-base)_+_var(--frame-panel-footer-py-adjust))]",
|
"[--frame-panel-px:calc(var(--frame-panel-px-base)+var(--frame-panel-px-adjust))] [--frame-panel-py:calc(var(--frame-panel-py-base)+var(--frame-panel-py-adjust))] [--frame-panel-header-px:calc(var(--frame-panel-header-px-base)+var(--frame-panel-header-px-adjust))] [--frame-panel-header-py:calc(var(--frame-panel-header-py-base)+var(--frame-panel-header-py-adjust))] [--frame-panel-footer-px:calc(var(--frame-panel-footer-px-base)+var(--frame-panel-footer-px-adjust))] [--frame-panel-footer-py:calc(var(--frame-panel-footer-py-base)+var(--frame-panel-footer-py-adjust))]",
|
||||||
"(1)] (1)] (1.25)] (1.5)] (1.5)] (0.5)] (1)] (1)]",
|
// Luma alone re-times the frame: wider gap and padding, roomier bars.
|
||||||
// Default panel token values — overridden per-variant below
|
"",
|
||||||
"[--frame-panel-bg:var(--color-card)] [--frame-panel-border-color:var(--color-border)] [--frame-border-color:var(--color-border)]",
|
"[--frame-panel-bg:var(--color-card)] [--frame-panel-border-color:var(--color-border)] [--frame-border-color:var(--color-border)]",
|
||||||
// Concentric inner radius: the panel corner nests smoothly inside the frame
|
// Concentric: the panel nests inside the frame's corner rather than copying
|
||||||
// corner instead of matching it. The panel sits inset from the frame's outer
|
// it. It is inset by the 1px border plus --frame-px, so subtracting exactly
|
||||||
// edge by the frame's 1px border + --frame-px padding, so its radius is
|
// that keeps the two arcs parallel. `ghost` drops the border term (no
|
||||||
// reduced by that same gap (radius − gap keeps the two arcs parallel). This
|
// border), `dense` pins it to the frame radius (panels sit flush).
|
||||||
// base value assumes the bordered default/inverse frame; `ghost` drops the
|
"[--frame-panel-radius:calc(var(--frame-radius)-var(--frame-px)-1px)]",
|
||||||
// 1px border term and `dense` pins it back to the frame radius (its panels
|
|
||||||
// are pulled flush to the edge).
|
|
||||||
"[--frame-panel-radius:calc(var(--frame-radius)_-_var(--frame-px)_-_1px)]",
|
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
@@ -40,17 +34,12 @@ const frameVariants = cva(
|
|||||||
default: "border border-[var(--frame-border-color)] bg-clip-padding",
|
default: "border border-[var(--frame-border-color)] bg-clip-padding",
|
||||||
inverse:
|
inverse:
|
||||||
"[--frame-panel-bg:color-mix(in_oklch,var(--color-muted)_40%,transparent)] border border-[var(--frame-border-color)] bg-background bg-clip-padding",
|
"[--frame-panel-bg:color-mix(in_oklch,var(--color-muted)_40%,transparent)] border border-[var(--frame-border-color)] bg-background bg-clip-padding",
|
||||||
// No frame border, so the panel is inset by --frame-px padding only.
|
ghost: "[--frame-panel-radius:calc(var(--frame-radius)-var(--frame-px))]",
|
||||||
ghost: "[--frame-panel-radius:calc(var(--frame-radius)_-_var(--frame-px))]",
|
|
||||||
},
|
},
|
||||||
// Header/footer vertical rhythm is tighter than the panel body's, and
|
// Bars read as chrome, not a second content block: py runs 0.5/1.5/2/2.5
|
||||||
// the gap widens as the frame grows: the bars read as chrome rather than
|
// against a body py of 2/3.5/4/5, while px stays level with the body so
|
||||||
// as another content block. py ladder is 0.5 / 1.5 / 2 / 2.5 against a
|
// header, content and footer left-align. xs floors at 0.5 (2px), below
|
||||||
// body py of 2 / 3.5 / 4 / 5. These vars are style-agnostic - no
|
// which it stops reading as padding. No style-*.css overrides these.
|
||||||
// style-*.css overrides them - so this single ladder drives all shadcn
|
|
||||||
// styles. `px` is deliberately left level with the body so header,
|
|
||||||
// content and footer stay left-aligned. `xs` holds at 0.5 (2px): it is
|
|
||||||
// the practical floor, since anything lower stops reading as padding.
|
|
||||||
spacing: {
|
spacing: {
|
||||||
xs: "[--frame-panel-px-base:--spacing(2)] [--frame-panel-py-base:--spacing(2)] [--frame-panel-header-px-base:--spacing(2)] [--frame-panel-header-py-base:--spacing(0.5)] [--frame-panel-footer-px-base:--spacing(2)] [--frame-panel-footer-py-base:--spacing(0.5)]",
|
xs: "[--frame-panel-px-base:--spacing(2)] [--frame-panel-py-base:--spacing(2)] [--frame-panel-header-px-base:--spacing(2)] [--frame-panel-header-py-base:--spacing(0.5)] [--frame-panel-footer-px-base:--spacing(2)] [--frame-panel-footer-py-base:--spacing(0.5)]",
|
||||||
sm: "[--frame-panel-px-base:--spacing(3)] [--frame-panel-py-base:--spacing(3.5)] [--frame-panel-header-px-base:--spacing(3)] [--frame-panel-header-py-base:--spacing(1.5)] [--frame-panel-footer-px-base:--spacing(3)] [--frame-panel-footer-py-base:--spacing(1.5)]",
|
sm: "[--frame-panel-px-base:--spacing(3)] [--frame-panel-py-base:--spacing(3.5)] [--frame-panel-header-px-base:--spacing(3)] [--frame-panel-header-py-base:--spacing(1.5)] [--frame-panel-footer-px-base:--spacing(3)] [--frame-panel-footer-py-base:--spacing(1.5)]",
|
||||||
@@ -72,9 +61,8 @@ const frameVariants = cva(
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
dense: {
|
dense: {
|
||||||
// Positional rules must stay as parent selectors — cannot be expressed via CSS vars.
|
// Parent selectors, not CSS vars: these are positional. Panels are
|
||||||
// Padding is 0 and panels are pulled flush to the frame edge (-mx-px), so
|
// pulled flush (-mx-px), so corners align with the frame's own radius.
|
||||||
// their corners align with the frame radius rather than nesting inside it.
|
|
||||||
true: "p-0 gap-0 border-[var(--frame-border-color)] [--frame-panel-radius:var(--frame-radius)] [&_[data-slot=frame-panel]]:-mx-px [&_[data-slot=frame-panel]]:before:hidden [&_[data-slot=frame-panel]:last-child]:-mb-px [&:not(:has([data-slot=frame-panel-header]))_[data-slot=frame-panel]:is(:first-child)]:-mt-px",
|
true: "p-0 gap-0 border-[var(--frame-border-color)] [--frame-panel-radius:var(--frame-radius)] [&_[data-slot=frame-panel]]:-mx-px [&_[data-slot=frame-panel]]:before:hidden [&_[data-slot=frame-panel]:last-child]:-mb-px [&:not(:has([data-slot=frame-panel-header]))_[data-slot=frame-panel]:is(:first-child)]:-mt-px",
|
||||||
false: "",
|
false: "",
|
||||||
},
|
},
|
||||||
@@ -117,13 +105,10 @@ function FramePanel({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
// bg-(--frame-panel-bg) and border-(--frame-panel-border-color) consume the
|
|
||||||
// CSS vars set by the Frame parent. Any explicit bg-* or border-* class passed
|
|
||||||
// via className overrides these by Tailwind source order - no ! needed.
|
|
||||||
"relative overflow-hidden rounded-(--frame-panel-radius) border border-(--frame-panel-border-color) bg-(--frame-panel-bg) bg-clip-padding shadow-xs",
|
"relative overflow-hidden rounded-(--frame-panel-radius) border border-(--frame-panel-border-color) bg-(--frame-panel-bg) bg-clip-padding shadow-xs",
|
||||||
// `fit` sizes the panel to its content; otherwise it grows to fill the frame.
|
// `fit` sizes the panel to its content; otherwise it grows to fill the frame.
|
||||||
!fit && "grow",
|
!fit && "grow",
|
||||||
"before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--frame-panel-radius)_-_1px)] before:shadow-black/5",
|
"before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--frame-panel-radius)-1px)] before:shadow-black/5",
|
||||||
"dark:bg-clip-border dark:before:shadow-white/5",
|
"dark:bg-clip-border dark:before:shadow-white/5",
|
||||||
"px-(--frame-panel-px) py-(--frame-panel-py)",
|
"px-(--frame-panel-px) py-(--frame-panel-py)",
|
||||||
className
|
className
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { mergeProps } from "@base-ui/react/merge-props"
|
import { mergeProps } from "@base-ui/react/merge-props"
|
||||||
import { useRender } from "@base-ui/react/use-render"
|
import { useRender } from "@base-ui/react/use-render"
|
||||||
import type {
|
import type {
|
||||||
|
CollisionDetection,
|
||||||
DragCancelEvent,
|
DragCancelEvent,
|
||||||
DragEndEvent,
|
DragEndEvent,
|
||||||
DragOverEvent,
|
DragOverEvent,
|
||||||
@@ -22,12 +23,16 @@ import type {
|
|||||||
UniqueIdentifier,
|
UniqueIdentifier,
|
||||||
} from "@dnd-kit/core"
|
} from "@dnd-kit/core"
|
||||||
import {
|
import {
|
||||||
|
closestCenter,
|
||||||
defaultDropAnimationSideEffects,
|
defaultDropAnimationSideEffects,
|
||||||
DndContext,
|
DndContext,
|
||||||
DragOverlay,
|
DragOverlay,
|
||||||
|
getFirstCollision,
|
||||||
KeyboardSensor,
|
KeyboardSensor,
|
||||||
MeasuringStrategy,
|
MeasuringStrategy,
|
||||||
MouseSensor,
|
MouseSensor,
|
||||||
|
pointerWithin,
|
||||||
|
rectIntersection,
|
||||||
TouchSensor,
|
TouchSensor,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
@@ -254,6 +259,49 @@ function Kanban<T>({
|
|||||||
[columns, columnIds, getItemValue, isColumn]
|
[columns, columnIds, getItemValue, isColumn]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// The droppable under the pointer, not the one the dragged rect overlaps
|
||||||
|
// most: in the gap between columns that overlap flips with every live-preview
|
||||||
|
// move, and each flip re-runs dragOver until React bails out.
|
||||||
|
const lastOverIdRef = useRef<UniqueIdentifier | null>(null)
|
||||||
|
const collisionDetection = useCallback<CollisionDetection>(
|
||||||
|
(args) => {
|
||||||
|
if (isColumn(args.active.id)) {
|
||||||
|
return closestCenter({
|
||||||
|
...args,
|
||||||
|
droppableContainers: args.droppableContainers.filter((container) =>
|
||||||
|
isColumn(container.id)
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard drags carry no pointer.
|
||||||
|
if (!args.pointerCoordinates) return rectIntersection(args)
|
||||||
|
|
||||||
|
let overId = getFirstCollision(pointerWithin(args), "id")
|
||||||
|
if (overId != null) {
|
||||||
|
// Over a column's empty space: resolve to its closest item, if any.
|
||||||
|
if (isColumn(overId)) {
|
||||||
|
const itemIds = new Set(columns[overId as string].map(getItemValue))
|
||||||
|
overId =
|
||||||
|
closestCenter({
|
||||||
|
...args,
|
||||||
|
droppableContainers: args.droppableContainers.filter(
|
||||||
|
(container) => itemIds.has(container.id as string)
|
||||||
|
),
|
||||||
|
})[0]?.id ?? overId
|
||||||
|
}
|
||||||
|
lastOverIdRef.current = overId
|
||||||
|
return [{ id: overId }]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Between droppables: hold the last target so the preview stays put.
|
||||||
|
return lastOverIdRef.current != null
|
||||||
|
? [{ id: lastOverIdRef.current }]
|
||||||
|
: rectIntersection(args)
|
||||||
|
},
|
||||||
|
[columns, getItemValue, isColumn]
|
||||||
|
)
|
||||||
|
|
||||||
const commitChange = useCallback(
|
const commitChange = useCallback(
|
||||||
(
|
(
|
||||||
finalValue: Record<string, T[]>,
|
finalValue: Record<string, T[]>,
|
||||||
@@ -312,6 +360,7 @@ function Kanban<T>({
|
|||||||
|
|
||||||
const handleDragStart = useCallback(
|
const handleDragStart = useCallback(
|
||||||
(event: DragStartEvent) => {
|
(event: DragStartEvent) => {
|
||||||
|
lastOverIdRef.current = null
|
||||||
setActiveId(event.active.id)
|
setActiveId(event.active.id)
|
||||||
onDragStart?.(event)
|
onDragStart?.(event)
|
||||||
|
|
||||||
@@ -421,6 +470,7 @@ function Kanban<T>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
dragOriginRef.current = null
|
dragOriginRef.current = null
|
||||||
|
lastOverIdRef.current = null
|
||||||
setActiveId(null)
|
setActiveId(null)
|
||||||
onDragCancel?.(event)
|
onDragCancel?.(event)
|
||||||
},
|
},
|
||||||
@@ -437,6 +487,7 @@ function Kanban<T>({
|
|||||||
const handleDragEnd = useCallback(
|
const handleDragEnd = useCallback(
|
||||||
(event: DragEndEvent) => {
|
(event: DragEndEvent) => {
|
||||||
const { active, over } = event
|
const { active, over } = event
|
||||||
|
lastOverIdRef.current = null
|
||||||
setActiveId(null)
|
setActiveId(null)
|
||||||
onDragEnd?.(event)
|
onDragEnd?.(event)
|
||||||
|
|
||||||
@@ -589,6 +640,7 @@ function Kanban<T>({
|
|||||||
modifiers={modifiers}
|
modifiers={modifiers}
|
||||||
accessibility={accessibility}
|
accessibility={accessibility}
|
||||||
measuring={MEASURING_CONFIG}
|
measuring={MEASURING_CONFIG}
|
||||||
|
collisionDetection={collisionDetection}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import type { HTMLAttributes, ReactElement } from "react"
|
import type { HTMLAttributes, ReactElement } from "react"
|
||||||
import {
|
import {
|
||||||
Children,
|
Children,
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { createContext, useCallback, useContext, useState } from "react"
|
import { createContext, useCallback, useContext, useState } from "react"
|
||||||
import { mergeProps } from "@base-ui/react/merge-props"
|
import { mergeProps } from "@base-ui/react/merge-props"
|
||||||
import { useRender } from "@base-ui/react/use-render"
|
import { useRender } from "@base-ui/react/use-render"
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import { ScheduleJobsGrid } from './schedule-jobs-grid'
|
|||||||
/** Jobs ResourcePage with status tabs (data-grid-filtering-2). */
|
/** Jobs ResourcePage with status tabs (data-grid-filtering-2). */
|
||||||
export function ScheduleJobsCard({
|
export function ScheduleJobsCard({
|
||||||
jobs,
|
jobs,
|
||||||
|
nameById,
|
||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
error,
|
error,
|
||||||
onRetry,
|
onRetry,
|
||||||
}: {
|
}: {
|
||||||
jobs: JobRow[]
|
jobs: JobRow[]
|
||||||
|
nameById?: Map<string, string>
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
isError: boolean
|
isError: boolean
|
||||||
error: unknown
|
error: unknown
|
||||||
@@ -27,7 +29,11 @@ export function ScheduleJobsCard({
|
|||||||
onRetry={onRetry}
|
onRetry={onRetry}
|
||||||
>
|
>
|
||||||
{(items) => (
|
{(items) => (
|
||||||
<ScheduleJobsGrid items={items} isLoading={isLoading && items.length > 0} />
|
<ScheduleJobsGrid
|
||||||
|
items={items}
|
||||||
|
nameById={nameById}
|
||||||
|
isLoading={isLoading && items.length > 0}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</QueryState>
|
</QueryState>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
createTextFilterQuery,
|
createTextFilterQuery,
|
||||||
type DataGridColumnDef,
|
type DataGridColumnDef,
|
||||||
} from '@/components/reui-kit'
|
} from '@/components/reui-kit'
|
||||||
import { isRefreshJobKind, jobKindRu } from '@/lib/ui-labels'
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { isRefreshJobKind, jobKindRu, jobSubtitleRu, jobTriggerRu } from '@/lib/ui-labels'
|
||||||
import type { JobRow } from '@/types/api'
|
import type { JobRow } from '@/types/api'
|
||||||
|
|
||||||
const FAILED_STATUSES = ['failed', 'error', 'cancelled']
|
const FAILED_STATUSES = ['failed', 'error', 'cancelled']
|
||||||
@@ -33,9 +34,11 @@ function tabFilter(item: JobRow, tabId: string): boolean {
|
|||||||
|
|
||||||
export function ScheduleJobsGrid({
|
export function ScheduleJobsGrid({
|
||||||
items,
|
items,
|
||||||
|
nameById = new Map<string, string>(),
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
}: {
|
}: {
|
||||||
items: JobRow[]
|
items: JobRow[]
|
||||||
|
nameById?: Map<string, string>
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
}) {
|
}) {
|
||||||
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
|
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
|
||||||
@@ -64,9 +67,25 @@ export function ScheduleJobsGrid({
|
|||||||
{
|
{
|
||||||
accessorKey: 'kind',
|
accessorKey: 'kind',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Вид" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => {
|
||||||
<DataGridNameCell icon={ListTodo} title={jobKindRu(row.original.kind)} />
|
const trigger = jobTriggerRu(row.original.meta?.trigger)
|
||||||
),
|
return (
|
||||||
|
<DataGridNameCell
|
||||||
|
icon={ListTodo}
|
||||||
|
title={
|
||||||
|
<span className="flex min-w-0 items-center gap-1.5">
|
||||||
|
<span className="truncate">{jobKindRu(row.original.kind)}</span>
|
||||||
|
{trigger ? (
|
||||||
|
<Badge variant="outline" size="sm" className="shrink-0 font-normal">
|
||||||
|
{trigger}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
subtitle={jobSubtitleRu(row.original.kind, row.original.meta, nameById)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
meta: { headerTitle: 'Вид' },
|
meta: { headerTitle: 'Вид' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -119,7 +138,7 @@ export function ScheduleJobsGrid({
|
|||||||
meta: { headerTitle: 'Ошибка' },
|
meta: { headerTitle: 'Ошибка' },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[],
|
[nameById],
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -134,7 +153,9 @@ export function ScheduleJobsGrid({
|
|||||||
onClearFilters={() => setFilterQuery(createTextFilterQuery('search'))}
|
onClearFilters={() => setFilterQuery(createTextFilterQuery('search'))}
|
||||||
getFilterFieldValue={(item, field) => {
|
getFilterFieldValue={(item, field) => {
|
||||||
if (field !== 'search') return undefined
|
if (field !== 'search') return undefined
|
||||||
return `${jobKindRu(item.kind)} ${item.status} ${item.error ?? ''} ${item.job_id}`
|
const subtitle = jobSubtitleRu(item.kind, item.meta, nameById)
|
||||||
|
const trigger = jobTriggerRu(item.meta?.trigger)
|
||||||
|
return `${jobKindRu(item.kind)} ${item.status} ${item.error ?? ''} ${item.job_id} ${subtitle ?? ''} ${trigger ?? ''}`
|
||||||
}}
|
}}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={items}
|
data={items}
|
||||||
|
|||||||
@@ -52,12 +52,14 @@ export function ScheduleModulesGrid({
|
|||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Модуль" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Модуль" />,
|
||||||
cell: ({ row }) => <DataGridNameCell icon={Boxes} title={row.original.name} />,
|
cell: ({ row }) => <DataGridNameCell icon={Boxes} title={row.original.name} />,
|
||||||
meta: { headerTitle: 'Модуль' },
|
meta: { headerTitle: 'Модуль' },
|
||||||
|
size: 280,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'type',
|
accessorKey: 'type',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Тип" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Тип" />,
|
||||||
cell: ({ row }) => <CategoryBadge>{moduleTypeRu(row.original.type)}</CategoryBadge>,
|
cell: ({ row }) => <CategoryBadge>{moduleTypeRu(row.original.type)}</CategoryBadge>,
|
||||||
meta: { headerTitle: 'Тип' },
|
meta: { headerTitle: 'Тип' },
|
||||||
|
size: 150,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'schedule',
|
id: 'schedule',
|
||||||
@@ -70,6 +72,7 @@ export function ScheduleModulesGrid({
|
|||||||
</DataGridMonoCell>
|
</DataGridMonoCell>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Расписание' },
|
meta: { headerTitle: 'Расписание' },
|
||||||
|
size: 130,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'last_refreshed_at',
|
id: 'last_refreshed_at',
|
||||||
@@ -83,6 +86,7 @@ export function ScheduleModulesGrid({
|
|||||||
</DataGridMutedCell>
|
</DataGridMutedCell>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Обновлено' },
|
meta: { headerTitle: 'Обновлено' },
|
||||||
|
size: 190,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'enabled',
|
id: 'enabled',
|
||||||
@@ -92,6 +96,7 @@ export function ScheduleModulesGrid({
|
|||||||
<ModeBadge enabled={row.original.enabled} onLabel="Вкл" offLabel="Выкл" />
|
<ModeBadge enabled={row.original.enabled} onLabel="Вкл" offLabel="Выкл" />
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Статус' },
|
meta: { headerTitle: 'Статус' },
|
||||||
|
size: 110,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
|
|||||||
@@ -58,7 +58,11 @@ export function SelectMenu<T extends string = string>({
|
|||||||
<SelectTrigger id={id} className={cn('w-full', triggerClassName)} size={size}>
|
<SelectTrigger id={id} className={cn('w-full', triggerClassName)} size={size}>
|
||||||
<SelectValue placeholder={placeholder} />
|
<SelectValue placeholder={placeholder} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent side={side} className={contentClassName}>
|
{/* alignItemWithTrigger=false: список раскрывается ПОД триггером. Режим
|
||||||
|
по умолчанию (true) накладывает выбранный пункт на триггер, а у
|
||||||
|
селектов с плейсхолдером (без значения) попап накрывает сам триггер.
|
||||||
|
Так же делает вендорский data-grid-pagination. */}
|
||||||
|
<SelectContent side={side} alignItemWithTrigger={false} className={contentClassName}>
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<SelectItem key={item.value} value={item.value} disabled={item.disabled}>
|
<SelectItem key={item.value} value={item.value} disabled={item.disabled}>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
} from '@evobgp/ui/components/item'
|
} from '@evobgp/ui/components/item'
|
||||||
|
|
||||||
const ROLE_LABELS: Record<string, string> = {
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
viewer: 'Просмотр',
|
viewer: 'Наблюдатель',
|
||||||
editor: 'Редактор',
|
editor: 'Редактор',
|
||||||
operator: 'Оператор',
|
operator: 'Оператор',
|
||||||
node: 'Нода',
|
node: 'Нода',
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
export const DATA_GRID_PAGINATION_RU = {
|
|
||||||
sizes: [10, 25, 50] as number[],
|
|
||||||
sizesLabel: 'Показать',
|
|
||||||
sizesDescription: 'на странице',
|
|
||||||
info: '{from}–{to} из {count}',
|
|
||||||
rowsPerPageLabel: 'Строк на странице',
|
|
||||||
previousPageLabel: 'Предыдущая страница',
|
|
||||||
nextPageLabel: 'Следующая страница',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DATA_GRID_MESSAGES_RU = {
|
|
||||||
emptyMessage: 'Нет данных',
|
|
||||||
loadingMessage: 'Загрузка…',
|
|
||||||
fetchingMoreMessage: 'Загрузка…',
|
|
||||||
selectAllLabel: 'Выбрать все',
|
|
||||||
selectRowLabel: 'Выбрать строку',
|
|
||||||
pinRowLabel: 'Закрепить строку',
|
|
||||||
unpinRowLabel: 'Открепить строку',
|
|
||||||
allRecordsLoadedMessage: 'Все записи загружены',
|
|
||||||
}
|
|
||||||
@@ -53,6 +53,46 @@ export const FILTERS_LABELS_RU: Partial<FilterLabels> = {
|
|||||||
noValue: 'нет значения',
|
noValue: 'нет значения',
|
||||||
selectCondition: 'Выберите условие',
|
selectCondition: 'Выберите условие',
|
||||||
incomplete: 'незавершённый фильтр',
|
incomplete: 'незавершённый фильтр',
|
||||||
|
branchAffordance: 'открывает список',
|
||||||
|
exclusiveHint: 'нельзя сочетать с другими вариантами',
|
||||||
|
exclusiveAnnouncement: (label, cleared) =>
|
||||||
|
cleared === 1
|
||||||
|
? `Выбрано «${label}». 1 другой вариант сброшен.`
|
||||||
|
: `Выбрано «${label}». Других вариантов сброшено: ${cleared}.`,
|
||||||
|
itemCount: (count) => `Элементов: ${count}`,
|
||||||
|
fieldsLabel: 'Поля',
|
||||||
|
resultsAnnouncement: (count) =>
|
||||||
|
count === 1 ? '1 результат' : `Результатов: ${count}`,
|
||||||
|
actionsLabel: 'Действия',
|
||||||
|
rowLabel: (condition, depth) => `${condition}, уровень ${depth}`,
|
||||||
|
groupLabel: (description, depth) => `${description}, уровень ${depth}`,
|
||||||
|
groupAnnouncement: (added) => (added ? 'Группа добавлена' : 'Группа удалена'),
|
||||||
|
reorderAnnouncement: (label, position, total) =>
|
||||||
|
`${label} перемещён на позицию ${position} из ${total}`,
|
||||||
|
moveAnnouncement: (label, destination, position, total) =>
|
||||||
|
`${label} перемещён в ${destination}, позиция ${position} из ${total}`,
|
||||||
|
combinatorLabel: (word) => `${word}, изменить связку`,
|
||||||
|
chipMenu: (fieldLabel) => `Параметры фильтра «${fieldLabel}»`,
|
||||||
|
stepAnnouncement: (step, label) => {
|
||||||
|
if (step === 'field') return `Выберите поле. ${label}`
|
||||||
|
if (step === 'operator') return `Выберите условие для ${label}`
|
||||||
|
return `Введите значение для ${label}`
|
||||||
|
},
|
||||||
|
countAnnouncement: (count) =>
|
||||||
|
count === 1 ? 'Применён 1 фильтр' : `Применено фильтров: ${count}`,
|
||||||
|
valueCount: (count) => `Выбрано: ${count}`,
|
||||||
|
valueRange: (from, to) => `${from} – ${to}`,
|
||||||
|
rangeFrom: (fieldLabel) => `${fieldLabel} от`,
|
||||||
|
rangeTo: (fieldLabel) => `${fieldLabel} до`,
|
||||||
|
rangeSeparator: '—',
|
||||||
|
negated: (operatorLabel) => `не ${operatorLabel}`,
|
||||||
|
issueOperator: 'Выберите условие',
|
||||||
|
issueValue: 'Введите значение',
|
||||||
|
issueRange: 'Заполните оба конца диапазона',
|
||||||
|
issueRangeOrder: 'Конец диапазона раньше его начала',
|
||||||
|
issueEmptyGroup: 'В этой группе пока нет условий',
|
||||||
|
issueSummary: (count) =>
|
||||||
|
count === 1 ? '1 строка требует внимания' : `Строк требует внимания: ${count}`,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FILTERS_OPERATOR_LABELS_RU: FilterOperatorLabels = {
|
export const FILTERS_OPERATOR_LABELS_RU: FilterOperatorLabels = {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { ModuleRow, ModuleType } from '@/types/api'
|
|||||||
import type { BreakdownSlice } from './types'
|
import type { BreakdownSlice } from './types'
|
||||||
|
|
||||||
const TYPE_META: Record<ModuleType, { label: string; color: string }> = {
|
const TYPE_META: Record<ModuleType, { label: string; color: string }> = {
|
||||||
AS_PREFIXES: { label: 'AS / префиксы', color: 'var(--color-chart-1)' },
|
AS_PREFIXES: { label: 'AS-префиксы', color: 'var(--color-chart-1)' },
|
||||||
DOMAINS: { label: 'Домены', color: 'var(--color-chart-2)' },
|
DOMAINS: { label: 'Домены', color: 'var(--color-chart-2)' },
|
||||||
CDN_CIDRS: { label: 'CDN', color: 'var(--color-chart-3)' },
|
CDN_CIDRS: { label: 'CDN', color: 'var(--color-chart-3)' },
|
||||||
IP_RANGES: { label: 'IP-диапазоны', color: 'var(--color-chart-4)' },
|
IP_RANGES: { label: 'IP-диапазоны', color: 'var(--color-chart-4)' },
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import type {
|
||||||
|
DataGridI18nLabels,
|
||||||
|
DataGridI18nOverrides,
|
||||||
|
} from '@/components/reui/data-grid/data-grid-i18n'
|
||||||
|
import type { CascaderLabels } from '@/components/reui/cascader/cascader-types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Русские формы множественного числа: 1 элемент, 2 элемента, 5 элементов.
|
||||||
|
*/
|
||||||
|
function ruPlural(count: number, one: string, few: string, many: string): string {
|
||||||
|
const mod10 = count % 10
|
||||||
|
const mod100 = count % 100
|
||||||
|
if (mod10 === 1 && mod100 !== 11) return one
|
||||||
|
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return few
|
||||||
|
return many
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Единый русский словарь ReUI DataGrid — официальный i18n-слой (@reui/data-grid-i18n).
|
||||||
|
* Подключается один раз через `i18n={DATA_GRID_I18N_RU}` на <DataGrid>
|
||||||
|
* (FrameDataGrid и ResourcePage) — наследуют все гриды приложения.
|
||||||
|
* Полнота словаря проверяется компилятором: тип DataGridI18nLabels требует все ключи.
|
||||||
|
*
|
||||||
|
* @see https://reui.io/docs/components/base/data-grid
|
||||||
|
*/
|
||||||
|
const DATA_GRID_LABELS_RU: DataGridI18nLabels = {
|
||||||
|
/* Меню заголовка столбца. */
|
||||||
|
sortAscending: 'По возрастанию',
|
||||||
|
sortDescending: 'По убыванию',
|
||||||
|
pinColumnStart: 'Закрепить слева',
|
||||||
|
pinColumnEnd: 'Закрепить справа',
|
||||||
|
moveColumnStart: 'Сдвинуть влево',
|
||||||
|
moveColumnEnd: 'Сдвинуть вправо',
|
||||||
|
columnsMenu: 'Столбцы',
|
||||||
|
unpinColumn: (title) => `Открепить столбец «${title}»`,
|
||||||
|
toggleColumns: 'Настройка столбцов',
|
||||||
|
/* Афордансы строк и ячеек. */
|
||||||
|
rowCreate: 'Добавить строку',
|
||||||
|
pinRow: 'Закрепить строку',
|
||||||
|
unpinRow: 'Открепить строку',
|
||||||
|
selectRow: 'Выбрать строку',
|
||||||
|
selectAll: 'Выбрать всё',
|
||||||
|
expandRow: 'Развернуть строку',
|
||||||
|
collapseRow: 'Свернуть строку',
|
||||||
|
dragToReorder: 'Перетащите, чтобы изменить порядок',
|
||||||
|
dragToReorderRow: 'Перетащите, чтобы изменить порядок строк',
|
||||||
|
reorderingUnavailable: 'Изменение порядка недоступно',
|
||||||
|
/* Состояния грида. */
|
||||||
|
loading: 'Загрузка…',
|
||||||
|
empty: 'Нет данных',
|
||||||
|
allRowsLoaded: 'Все записи загружены',
|
||||||
|
/* Пагинация. */
|
||||||
|
rowsPerPage: 'Строк на странице',
|
||||||
|
paginationInfo: ({ from, to, count }) => `${from}–${to} из ${count}`,
|
||||||
|
previousPage: 'Предыдущая страница',
|
||||||
|
nextPage: 'Следующая страница',
|
||||||
|
goToPage: (page) => `Перейти на страницу ${page}`,
|
||||||
|
paginationEllipsis: '…',
|
||||||
|
/* Фасетный фильтр столбца. */
|
||||||
|
filterSelectedCount: (count) => `Выбрано: ${count}`,
|
||||||
|
filterNoResults: 'Ничего не найдено.',
|
||||||
|
filterClear: 'Сбросить фильтры',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DATA_GRID_I18N_RU: DataGridI18nOverrides = {
|
||||||
|
labels: DATA_GRID_LABELS_RU,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Единый русский словарь ReUI Cascader (@reui/cascader-i18n).
|
||||||
|
* Передаётся в `labels` каскадера; поверх — контекстный `rootLevel`.
|
||||||
|
*
|
||||||
|
* @see https://reui.io/docs/components/base/cascader
|
||||||
|
*/
|
||||||
|
const CASCADER_ROOT_LEVEL_RU = 'Корневой уровень'
|
||||||
|
|
||||||
|
const cascaderItemCount = (count: number) =>
|
||||||
|
`${count} ${ruPlural(count, 'элемент', 'элемента', 'элементов')}`
|
||||||
|
|
||||||
|
export const CASCADER_LABELS_RU: CascaderLabels = {
|
||||||
|
search: (parentLabel) =>
|
||||||
|
parentLabel ? `Поиск: ${parentLabel}…` : 'Поиск…',
|
||||||
|
back: 'Назад',
|
||||||
|
loading: 'Загрузка…',
|
||||||
|
loadingMore: 'Загрузка…',
|
||||||
|
loadMore: 'Ещё',
|
||||||
|
error: 'Не удалось загрузить элементы.',
|
||||||
|
retry: 'Повторить',
|
||||||
|
empty: 'Ничего не найдено.',
|
||||||
|
selectedCount: (count) => `Выбрано: ${count}`,
|
||||||
|
breadcrumbLabel: 'Навигация',
|
||||||
|
chipsLabel: 'Выбранные элементы',
|
||||||
|
removeChip: (label) => `Удалить «${label}»`,
|
||||||
|
pathSeparator: '/',
|
||||||
|
rootLevel: CASCADER_ROOT_LEVEL_RU,
|
||||||
|
itemCount: cascaderItemCount,
|
||||||
|
branchAffordance: 'подменю',
|
||||||
|
selectedState: 'выбрано',
|
||||||
|
partiallySelectedState: 'частично выбрано',
|
||||||
|
columnsLabel: 'Уровни',
|
||||||
|
actionsLabel: 'Действия',
|
||||||
|
submenuAffordance: 'открывает меню',
|
||||||
|
panelLabel: 'Варианты',
|
||||||
|
keyboardHint: (mode, dir) => {
|
||||||
|
const open = dir === 'rtl' ? 'влево' : 'вправо'
|
||||||
|
const back = dir === 'rtl' ? 'вправо' : 'влево'
|
||||||
|
if (mode === 'tree') {
|
||||||
|
return `Стрелка ${open} — раскрыть уровень, стрелка ${back} — свернуть.`
|
||||||
|
}
|
||||||
|
if (mode === 'columns') {
|
||||||
|
return `Стрелка ${open} — открыть следующий столбец, стрелка ${back} — вернуться.`
|
||||||
|
}
|
||||||
|
return `Стрелка ${open} — открыть ветку, стрелка ${back} — вернуться.`
|
||||||
|
},
|
||||||
|
rootAnnouncement: (count) => `${CASCADER_ROOT_LEVEL_RU}, ${cascaderItemCount(count)}`,
|
||||||
|
expandedAnnouncement: (label, count) => `${label} раскрыт, ${cascaderItemCount(count)}`,
|
||||||
|
collapsedAnnouncement: (label) => `${label} свёрнут`,
|
||||||
|
levelAnnouncement: (parentLabel, depth, count) =>
|
||||||
|
`${parentLabel}, уровень ${depth}, ${cascaderItemCount(count)}`,
|
||||||
|
resultsAnnouncement: (count) =>
|
||||||
|
count === 1 ? '1 результат' : `Результатов: ${count}`,
|
||||||
|
maxReachedAnnouncement: (max) => `Достигнут предел выбора: ${max}`,
|
||||||
|
cascadeAnnouncement: (label, count, selecting) =>
|
||||||
|
`${label} ${selecting ? 'выбрано' : 'снято'}, вложенных затронуто: ${count}`,
|
||||||
|
searchingAnnouncement: 'Поиск…',
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ export function dohPolicyRu(policy: DohResolverPolicy | string | null | undefine
|
|||||||
export function moduleTypeRu(type: string): string {
|
export function moduleTypeRu(type: string): string {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'AS_PREFIXES':
|
case 'AS_PREFIXES':
|
||||||
return 'AS (номера)'
|
return 'AS-префиксы'
|
||||||
case 'CDN_CIDRS':
|
case 'CDN_CIDRS':
|
||||||
return 'CDN CIDR'
|
return 'CDN CIDR'
|
||||||
case 'DOMAINS':
|
case 'DOMAINS':
|
||||||
@@ -30,7 +30,7 @@ export function moduleTypeRu(type: string): string {
|
|||||||
|
|
||||||
const JOB_KIND_RU: Record<string, string> = {
|
const JOB_KIND_RU: Record<string, string> = {
|
||||||
module_refresh: 'Обновление модуля',
|
module_refresh: 'Обновление модуля',
|
||||||
tenant_refresh: 'Обновление тенанта',
|
tenant_refresh: 'Обновление арендатора',
|
||||||
peer_reconcile: 'Согласование пиров',
|
peer_reconcile: 'Согласование пиров',
|
||||||
deploy_apply: 'Применение на спикеры',
|
deploy_apply: 'Применение на спикеры',
|
||||||
apply: 'Применение конфигурации',
|
apply: 'Применение конфигурации',
|
||||||
@@ -58,6 +58,54 @@ export function jobKindRu(kind: string): string {
|
|||||||
return JOB_KIND_RU[kind] ?? kind
|
return JOB_KIND_RU[kind] ?? kind
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const JOB_TRIGGER_RU: Record<string, string> = {
|
||||||
|
scheduler: 'по расписанию',
|
||||||
|
api: 'через API',
|
||||||
|
peer_patch: 'правка пиров',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function jobTriggerRu(trigger: unknown): string | undefined {
|
||||||
|
if (typeof trigger !== 'string' || trigger === '') return undefined
|
||||||
|
const known = JOB_TRIGGER_RU[trigger]
|
||||||
|
if (known) return known
|
||||||
|
if (/_(create|patch|delete|import_csv)$/.test(trigger)) return 'после правки данных'
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function jobSubtitleRu(
|
||||||
|
kind: string,
|
||||||
|
meta: Record<string, unknown> | undefined,
|
||||||
|
nameById: Map<string, string>,
|
||||||
|
): string | undefined {
|
||||||
|
if (!meta) return undefined
|
||||||
|
switch (kind) {
|
||||||
|
case 'tenant_refresh': {
|
||||||
|
const ids = Array.isArray(meta.module_ids)
|
||||||
|
? meta.module_ids.map(String).filter((id) => id !== '')
|
||||||
|
: []
|
||||||
|
if (ids.length === 0) return undefined
|
||||||
|
const names = ids.map((id) => nameById.get(id) ?? id)
|
||||||
|
if (names.length <= 3) return names.join(', ')
|
||||||
|
return `${names.slice(0, 2).join(', ')} +${names.length - 2} ещё`
|
||||||
|
}
|
||||||
|
case 'module_refresh': {
|
||||||
|
const id = typeof meta.module_id === 'string' ? meta.module_id : ''
|
||||||
|
if (id === '') return undefined
|
||||||
|
return nameById.get(id) ?? id
|
||||||
|
}
|
||||||
|
case 'deploy_apply': {
|
||||||
|
const summary = meta.apply_summary
|
||||||
|
if (summary && typeof summary === 'object') {
|
||||||
|
const message = (summary as Record<string, unknown>).message
|
||||||
|
if (typeof message === 'string' && message !== '') return message
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const JOB_STATUS_RU: Record<string, string> = {
|
const JOB_STATUS_RU: Record<string, string> = {
|
||||||
queued: 'В очереди',
|
queued: 'В очереди',
|
||||||
running: 'Выполняется',
|
running: 'Выполняется',
|
||||||
@@ -81,7 +129,7 @@ const JOB_STATUS_RU: Record<string, string> = {
|
|||||||
disabled: 'Выключен',
|
disabled: 'Выключен',
|
||||||
archived: 'В архиве',
|
archived: 'В архиве',
|
||||||
block: 'Блокировать',
|
block: 'Блокировать',
|
||||||
accept: 'Принимать',
|
accept: 'Принять',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function jobStatusRu(status: string): string {
|
export function jobStatusRu(status: string): string {
|
||||||
|
|||||||
@@ -19,16 +19,6 @@ export const SKIP_TO_CONTENT_CLASS =
|
|||||||
/** Grid for ReUI stats-12 KPI rows (3–6 tiles). */
|
/** Grid for ReUI stats-12 KPI rows (3–6 tiles). */
|
||||||
export const kpiStatGridClassName = '@container w-full'
|
export const kpiStatGridClassName = '@container w-full'
|
||||||
|
|
||||||
/** @deprecated Use kpiStatGridClassName — kept for legacy imports. */
|
|
||||||
export const dashboardKpiGridClassName = kpiStatGridClassName
|
|
||||||
|
|
||||||
/** @deprecated Sparkline KPI row replaced by stats-12 grid. */
|
|
||||||
export const kpiGridClassName =
|
|
||||||
'grid grid-cols-1 gap-5 @3xl:grid-cols-2 @6xl:grid-cols-4'
|
|
||||||
|
|
||||||
/** @deprecated */
|
|
||||||
export const kpiCardContentClassName = 'flex flex-col items-start gap-4 p-5'
|
|
||||||
|
|
||||||
/** Two-column chart panel row (monitoring / network overview). */
|
/** Two-column chart panel row (monitoring / network overview). */
|
||||||
export const chartPanelGridClassName =
|
export const chartPanelGridClassName =
|
||||||
'grid min-w-0 grid-cols-1 items-start gap-4 @5xl:grid-cols-2'
|
'grid min-w-0 grid-cols-1 items-start gap-4 @5xl:grid-cols-2'
|
||||||
|
|||||||
@@ -4,24 +4,17 @@ import { RefreshCw } from 'lucide-react'
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
|
||||||
|
|
||||||
import { DashboardActivityTimeline } from '@/components/dashboard/dashboard-activity-timeline'
|
|
||||||
import { buildDashboardKpiCards } from '@/components/dashboard/dashboard-kpi-grid'
|
import { buildDashboardKpiCards } from '@/components/dashboard/dashboard-kpi-grid'
|
||||||
import { DashboardModulesGrid } from '@/components/dashboard/dashboard-modules-grid'
|
|
||||||
import { DashboardNetworkHealth } from '@/components/dashboard/dashboard-network-health'
|
import { DashboardNetworkHealth } from '@/components/dashboard/dashboard-network-health'
|
||||||
import { DashboardOperationsBreakdown } from '@/components/dashboard/dashboard-operations-breakdown'
|
import { DashboardOperationsBreakdown } from '@/components/dashboard/dashboard-operations-breakdown'
|
||||||
import { DashboardQuickLinks } from '@/components/dashboard/dashboard-quick-links'
|
import { DashboardQuickLinks } from '@/components/dashboard/dashboard-quick-links'
|
||||||
import { DashboardRecentJobsGrid } from '@/components/dashboard/dashboard-recent-jobs-grid'
|
|
||||||
import { DashboardRecentRevisionsGrid } from '@/components/dashboard/dashboard-recent-revisions-grid'
|
|
||||||
import { OpsDashboard } from '@/components/reui-kit'
|
import { OpsDashboard } from '@/components/reui-kit'
|
||||||
import { chartPanelGridClassName, dashboardMainSidebarClassName } from '@/lib/ui-surface'
|
import { chartPanelGridClassName } from '@/lib/ui-surface'
|
||||||
import {
|
import {
|
||||||
moduleNameById,
|
|
||||||
overviewJobsQueryOptions,
|
overviewJobsQueryOptions,
|
||||||
overviewModulesQueryOptions,
|
overviewModulesQueryOptions,
|
||||||
overviewPeersQueryOptions,
|
overviewPeersQueryOptions,
|
||||||
overviewRevisionsQueryOptions,
|
|
||||||
overviewSpeakersQueryOptions,
|
overviewSpeakersQueryOptions,
|
||||||
} from '@/queries/overview'
|
} from '@/queries/overview'
|
||||||
import { settingsQueryOptions } from '@/queries/settings'
|
import { settingsQueryOptions } from '@/queries/settings'
|
||||||
@@ -36,11 +29,12 @@ function parseShowQuickActions(value: unknown): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dashboard — OpsDashboard kit (KPI → QuickActions → modules → activity/health → queue).
|
* Dashboard — KPI infographic + Quick Actions + charts (no list duplicates).
|
||||||
* Charts slot is a vertical stack: modules stay full-width; activity shares a row
|
|
||||||
* with BGP widgets only at @5xl (container), never nested 8+4 inside a 2-col parent.
|
|
||||||
* @see https://reui.io/preview/base/dashboard-1
|
* @see https://reui.io/preview/base/dashboard-1
|
||||||
* @see https://reui.io/preview/base/stats-12
|
* @see https://reui.io/preview/base/stats-12
|
||||||
|
* @see https://reui.io/preview/base/stats-4
|
||||||
|
* @see https://reui.io/preview/base/card-12
|
||||||
|
* @see https://reui.io/preview/base/chart-27
|
||||||
*/
|
*/
|
||||||
function DashboardComponent() {
|
function DashboardComponent() {
|
||||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
|
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
|
||||||
@@ -52,14 +46,13 @@ function DashboardComponent() {
|
|||||||
overviewModulesQueryOptions(),
|
overviewModulesQueryOptions(),
|
||||||
overviewPeersQueryOptions(),
|
overviewPeersQueryOptions(),
|
||||||
overviewSpeakersQueryOptions(),
|
overviewSpeakersQueryOptions(),
|
||||||
overviewRevisionsQueryOptions(),
|
|
||||||
overviewJobsQueryOptions(),
|
overviewJobsQueryOptions(),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
const [modulesQ, peersQ, speakersQ, revisionsQ, jobsQ] = results
|
const [modulesQ, peersQ, speakersQ, jobsQ] = results
|
||||||
const initialLoading =
|
const initialLoading =
|
||||||
modulesQ.isLoading || peersQ.isLoading || speakersQ.isLoading || revisionsQ.isLoading || jobsQ.isLoading
|
modulesQ.isLoading || peersQ.isLoading || speakersQ.isLoading || jobsQ.isLoading
|
||||||
const refreshing = results.some((r) => r.isFetching && !r.isLoading)
|
const refreshing = results.some((r) => r.isFetching && !r.isLoading)
|
||||||
|
|
||||||
if (!lastUpdated && !initialLoading && results.every((r) => r.isSuccess || r.isError)) {
|
if (!lastUpdated && !initialLoading && results.every((r) => r.isSuccess || r.isError)) {
|
||||||
@@ -74,11 +67,7 @@ function DashboardComponent() {
|
|||||||
const modules = modulesQ.data?.items ?? []
|
const modules = modulesQ.data?.items ?? []
|
||||||
const peers = peersQ.data?.items ?? []
|
const peers = peersQ.data?.items ?? []
|
||||||
const speakers = speakersQ.data?.items ?? []
|
const speakers = speakersQ.data?.items ?? []
|
||||||
const revisions = revisionsQ.data?.items ?? []
|
|
||||||
const jobs = jobsQ.data?.items ?? []
|
const jobs = jobsQ.data?.items ?? []
|
||||||
const nameById = moduleNameById(modules)
|
|
||||||
|
|
||||||
const activityLoading = refreshing && jobs.length === 0 && revisions.length === 0
|
|
||||||
const kpiCards = buildDashboardKpiCards({ modules, peers, speakers, jobs })
|
const kpiCards = buildDashboardKpiCards({ modules, peers, speakers, jobs })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -99,38 +88,18 @@ function DashboardComponent() {
|
|||||||
isLoading={initialLoading}
|
isLoading={initialLoading}
|
||||||
afterKpi={showQuickActions ? <DashboardQuickLinks /> : null}
|
afterKpi={showQuickActions ? <DashboardQuickLinks /> : null}
|
||||||
charts={
|
charts={
|
||||||
<>
|
<div className={chartPanelGridClassName}>
|
||||||
<div className="min-w-0">
|
<DashboardNetworkHealth
|
||||||
<DashboardModulesGrid modules={modules} isLoading={refreshing} />
|
|
||||||
</div>
|
|
||||||
<div className={dashboardMainSidebarClassName}>
|
|
||||||
<DashboardActivityTimeline
|
|
||||||
jobs={jobs}
|
|
||||||
revisions={revisions}
|
|
||||||
peers={peers}
|
peers={peers}
|
||||||
speakers={speakers}
|
speakers={speakers}
|
||||||
|
jobs={jobs}
|
||||||
|
loading={refreshing && peers.length === 0 && speakers.length === 0}
|
||||||
|
/>
|
||||||
|
<DashboardOperationsBreakdown
|
||||||
|
jobs={jobs}
|
||||||
|
modules={modules}
|
||||||
|
loading={refreshing && jobs.length === 0 && modules.length === 0}
|
||||||
/>
|
/>
|
||||||
<div className="grid min-w-0 items-start gap-4">
|
|
||||||
<DashboardNetworkHealth peers={peers} speakers={speakers} jobs={jobs} />
|
|
||||||
<DashboardOperationsBreakdown jobs={jobs} modules={modules} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
queueTitle="Задачи и ревизии"
|
|
||||||
queueDescription="Последние фоновые операции и история конфигураций"
|
|
||||||
queue={
|
|
||||||
<div className={chartPanelGridClassName}>
|
|
||||||
{activityLoading ? (
|
|
||||||
<Skeleton className="m-4 h-24 w-auto" />
|
|
||||||
) : (
|
|
||||||
<DashboardRecentJobsGrid jobs={jobs.slice(0, 8)} nameById={nameById} isLoading={refreshing} />
|
|
||||||
)}
|
|
||||||
{activityLoading ? (
|
|
||||||
<Skeleton className="m-4 h-24 w-auto" />
|
|
||||||
) : (
|
|
||||||
<DashboardRecentRevisionsGrid revisions={revisions} isLoading={refreshing} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ function DirectoriesComponent() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'DoH профили',
|
label: 'DoH-профили',
|
||||||
value: dohProfiles.length,
|
value: dohProfiles.length,
|
||||||
icon: <Globe className="size-4" />,
|
icon: <Globe className="size-4" />,
|
||||||
footer: (
|
footer: (
|
||||||
@@ -137,7 +137,7 @@ function DirectoriesComponent() {
|
|||||||
defaultValue="communities"
|
defaultValue="communities"
|
||||||
items={[
|
items={[
|
||||||
{ value: 'communities', label: 'BGP community', count: communities.length },
|
{ value: 'communities', label: 'BGP community', count: communities.length },
|
||||||
{ value: 'doh', label: 'DoH профили', count: dohProfiles.length, badgeVariant: 'info-light' },
|
{ value: 'doh', label: 'DoH-профили', count: dohProfiles.length, badgeVariant: 'info-light' },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<TabsContent value="communities" className="mt-0">
|
<TabsContent value="communities" className="mt-0">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'
|
|||||||
import { Bird, RefreshCw } from 'lucide-react'
|
import { Bird, RefreshCw } from 'lucide-react'
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import { Progress } from '@evobgp/ui/components/progress'
|
||||||
import { MonitoringHealthCard } from '@/components/analytics'
|
import { MonitoringHealthCard } from '@/components/analytics'
|
||||||
import { MonitoringReadyGrid } from '@/components/monitoring/monitoring-ready-grid'
|
import { MonitoringReadyGrid } from '@/components/monitoring/monitoring-ready-grid'
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
@@ -164,14 +165,17 @@ function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{ratio !== null ? (
|
{ratio !== null ? (
|
||||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
<Progress
|
||||||
<div
|
value={ratio}
|
||||||
className={`h-full rounded-full transition-all ${
|
aria-label="Установлено BGP-сессий"
|
||||||
ratio >= 100 ? 'bg-success' : ratio >= 50 ? 'bg-warning' : 'bg-destructive'
|
className={
|
||||||
}`}
|
ratio >= 100
|
||||||
style={{ width: `${ratio}%` }}
|
? '[&_[data-slot=progress-indicator]]:bg-success'
|
||||||
|
: ratio >= 50
|
||||||
|
? '[&_[data-slot=progress-indicator]]:bg-warning'
|
||||||
|
: '[&_[data-slot=progress-indicator]]:bg-destructive'
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
{bird.error ? <p className="text-xs text-destructive">{bird.error}</p> : null}
|
{bird.error ? <p className="text-xs text-destructive">{bird.error}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { toast } from 'sonner'
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import { Field, FieldLabel } from '@evobgp/ui/components/field'
|
||||||
import { SegmentedTabs, TabsContent } from '@/components/segmented-tabs'
|
import { SegmentedTabs, TabsContent } from '@/components/segmented-tabs'
|
||||||
import {
|
import {
|
||||||
FrameSection,
|
FrameSection,
|
||||||
@@ -14,6 +15,14 @@ import {
|
|||||||
type QuickActionItem,
|
type QuickActionItem,
|
||||||
} from '@/components/reui-kit'
|
} from '@/components/reui-kit'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import {
|
||||||
|
CodeBlock,
|
||||||
|
CodeBlockCopyButton,
|
||||||
|
CodeBlockExpandButton,
|
||||||
|
CodeBlockHeader,
|
||||||
|
CodeBlockTitle,
|
||||||
|
} from '@/components/reui/code-block/code-block'
|
||||||
|
import type { CodeBlockDiffSpec } from '@/components/reui/code-block/code-block-highlight'
|
||||||
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'
|
||||||
@@ -268,35 +277,31 @@ function DiffTab({ revisions }: { revisions: RevisionRow[] }) {
|
|||||||
const [b, setB] = useState('')
|
const [b, setB] = useState('')
|
||||||
const diffQ = useQuery(operationsDiffQueryOptions(a, b))
|
const diffQ = useQuery(operationsDiffQueryOptions(a, b))
|
||||||
const revisionItems = useMemo(
|
const revisionItems = useMemo(
|
||||||
() =>
|
() => revisions.map((r) => ({ value: r.id, label: r.id })),
|
||||||
revisions.map((r) => ({
|
|
||||||
value: r.id,
|
|
||||||
label: `${r.id.slice(0, 12)}…`,
|
|
||||||
})),
|
|
||||||
[revisions],
|
[revisions],
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FrameSection title="Сравнение ревизий" contentClassName="flex flex-col gap-4 py-4">
|
<FrameSection title="Сравнение ревизий" contentClassName="flex flex-col gap-4 px-5 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">
|
<Field className="w-full max-w-xs">
|
||||||
<span className="text-xs text-muted-foreground">Ревизия A</span>
|
<FieldLabel>Ревизия A</FieldLabel>
|
||||||
<SelectMenu
|
<SelectMenu
|
||||||
items={revisionItems}
|
items={revisionItems}
|
||||||
value={a}
|
value={a}
|
||||||
placeholder="Выберите"
|
placeholder="Выберите"
|
||||||
onValueChange={(v) => v && setA(v)}
|
onValueChange={(v) => v && setA(v)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
<div className="flex w-full max-w-xs flex-col gap-1">
|
<Field className="w-full max-w-xs">
|
||||||
<span className="text-xs text-muted-foreground">Ревизия B</span>
|
<FieldLabel>Ревизия B</FieldLabel>
|
||||||
<SelectMenu
|
<SelectMenu
|
||||||
items={revisionItems}
|
items={revisionItems}
|
||||||
value={b}
|
value={b}
|
||||||
placeholder="Выберите"
|
placeholder="Выберите"
|
||||||
onValueChange={(v) => v && setB(v)}
|
onValueChange={(v) => v && setB(v)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
<Button onClick={() => diffQ.refetch()} disabled={!a || !b || diffQ.isFetching}>
|
<Button onClick={() => diffQ.refetch()} disabled={!a || !b || diffQ.isFetching}>
|
||||||
Сравнить
|
Сравнить
|
||||||
</Button>
|
</Button>
|
||||||
@@ -320,20 +325,49 @@ function DiffTab({ revisions }: { revisions: RevisionRow[] }) {
|
|||||||
function DiffView({ diff }: { diff: import('@/types/api').RevisionDiff }) {
|
function DiffView({ diff }: { diff: import('@/types/api').RevisionDiff }) {
|
||||||
const added = diff.prefixes?.added ?? (diff.added as string[]) ?? []
|
const added = diff.prefixes?.added ?? (diff.added as string[]) ?? []
|
||||||
const removed = diff.prefixes?.removed ?? (diff.removed as string[]) ?? []
|
const removed = diff.prefixes?.removed ?? (diff.removed as string[]) ?? []
|
||||||
|
|
||||||
|
if (added.length === 0 && removed.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<p className="text-muted-foreground text-sm">Различий между ревизиями нет.</p>
|
||||||
<div>
|
)
|
||||||
<p className="mb-2 text-sm font-medium text-success">Добавлено: {added.length}</p>
|
}
|
||||||
<pre className="max-h-80 overflow-auto rounded-md border bg-muted/40 p-3 font-mono text-xs">
|
|
||||||
{added.join('\n')}
|
// ReUI CodeBlock diff: добавленные строки идут первыми, удалённые следом,
|
||||||
</pre>
|
// диапазоны размечают их (+/− канал и зелёный/красный фон строк).
|
||||||
</div>
|
const code = [...added, ...removed].join('\n')
|
||||||
<div>
|
const diffSpec: CodeBlockDiffSpec = {
|
||||||
<p className="mb-2 text-sm font-medium text-destructive">Удалено: {removed.length}</p>
|
...(added.length > 0 ? { added: `1-${added.length}` } : {}),
|
||||||
<pre className="max-h-80 overflow-auto rounded-md border bg-muted/40 p-3 font-mono text-xs">
|
...(removed.length > 0
|
||||||
{removed.join('\n')}
|
? { removed: `${added.length + 1}-${added.length + removed.length}` }
|
||||||
</pre>
|
: {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="success-light" size="sm">
|
||||||
|
Добавлено: {added.length}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="destructive-light" size="sm">
|
||||||
|
Удалено: {removed.length}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
<CodeBlock
|
||||||
|
code={code}
|
||||||
|
language="json"
|
||||||
|
showLineNumbers
|
||||||
|
diff={diffSpec}
|
||||||
|
maxLines={24}
|
||||||
|
label="Различия ревизий"
|
||||||
|
>
|
||||||
|
<CodeBlockHeader>
|
||||||
|
<CodeBlockTitle className="flex-1">
|
||||||
|
{`${diff.revision_a ?? '—'} → ${diff.revision_b ?? '—'}`}
|
||||||
|
</CodeBlockTitle>
|
||||||
|
<CodeBlockCopyButton />
|
||||||
|
<CodeBlockExpandButton>Показать всё</CodeBlockExpandButton>
|
||||||
|
</CodeBlockHeader>
|
||||||
|
</CodeBlock>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
|
|||||||
import { ScheduleJobsCard } from '@/components/schedule/schedule-jobs-card'
|
import { ScheduleJobsCard } from '@/components/schedule/schedule-jobs-card'
|
||||||
import { ScheduleModulesGrid } from '@/components/schedule/schedule-modules-grid'
|
import { ScheduleModulesGrid } from '@/components/schedule/schedule-modules-grid'
|
||||||
import { apiMutate } from '@/lib/api-client'
|
import { apiMutate } from '@/lib/api-client'
|
||||||
|
import { moduleNameById } from '@/queries/overview'
|
||||||
import { modulesListQueryOptions } from '@/queries/modules'
|
import { modulesListQueryOptions } from '@/queries/modules'
|
||||||
import { operationsJobsQueryOptions } from '@/queries/operations'
|
import { operationsJobsQueryOptions } from '@/queries/operations'
|
||||||
|
|
||||||
@@ -117,6 +118,7 @@ function ScheduleComponent() {
|
|||||||
|
|
||||||
<ScheduleJobsCard
|
<ScheduleJobsCard
|
||||||
jobs={jobs}
|
jobs={jobs}
|
||||||
|
nameById={moduleNameById(modules)}
|
||||||
isLoading={jobsQ.isLoading}
|
isLoading={jobsQ.isLoading}
|
||||||
isError={jobsQ.isError}
|
isError={jobsQ.isError}
|
||||||
error={jobsQ.error}
|
error={jobsQ.error}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -168,6 +168,16 @@ http://localhost:5173,http://127.0.0.1:5173,https://ui.example.com
|
|||||||
|
|
||||||
Разрешённые заголовки включают `Authorization`, `Content-Type`, `Idempotency-Key`, `Accept`, `X-Tenant-Id` (см. `internal/httpapi/cors.go`).
|
Разрешённые заголовки включают `Authorization`, `Content-Type`, `Idempotency-Key`, `Accept`, `X-Tenant-Id` (см. `internal/httpapi/cors.go`).
|
||||||
|
|
||||||
|
## Pipeline: TTL и внешние вызовы
|
||||||
|
|
||||||
|
| Переменная | Назначение |
|
||||||
|
|------------|------------|
|
||||||
|
| `EVOBGP_ASN_CACHE_TTL_SEC` | TTL кэша объявленных префиксов RIPEstat (default 1800) |
|
||||||
|
| `EVOBGP_ASN_HOLDER_TTL_SEC` | TTL имени holder AS (default 7 суток) |
|
||||||
|
| `EVOBGP_CDN_DNS_CACHE_TTL_SEC` | TTL DNS при проверке CDN URL (default 300) |
|
||||||
|
| `EVOBGP_DOMAIN_CACHE_TTL_SEC` | TTL кэша DoH A/AAAA (default 300) |
|
||||||
|
| `EVOBGP_CDN_PARTIAL_OK` | Сохранять prior-строки skipped CDN-источников при частичном сбое |
|
||||||
|
|
||||||
## Заголовок `X-Tenant-Id` (решение: не реализован)
|
## Заголовок `X-Tenant-Id` (решение: не реализован)
|
||||||
|
|
||||||
**Решение (done):** заголовок **`X-Tenant-Id` не переключает tenant** в handlers и **не планируется** без отдельного ADR на супер-роли.
|
**Решение (done):** заголовок **`X-Tenant-Id` не переключает tenant** в handlers и **не планируется** без отдельного ADR на супер-роли.
|
||||||
|
|||||||
+12
-1
@@ -39,7 +39,7 @@
|
|||||||
| `config` | Переменные окружения `EVOBGP_*`. |
|
| `config` | Переменные окружения `EVOBGP_*`. |
|
||||||
| `observability` | Метрики Prometheus, HTTP middleware. |
|
| `observability` | Метрики Prometheus, HTTP middleware. |
|
||||||
| `broker` | Опциональный `EVOBGP_BROKER_URL` для будущей шины; сейчас задачи только in-process (`jobs.Registry`), пакет лишь логирует факт настройки URL. |
|
| `broker` | Опциональный `EVOBGP_BROKER_URL` для будущей шины; сейчас задачи только in-process (`jobs.Registry`), пакет лишь логирует факт настройки URL. |
|
||||||
| `pipeline` | Ingest+render в одном шаге для `module_refresh`: выборка префиксов (CDN/AS/IP/пустые DOMAINS), `CreateRenderRevision`, превью BIRD через `birdfmt`. |
|
| `pipeline` | Ingest+render для `module_refresh`: CDN/AS/IP/DOMAINS → `module_prefix_snapshot` (batch `COPY`, per-module lock) → агрегация CIDR O(n log n) → `CreateRenderRevision`. Fast-path снапшота — `module.input_hash`; DoH — TTL-кэш `domain_resolve_cache`; scheduler — jitter границ интервала. |
|
||||||
| `nodedispatch` | Panel→Node HTTP wake-up (`POST /v1/agent/sync`) после `deploy_apply`. |
|
| `nodedispatch` | Panel→Node HTTP wake-up (`POST /v1/agent/sync`) после `deploy_apply`. |
|
||||||
| `agentserver` | HTTP API на реплике (`serve`): sync + health для Traefik; опционально firewall failover (`/v1/firewall/*`). |
|
| `agentserver` | HTTP API на реплике (`serve`): sync + health для Traefik; опционально firewall failover (`/v1/firewall/*`). |
|
||||||
| `firewall` | Вычисление policy block/accept → плоский CIDR blocklist. |
|
| `firewall` | Вычисление policy block/accept → плоский CIDR blocklist. |
|
||||||
@@ -98,6 +98,17 @@ flowchart LR
|
|||||||
|
|
||||||
Профиль Compose **`microvps-full`** добавляет к этому стеку **Web UI** (nginx → `evobgp-all`), **NATS** и **Prometheus** без отдельных контейнеров воркеров (функционально то же, что отдельные `scheduler`/`ingest`/… в reference). Запуск и лимиты под ~1 ГиБ RAM — в [quickstart.md](quickstart.md).
|
Профиль Compose **`microvps-full`** добавляет к этому стеку **Web UI** (nginx → `evobgp-all`), **NATS** и **Prometheus** без отдельных контейнеров воркеров (функционально то же, что отдельные `scheduler`/`ingest`/… в reference). Запуск и лимиты под ~1 ГиБ RAM — в [quickstart.md](quickstart.md).
|
||||||
|
|
||||||
|
## Поток pipeline (ingest → render)
|
||||||
|
|
||||||
|
1. **Scheduler** (`evobgp-scheduler` / in-process в `evobgp-all`) ставит `tenant_refresh`, если `ModuleDueForScheduler`: граница окна `refresh_interval_sec` со **сдвигом `fnv32(module.ID) % interval`**, чтобы модули с одним интервалом не били внешние API одновременно.
|
||||||
|
2. **Ingest** (`RefreshModuleIngest`):
|
||||||
|
- `AS_PREFIXES` — RIPEstat prefixes + holder параллельно, кэш `asn_prefix_cache`; дедуп строк по `prefix + community`.
|
||||||
|
- `CDN_CIDRS` — единый `fetchCDNSourceRows` (conditional GET); prefetch уважает `RefreshIntervalSec`; merge снапшота под `LockModuleSnapshot` (пропущенные по ошибке источники сохраняют prior-строки при `EVOBGP_CDN_PARTIAL_OK`).
|
||||||
|
- `DOMAINS` — DoH A+AAAA параллельно; попадания в `domain_resolve_cache` с TTL `EVOBGP_DOMAIN_CACHE_TTL_SEC` (default 300).
|
||||||
|
- `IP_RANGES` — напрямую из записей модуля.
|
||||||
|
3. Снапшот пишется **batch** (`pgx.CopyFrom` в PostgreSQL). Совпадение `module.input_hash` со снапшотом — O(1) пропуск повторного ingest при render; CRUD entries обнуляет hash.
|
||||||
|
4. **Render** (`RenderTenantRevision`): `smartAggregatePrefixRows` (стек-схлопывание O(n log n), IPv6 без `math/big`) → ревизия, если набор префиксов изменился.
|
||||||
|
|
||||||
## Поток: ревизия и бандл для ноды
|
## Поток: ревизия и бандл для ноды
|
||||||
|
|
||||||
1. Оператор (роль `operator` или выше по политике) изменяет модули и запускает цепочку, приводящую к новой **ревизии** (часть шагов может быть асинхронной через jobs — см. OpenAPI).
|
1. Оператор (роль `operator` или выше по политике) изменяет модули и запускает цепочку, приводящую к новой **ревизии** (часть шагов может быть асинхронной через jobs — см. OpenAPI).
|
||||||
|
|||||||
@@ -12,6 +12,9 @@
|
|||||||
- `EVOBGP_CORS_ORIGINS` — явный whitelist origin веб-панели.
|
- `EVOBGP_CORS_ORIGINS` — явный whitelist origin веб-панели.
|
||||||
- `EVOBGP_STALE_ON_UPSTREAM_ERROR=1` (по умолчанию) — stale snapshot при сбоях CDN/ASN/DoH.
|
- `EVOBGP_STALE_ON_UPSTREAM_ERROR=1` (по умолчанию) — stale snapshot при сбоях CDN/ASN/DoH.
|
||||||
- Опционально `EVOBGP_CDN_PARTIAL_OK=1` — при сбое одного CDN source без stale cache продолжать refresh остальных (иначе fail модуля).
|
- Опционально `EVOBGP_CDN_PARTIAL_OK=1` — при сбое одного CDN source без stale cache продолжать refresh остальных (иначе fail модуля).
|
||||||
|
- `EVOBGP_ASN_HOLDER_TTL_SEC` — TTL имени holder AS (default 7 суток); prefixes кэшируются отдельно (`EVOBGP_ASN_CACHE_TTL_SEC`, default 1800).
|
||||||
|
- `EVOBGP_CDN_DNS_CACHE_TTL_SEC` — TTL кэша DNS при SSRF-проверке CDN URL (default 300).
|
||||||
|
- `EVOBGP_DOMAIN_CACHE_TTL_SEC` — TTL кэша DoH A/AAAA (`domain_resolve_cache`, default 300).
|
||||||
|
|
||||||
## Рекомендуется
|
## Рекомендуется
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,7 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"evobgp/internal/httpclient"
|
"evobgp/internal/httpclient"
|
||||||
)
|
)
|
||||||
@@ -137,22 +135,3 @@ func truncateForErr(b []byte, n int) string {
|
|||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// PolitePause is a short delay between upstream ASN lookups (same refresh).
|
|
||||||
func PolitePause() {
|
|
||||||
d := 150 * time.Millisecond
|
|
||||||
if s := strings.TrimSpace(os.Getenv("EVOBGP_ASN_RESOLVE_PAUSE_MS")); s != "" {
|
|
||||||
if ms, err := parsePositiveInt(s); err == nil && ms > 0 {
|
|
||||||
d = time.Duration(ms) * time.Millisecond
|
|
||||||
}
|
|
||||||
}
|
|
||||||
time.Sleep(d)
|
|
||||||
}
|
|
||||||
|
|
||||||
func parsePositiveInt(s string) (int, error) {
|
|
||||||
n, err := strconv.Atoi(s)
|
|
||||||
if err != nil || n <= 0 {
|
|
||||||
return 0, fmt.Errorf("invalid")
|
|
||||||
}
|
|
||||||
return n, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -448,7 +448,10 @@ func (s *Server) handleModuleRefresh(w http.ResponseWriter, r *http.Request) {
|
|||||||
idemPtr = &idem
|
idemPtr = &idem
|
||||||
}
|
}
|
||||||
mid := mod.ID
|
mid := mod.ID
|
||||||
j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindModuleRefresh, idemPtr, &mid, map[string]any{"module_id": moduleID})
|
j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindModuleRefresh, idemPtr, &mid, map[string]any{
|
||||||
|
"module_id": moduleID,
|
||||||
|
"trigger": "api",
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeInternalError(w, "internal", err)
|
writeInternalError(w, "internal", err)
|
||||||
return
|
return
|
||||||
@@ -751,6 +754,7 @@ func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) {
|
|||||||
j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindDeployApply, idemPtr, nil, map[string]any{
|
j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindDeployApply, idemPtr, nil, map[string]any{
|
||||||
"revision_id": revID,
|
"revision_id": revID,
|
||||||
"strategy": body.Strategy,
|
"strategy": body.Strategy,
|
||||||
|
"trigger": "api",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeInternalError(w, "internal", err)
|
writeInternalError(w, "internal", err)
|
||||||
@@ -801,6 +805,7 @@ func (s *Server) handleSpeakerApply(w http.ResponseWriter, r *http.Request) {
|
|||||||
j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindDeployApply, idemPtr, nil, map[string]any{
|
j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindDeployApply, idemPtr, nil, map[string]any{
|
||||||
"revision_id": revID,
|
"revision_id": revID,
|
||||||
"speaker_id": spkID,
|
"speaker_id": spkID,
|
||||||
|
"trigger": "api",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeInternalError(w, "internal", err)
|
writeInternalError(w, "internal", err)
|
||||||
|
|||||||
@@ -436,9 +436,11 @@ func (w *Worker) enqueueDeployAllSpeakers(j *Job, tenantID, revID string) {
|
|||||||
if revID == "" {
|
if revID == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
applyJob, _, err := w.Registry.Enqueue(tenantID, KindDeployApply, nil, nil, map[string]any{
|
meta := map[string]any{"revision_id": revID}
|
||||||
"revision_id": revID,
|
if tr := strings.TrimSpace(j.metaString("trigger")); tr != "" {
|
||||||
})
|
meta["trigger"] = tr
|
||||||
|
}
|
||||||
|
applyJob, _, err := w.Registry.Enqueue(tenantID, KindDeployApply, nil, nil, meta)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
j.mergeMeta(map[string]any{"deploy_apply_enqueue_error": err.Error()})
|
j.mergeMeta(map[string]any{"deploy_apply_enqueue_error": err.Error()})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package pipeline
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"evobgp/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Prefix collapse replaces the former prune+merge fixed-point loop (O(n²) per pass,
|
||||||
|
// multiple passes) with a single sort + linear stack pass: O(n log n) overall.
|
||||||
|
//
|
||||||
|
// Classic trie-collapse without building a trie:
|
||||||
|
// 1. Sort prefixes by (address, mask len ascending).
|
||||||
|
// 2. Walk left to right keeping a stack of "open" prefixes:
|
||||||
|
// - while the stack top contains the new prefix -> the top covers it, drop the new one (prune);
|
||||||
|
// - else, while the stack top is a sibling of the new prefix (same mask, XOR of the
|
||||||
|
// network bit equals the parent block) and merging is allowed for that mask length
|
||||||
|
// -> pop the sibling, replace the new prefix with the parent and re-check;
|
||||||
|
// - otherwise push the new prefix.
|
||||||
|
//
|
||||||
|
// A parent absorbs its sibling children only when both halves are present, which matches
|
||||||
|
// the previous merge-to-fixed-point semantics, including the guard that forbids merging
|
||||||
|
// above a floor mask (IPv4 /8, IPv6 /16).
|
||||||
|
|
||||||
|
type collapseItem struct {
|
||||||
|
row store.PrefixRow
|
||||||
|
pfx netip.Prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
// collapsePrefixGroup collapses one (community, source) group of same-family prefixes.
|
||||||
|
// All rows must be masked; family and floor are enforced by minBits.
|
||||||
|
func collapsePrefixGroup(rows []store.PrefixRow, is4 bool) []store.PrefixRow {
|
||||||
|
if len(rows) <= 1 {
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
items := make([]collapseItem, 0, len(rows))
|
||||||
|
seen := make(map[string]struct{}, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
if _, dup := seen[row.Prefix]; dup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[row.Prefix] = struct{}{}
|
||||||
|
pfx, err := netip.ParsePrefix(row.Prefix)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pfx = pfx.Masked()
|
||||||
|
if is4 != pfx.Addr().Is4() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, collapseItem{row: row, pfx: pfx})
|
||||||
|
}
|
||||||
|
if len(items) <= 1 {
|
||||||
|
out := make([]store.PrefixRow, 0, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
out = append(out, it.row)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by (address, mask len): a parent always sorts before its children, and a
|
||||||
|
// shorter sibling sorts before a longer one within the same parent block.
|
||||||
|
sort.Slice(items, func(i, j int) bool {
|
||||||
|
a, b := items[i].pfx, items[j].pfx
|
||||||
|
if a.Addr() != b.Addr() {
|
||||||
|
return lessAddr(a.Addr(), b.Addr())
|
||||||
|
}
|
||||||
|
return a.Bits() < b.Bits()
|
||||||
|
})
|
||||||
|
|
||||||
|
minBits := 8
|
||||||
|
if !is4 {
|
||||||
|
minBits = 16
|
||||||
|
}
|
||||||
|
|
||||||
|
stack := make([]collapseItem, 0, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
cur := it
|
||||||
|
dropped := false
|
||||||
|
for len(stack) > 0 {
|
||||||
|
top := stack[len(stack)-1]
|
||||||
|
if top.pfx.Contains(cur.pfx.Addr()) && top.pfx.Bits() <= cur.pfx.Bits() {
|
||||||
|
// Covered by an existing prefix: drop (prune).
|
||||||
|
dropped = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if cur.pfx.Bits() == top.pfx.Bits() && cur.pfx.Bits() > minBits && areSiblings(top.pfx, cur.pfx) {
|
||||||
|
// Merge siblings into the parent (parent keeps the lower sibling's attributes),
|
||||||
|
// then re-check the parent against the new stack top.
|
||||||
|
stack = stack[:len(stack)-1]
|
||||||
|
parentBits := cur.pfx.Bits() - 1
|
||||||
|
parent := netip.PrefixFrom(maskAddr(cur.pfx.Addr(), parentBits, is4), parentBits).Masked()
|
||||||
|
cur = collapseItem{row: top.row, pfx: parent}
|
||||||
|
cur.row.Prefix = parent.String()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !dropped {
|
||||||
|
stack = append(stack, cur)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]store.PrefixRow, 0, len(stack))
|
||||||
|
for _, it := range stack {
|
||||||
|
out = append(out, it.row)
|
||||||
|
}
|
||||||
|
sortPrefixRows(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// areSiblings reports whether two same-length prefixes combine into their common parent.
|
||||||
|
func areSiblings(a, b netip.Prefix) bool {
|
||||||
|
if a.Bits() != b.Bits() || a.Bits() == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
parentBits := a.Bits() - 1
|
||||||
|
pa := maskAddr(a.Addr(), parentBits, a.Addr().Is4())
|
||||||
|
pb := maskAddr(b.Addr(), parentBits, b.Addr().Is4())
|
||||||
|
return pa == pb
|
||||||
|
}
|
||||||
|
|
||||||
|
// maskAddr clears the host bits below prefixLen (IPv4: 32-bit space; IPv6: 128-bit).
|
||||||
|
func maskAddr(a netip.Addr, prefixLen int, is4 bool) netip.Addr {
|
||||||
|
if is4 {
|
||||||
|
v := uint32FromIPv4(a)
|
||||||
|
if prefixLen <= 0 {
|
||||||
|
v = 0
|
||||||
|
} else if prefixLen < 32 {
|
||||||
|
v &= ^(uint32(1)<<(32-prefixLen) - 1)
|
||||||
|
}
|
||||||
|
return u32ToIPv4(v)
|
||||||
|
}
|
||||||
|
b := a.As16()
|
||||||
|
hostBits := 128 - prefixLen
|
||||||
|
fullBytes := hostBits / 8
|
||||||
|
for i := 15; i > 15-fullBytes; i-- {
|
||||||
|
b[i] = 0
|
||||||
|
}
|
||||||
|
if rem := hostBits % 8; rem > 0 {
|
||||||
|
idx := 15 - fullBytes
|
||||||
|
if idx >= 0 && idx < 16 {
|
||||||
|
b[idx] &= byte(0xFF << rem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return netip.AddrFrom16(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func uint32FromIPv4(a netip.Addr) uint32 {
|
||||||
|
o := a.As4()
|
||||||
|
return uint32(o[0])<<24 | uint32(o[1])<<16 | uint32(o[2])<<8 | uint32(o[3])
|
||||||
|
}
|
||||||
|
|
||||||
|
func u32ToIPv4(v uint32) netip.Addr {
|
||||||
|
return netip.AddrFrom4([4]byte{byte(v >> 24), byte(v >> 16), byte(v >> 8), byte(v)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func lessAddr(a, b netip.Addr) bool {
|
||||||
|
if a.Is4() != b.Is4() {
|
||||||
|
return a.Is4()
|
||||||
|
}
|
||||||
|
if a.Is4() {
|
||||||
|
return uint32FromIPv4(a) < uint32FromIPv4(b)
|
||||||
|
}
|
||||||
|
a16, b16 := a.As16(), b.As16()
|
||||||
|
for i := 0; i < 16; i++ {
|
||||||
|
if a16[i] != b16[i] {
|
||||||
|
return a16[i] < b16[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
package pipeline
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"math/rand"
|
||||||
|
"net/netip"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"evobgp/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The functions below are a verbatim copy of the pre-2.1 prune+merge loop
|
||||||
|
// (from git HEAD internal/pipeline/refresh.go). They exist only as an
|
||||||
|
// equivalence oracle for collapsePrefixGroup.
|
||||||
|
|
||||||
|
func legacyAggregateCIDRGroup(rows []store.PrefixRow, mergeFn func(map[string]store.PrefixRow) bool) []store.PrefixRow {
|
||||||
|
if len(rows) <= 1 {
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
set := make(map[string]store.PrefixRow, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
set[row.Prefix] = row
|
||||||
|
}
|
||||||
|
legacyPruneCoveredPrefixes(set)
|
||||||
|
for {
|
||||||
|
if !mergeFn(set) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
legacyPruneCoveredPrefixes(set)
|
||||||
|
}
|
||||||
|
out := make([]store.PrefixRow, 0, len(set))
|
||||||
|
for _, row := range set {
|
||||||
|
out = append(out, row)
|
||||||
|
}
|
||||||
|
sortPrefixRows(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func legacyPruneCoveredPrefixes(set map[string]store.PrefixRow) {
|
||||||
|
type item struct {
|
||||||
|
key string
|
||||||
|
pfx netip.Prefix
|
||||||
|
bits int
|
||||||
|
}
|
||||||
|
items := make([]item, 0, len(set))
|
||||||
|
for k := range set {
|
||||||
|
p, err := netip.ParsePrefix(k)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, item{key: k, pfx: p, bits: p.Bits()})
|
||||||
|
}
|
||||||
|
sort.Slice(items, func(i, j int) bool {
|
||||||
|
if items[i].bits != items[j].bits {
|
||||||
|
return items[i].bits < items[j].bits
|
||||||
|
}
|
||||||
|
return items[i].key < items[j].key
|
||||||
|
})
|
||||||
|
for i := 0; i < len(items); i++ {
|
||||||
|
for j := i + 1; j < len(items); j++ {
|
||||||
|
if items[j].bits <= items[i].bits {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if items[i].pfx.Contains(items[j].pfx.Addr()) {
|
||||||
|
delete(set, items[j].key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func legacyMergeSiblingPrefixesIPv4(set map[string]store.PrefixRow) bool {
|
||||||
|
merged := false
|
||||||
|
seen := make(map[string]struct{}, len(set))
|
||||||
|
for key, row := range set {
|
||||||
|
if _, done := seen[key]; done {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pfx, err := netip.ParsePrefix(key)
|
||||||
|
if err != nil || !pfx.Addr().Is4() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bits := pfx.Bits()
|
||||||
|
if bits <= 8 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
netNum := ipv4PrefixNetworkLegacy(pfx)
|
||||||
|
blockSize := uint32(1) << (32 - bits)
|
||||||
|
siblingNet := netNum ^ blockSize
|
||||||
|
siblingPfx := netip.PrefixFrom(u32ToIPv4(siblingNet), bits).Masked().String()
|
||||||
|
if _, ok := set[siblingPfx]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parentBits := bits - 1
|
||||||
|
parentBlock := uint32(1) << (32 - parentBits)
|
||||||
|
parentNet := netNum & ^(parentBlock - 1)
|
||||||
|
parentPfx := netip.PrefixFrom(u32ToIPv4(parentNet), parentBits).Masked().String()
|
||||||
|
delete(set, key)
|
||||||
|
delete(set, siblingPfx)
|
||||||
|
parentRow := row
|
||||||
|
parentRow.Prefix = parentPfx
|
||||||
|
set[parentPfx] = parentRow
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
seen[siblingPfx] = struct{}{}
|
||||||
|
merged = true
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
func ipv4PrefixNetworkLegacy(p netip.Prefix) uint32 {
|
||||||
|
a := p.Masked().Addr().As4()
|
||||||
|
return uint32(a[0])<<24 | uint32(a[1])<<16 | uint32(a[2])<<8 | uint32(a[3])
|
||||||
|
}
|
||||||
|
|
||||||
|
func legacyMergeSiblingPrefixesIPv6(set map[string]store.PrefixRow) bool {
|
||||||
|
merged := false
|
||||||
|
seen := make(map[string]struct{}, len(set))
|
||||||
|
for key, row := range set {
|
||||||
|
if _, done := seen[key]; done {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pfx, err := netip.ParsePrefix(key)
|
||||||
|
if err != nil || !pfx.Addr().Is6() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bits := pfx.Bits()
|
||||||
|
if bits <= 16 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
netNum := ipv6PrefixNetworkLegacy(pfx)
|
||||||
|
blockSize := new(big.Int).Lsh(big.NewInt(1), uint(128-bits))
|
||||||
|
siblingNet := new(big.Int).Xor(netNum, blockSize)
|
||||||
|
siblingPfx := ipv6PrefixFromBigIntLegacy(siblingNet, bits).String()
|
||||||
|
if _, ok := set[siblingPfx]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parentBits := bits - 1
|
||||||
|
parentBlock := new(big.Int).Lsh(big.NewInt(1), uint(128-parentBits))
|
||||||
|
mask := new(big.Int).Sub(parentBlock, big.NewInt(1))
|
||||||
|
mask.Not(mask)
|
||||||
|
parentNet := new(big.Int).And(netNum, mask)
|
||||||
|
parentPfx := ipv6PrefixFromBigIntLegacy(parentNet, parentBits).String()
|
||||||
|
delete(set, key)
|
||||||
|
delete(set, siblingPfx)
|
||||||
|
parentRow := row
|
||||||
|
parentRow.Prefix = parentPfx
|
||||||
|
set[parentPfx] = parentRow
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
seen[siblingPfx] = struct{}{}
|
||||||
|
merged = true
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
func ipv6PrefixNetworkLegacy(p netip.Prefix) *big.Int {
|
||||||
|
a := p.Masked().Addr().As16()
|
||||||
|
n := new(big.Int)
|
||||||
|
n.SetBytes(a[:])
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func ipv6PrefixFromBigIntLegacy(n *big.Int, bits int) netip.Prefix {
|
||||||
|
b := n.Bytes()
|
||||||
|
var a [16]byte
|
||||||
|
copy(a[16-len(b):], b)
|
||||||
|
return netip.PrefixFrom(netip.AddrFrom16(a), bits).Masked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeForCompare(rows []store.PrefixRow) []string {
|
||||||
|
type line struct{ p, c, s string }
|
||||||
|
lines := make([]line, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
p := strings.TrimSpace(r.Prefix)
|
||||||
|
if p != "" {
|
||||||
|
if pfx, err := netip.ParsePrefix(p); err == nil {
|
||||||
|
p = pfx.Masked().String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines = append(lines, line{p, prefixRowCommunity(r), r.Source})
|
||||||
|
}
|
||||||
|
sort.Slice(lines, func(i, j int) bool {
|
||||||
|
if lines[i].p != lines[j].p {
|
||||||
|
return lines[i].p < lines[j].p
|
||||||
|
}
|
||||||
|
if lines[i].c != lines[j].c {
|
||||||
|
return lines[i].c < lines[j].c
|
||||||
|
}
|
||||||
|
return lines[i].s < lines[j].s
|
||||||
|
})
|
||||||
|
out := make([]string, 0, len(lines))
|
||||||
|
for _, l := range lines {
|
||||||
|
out = append(out, fmt.Sprintf("%s|%s|%s", l.p, l.c, l.s))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollapsePrefixGroup_EquivalenceWithLegacy(t *testing.T) {
|
||||||
|
r := rand.New(rand.NewSource(7))
|
||||||
|
for iter := 0; iter < 200; iter++ {
|
||||||
|
n := 1 + r.Intn(60)
|
||||||
|
v4rows := make([]store.PrefixRow, 0, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
addr := netip.AddrFrom4([4]byte{203, byte(r.Intn(4)), byte(r.Intn(256)), byte(r.Intn(256))})
|
||||||
|
bits := 16 + r.Intn(9)
|
||||||
|
pfx := netip.PrefixFrom(addr, bits).Masked()
|
||||||
|
v4rows = append(v4rows, store.PrefixRow{Prefix: pfx.String(), Source: "ip_range"})
|
||||||
|
}
|
||||||
|
legacy := legacyAggregateCIDRGroup(append([]store.PrefixRow(nil), v4rows...), legacyMergeSiblingPrefixesIPv4)
|
||||||
|
got := collapsePrefixGroup(append([]store.PrefixRow(nil), v4rows...), true)
|
||||||
|
if fmt.Sprint(normalizeForCompare(legacy)) != fmt.Sprint(normalizeForCompare(got)) {
|
||||||
|
t.Fatalf("iter %d mismatch:\nlegacy=%v\ngot =%v", iter, normalizeForCompare(legacy), normalizeForCompare(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollapsePrefixGroup_EquivalenceWithLegacyIPv6(t *testing.T) {
|
||||||
|
r := rand.New(rand.NewSource(11))
|
||||||
|
for iter := 0; iter < 200; iter++ {
|
||||||
|
n := 1 + r.Intn(60)
|
||||||
|
rows := make([]store.PrefixRow, 0, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
var a [16]byte
|
||||||
|
a[0], a[1] = 0x20, 0x01
|
||||||
|
a[2], a[3] = 0x0d, 0xb8
|
||||||
|
a[4] = byte(r.Intn(2))
|
||||||
|
a[5] = byte(r.Intn(256))
|
||||||
|
a[6] = byte(r.Intn(256))
|
||||||
|
addr := netip.AddrFrom16(a)
|
||||||
|
bits := 32 + r.Intn(17)
|
||||||
|
pfx := netip.PrefixFrom(addr, bits).Masked()
|
||||||
|
rows = append(rows, store.PrefixRow{Prefix: pfx.String(), Source: "ip_range"})
|
||||||
|
}
|
||||||
|
legacy := legacyAggregateCIDRGroup(append([]store.PrefixRow(nil), rows...), legacyMergeSiblingPrefixesIPv6)
|
||||||
|
got := collapsePrefixGroup(append([]store.PrefixRow(nil), rows...), false)
|
||||||
|
if fmt.Sprint(normalizeForCompare(legacy)) != fmt.Sprint(normalizeForCompare(got)) {
|
||||||
|
t.Fatalf("iter %d mismatch:\nlegacy=%v\ngot =%v", iter, normalizeForCompare(legacy), normalizeForCompare(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollapsePrefixGroup_CoversAndSiblingChain(t *testing.T) {
|
||||||
|
rows := []store.PrefixRow{
|
||||||
|
{Prefix: "10.0.0.0/16", Source: "ip_range"},
|
||||||
|
{Prefix: "10.0.0.0/24", Source: "ip_range"},
|
||||||
|
{Prefix: "10.0.1.0/24", Source: "ip_range"},
|
||||||
|
{Prefix: "10.0.2.0/24", Source: "ip_range"},
|
||||||
|
{Prefix: "10.0.3.0/24", Source: "ip_range"},
|
||||||
|
{Prefix: "10.1.0.0/24", Source: "ip_range"},
|
||||||
|
}
|
||||||
|
got := collapsePrefixGroup(rows, true)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("want 2 rows (/16 + /24), got %d: %+v", len(got), got)
|
||||||
|
}
|
||||||
|
set := map[string]bool{}
|
||||||
|
for _, r := range got {
|
||||||
|
set[r.Prefix] = true
|
||||||
|
}
|
||||||
|
if !set["10.0.0.0/16"] || !set["10.1.0.0/24"] {
|
||||||
|
t.Fatalf("unexpected rows: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollapsePrefixGroup_RespectsMinBitsFloor(t *testing.T) {
|
||||||
|
rows := []store.PrefixRow{
|
||||||
|
{Prefix: "10.0.0.0/8", Source: "ip_range"},
|
||||||
|
{Prefix: "11.0.0.0/8", Source: "ip_range"},
|
||||||
|
}
|
||||||
|
got := collapsePrefixGroup(rows, true)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("floor must prevent /8+/8 -> /7, got %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows9 := []store.PrefixRow{
|
||||||
|
{Prefix: "10.0.0.0/9", Source: "ip_range"},
|
||||||
|
{Prefix: "10.128.0.0/9", Source: "ip_range"},
|
||||||
|
}
|
||||||
|
got9 := collapsePrefixGroup(rows9, true)
|
||||||
|
if len(got9) != 1 || got9[0].Prefix != "10.0.0.0/8" {
|
||||||
|
t.Fatalf("expected /9+/9 -> /8, got %+v", got9)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollapsePrefixGroup_PrunesCovered(t *testing.T) {
|
||||||
|
rows := []store.PrefixRow{
|
||||||
|
{Prefix: "10.0.0.0/16", Source: "ip_range"},
|
||||||
|
{Prefix: "10.0.0.0/24", Source: "ip_range"},
|
||||||
|
}
|
||||||
|
got := collapsePrefixGroup(rows, true)
|
||||||
|
if len(got) != 1 || got[0].Prefix != "10.0.0.0/16" {
|
||||||
|
t.Fatalf("want covered prune to /16, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,28 +24,43 @@ func asnCacheTTL() time.Duration {
|
|||||||
return time.Duration(sec) * time.Second
|
return time.Duration(sec) * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// asnHolderTTL is how long a holder name stays authoritative between refreshes;
|
||||||
|
// holder text changes rarely, so it survives short prefix-cache TTLs.
|
||||||
|
func asnHolderTTL() time.Duration {
|
||||||
|
sec := 7 * 24 * 3600
|
||||||
|
if s := strings.TrimSpace(os.Getenv("EVOBGP_ASN_HOLDER_TTL_SEC")); s != "" {
|
||||||
|
if v, err := strconv.Atoi(s); err == nil && v > 0 {
|
||||||
|
sec = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Duration(sec) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
// resolveASNForEntry fetches prefixes and holder with shared TTL cache (asn_prefix_cache).
|
// resolveASNForEntry fetches prefixes and holder with shared TTL cache (asn_prefix_cache).
|
||||||
|
// The holder lookup starts concurrently with the prefix fetch (one RTT instead of two);
|
||||||
|
// a holder failure is non-fatal — the previously cached holder name is kept.
|
||||||
func resolveASNForEntry(ctx context.Context, st store.Backend, hc *http.Client, asn int64) ([]netip.Prefix, string, error) {
|
func resolveASNForEntry(ctx context.Context, st store.Backend, hc *http.Client, asn int64) ([]netip.Prefix, string, error) {
|
||||||
ttl := asnCacheTTL()
|
ttl := asnCacheTTL()
|
||||||
|
var prevHolder string
|
||||||
if st != nil {
|
if st != nil {
|
||||||
if ent, ok, err := st.GetASNPrefixCache(asn); err == nil && ok && ent != nil && time.Since(ent.FetchedAt) < ttl {
|
if ent, ok, err := st.GetASNPrefixCache(asn); err == nil && ok && ent != nil {
|
||||||
out := make([]netip.Prefix, 0, len(ent.Prefixes))
|
prevHolder = ent.Holder
|
||||||
for _, p := range ent.Prefixes {
|
if time.Since(ent.FetchedAt) < ttl {
|
||||||
pfx, perr := netip.ParsePrefix(strings.TrimSpace(p))
|
return parseASNCachePrefixes(ent.Prefixes), ent.Holder, nil
|
||||||
if perr != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, pfx.Masked())
|
|
||||||
}
|
|
||||||
return out, ent.Holder, nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
holderCh := startASNHolderFetch(ctx, hc, st, asn, prevHolder)
|
||||||
|
|
||||||
pfxs, err := asnresolve.AnnouncedPrefixes(ctx, hc, asn)
|
pfxs, err := asnresolve.AnnouncedPrefixes(ctx, hc, asn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
holder := <-holderCh // drain to avoid leaking the goroutine's channel send
|
||||||
|
_ = holder
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
asnresolve.PolitePause()
|
|
||||||
holder, _ := asnresolve.ASHolderName(ctx, hc, asn)
|
holder := <-holderCh
|
||||||
if st != nil {
|
if st != nil {
|
||||||
strs := make([]string, len(pfxs))
|
strs := make([]string, len(pfxs))
|
||||||
for i, p := range pfxs {
|
for i, p := range pfxs {
|
||||||
@@ -57,3 +72,55 @@ func resolveASNForEntry(ctx context.Context, st store.Backend, hc *http.Client,
|
|||||||
}
|
}
|
||||||
return pfxs, holder, nil
|
return pfxs, holder, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// startASNHolderFetch launches the holder lookup concurrently. The returned buffered
|
||||||
|
// channel always yields exactly one value, so callers may abandon it without leaking.
|
||||||
|
func startASNHolderFetch(ctx context.Context, hc *http.Client, st store.Backend, asn int64, prevHolder string) <-chan string {
|
||||||
|
ch := make(chan string, 1)
|
||||||
|
go func() {
|
||||||
|
if prevHolder != "" && holderStillFresh(st, asn, prevHolder) {
|
||||||
|
ch <- prevHolder
|
||||||
|
return
|
||||||
|
}
|
||||||
|
holder, err := asnresolve.ASHolderName(ctx, hc, asn)
|
||||||
|
if err != nil || strings.TrimSpace(holder) == "" {
|
||||||
|
ch <- prevHolder
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ch <- strings.TrimSpace(holder)
|
||||||
|
}()
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
// holderStillFresh reports whether the cached holder name is within its own (long) TTL.
|
||||||
|
func holderStillFresh(st store.Backend, asn int64, prevHolder string) bool {
|
||||||
|
if st == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
ent, ok, err := st.GetASNPrefixCache(asn)
|
||||||
|
return err == nil && ok && ent != nil && ent.Holder == prevHolder && time.Since(ent.FetchedAt) < asnHolderTTL()
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseASNCachePrefixes(raw []string) []netip.Prefix {
|
||||||
|
out := make([]netip.Prefix, 0, len(raw))
|
||||||
|
for _, p := range raw {
|
||||||
|
pfx, perr := netip.ParsePrefix(strings.TrimSpace(p))
|
||||||
|
if perr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, pfx.Masked())
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCachedASNCachedPrefixes(raw []string) []netip.Prefix {
|
||||||
|
out := make([]netip.Prefix, 0, len(raw))
|
||||||
|
for _, p := range raw {
|
||||||
|
pfx, perr := netip.ParsePrefix(strings.TrimSpace(p))
|
||||||
|
if perr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, pfx.Masked())
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,13 +51,92 @@ func TestResolveASNForEntry_UsesTTLCache(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveASNForEntry_HolderFailureIsNonFatal(t *testing.T) {
|
||||||
|
m := store.NewMemory()
|
||||||
|
t.Setenv("EVOBGP_ASN_CACHE_TTL_SEC", "1")
|
||||||
|
|
||||||
|
var holderCalls atomic.Int32
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.Contains(r.URL.Path, "/announced") {
|
||||||
|
_, _ = w.Write([]byte(`{"status":"ok","data":{"prefixes":[{"prefix":"203.0.113.0/24"}]}}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
holderCalls.Add(1)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
t.Setenv("EVOBGP_RIPESTAT_ANNOUNCED_PREFIXES_URL", srv.URL+"/announced")
|
||||||
|
t.Setenv("EVOBGP_RIPESTAT_AS_OVERVIEW_URL", srv.URL+"/overview")
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
hc := srv.Client()
|
||||||
|
pfxs, holder, err := resolveASNForEntry(ctx, m, hc, 64512)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("holder failure must not fail ingest: %v", err)
|
||||||
|
}
|
||||||
|
if len(pfxs) != 1 {
|
||||||
|
t.Fatalf("expected 1 prefix, got %+v", pfxs)
|
||||||
|
}
|
||||||
|
if holder != "" {
|
||||||
|
t.Fatalf("expected empty holder on upstream failure, got %q", holder)
|
||||||
|
}
|
||||||
|
if holderCalls.Load() == 0 {
|
||||||
|
t.Fatal("expected holder endpoint to be attempted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveASNForEntry_KeepsPreviousHolderOnFailure(t *testing.T) {
|
||||||
|
m := store.NewMemory()
|
||||||
|
t.Setenv("EVOBGP_ASN_CACHE_TTL_SEC", "1")
|
||||||
|
t.Setenv("EVOBGP_ASN_HOLDER_TTL_SEC", "3600")
|
||||||
|
|
||||||
|
holderOK := atomic.Bool{}
|
||||||
|
holderOK.Store(true)
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.Contains(r.URL.Path, "/announced") {
|
||||||
|
_, _ = w.Write([]byte(`{"status":"ok","data":{"prefixes":[{"prefix":"203.0.113.0/24"}]}}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if holderOK.Load() {
|
||||||
|
_, _ = w.Write([]byte(`{"status":"ok","data":{"holder":"Good AS"}}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
t.Setenv("EVOBGP_RIPESTAT_ANNOUNCED_PREFIXES_URL", srv.URL+"/announced")
|
||||||
|
t.Setenv("EVOBGP_RIPESTAT_AS_OVERVIEW_URL", srv.URL+"/overview")
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
hc := srv.Client()
|
||||||
|
if _, h, err := resolveASNForEntry(ctx, m, hc, 64512); err != nil || h != "Good AS" {
|
||||||
|
t.Fatalf("first resolve: holder=%q err=%v", h, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefix cache expired; holder endpoint now broken — previous holder must survive.
|
||||||
|
holderOK.Store(false)
|
||||||
|
time.Sleep(1100 * time.Millisecond)
|
||||||
|
pfxs, h, err := resolveASNForEntry(ctx, m, hc, 64512)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(pfxs) != 1 {
|
||||||
|
t.Fatalf("expected 1 prefix, got %+v", pfxs)
|
||||||
|
}
|
||||||
|
if h != "Good AS" {
|
||||||
|
t.Fatalf("expected previous holder preserved, got %q", h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestModuleDueForScheduler_BucketRollover(t *testing.T) {
|
func TestModuleDueForScheduler_BucketRollover(t *testing.T) {
|
||||||
mod := &store.Module{Enabled: true, Type: "CDN_CIDRS", RefreshIntervalSec: 300}
|
mod := &store.Module{ID: "mod-jitter", Enabled: true, Type: "CDN_CIDRS", RefreshIntervalSec: 300}
|
||||||
boundary := time.Unix(300, 0)
|
win := int64(300)
|
||||||
|
off := moduleSchedulerOffset(mod.ID, win)
|
||||||
|
boundary := time.Unix(win+off, 0)
|
||||||
if !ModuleDueForScheduler(mod, boundary) {
|
if !ModuleDueForScheduler(mod, boundary) {
|
||||||
t.Fatal("expected due when refresh bucket rolls")
|
t.Fatal("expected due when refresh bucket rolls")
|
||||||
}
|
}
|
||||||
mid := time.Unix(330, 0)
|
mid := time.Unix(win+off+SchedulerTickSec, 0)
|
||||||
if ModuleDueForScheduler(mod, mid) {
|
if ModuleDueForScheduler(mod, mid) {
|
||||||
t.Fatal("expected not due within same bucket")
|
t.Fatal("expected not due within same bucket")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package pipeline
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"net/netip"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"evobgp/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// benchPrefixRows generates seed-stable pseudo-random prefixes for aggregation benchmarks.
|
||||||
|
// v4 blocks are carved from TEST-NET-style ranges; v6 from 2001:db8::/32.
|
||||||
|
func benchPrefixRows(n int, v6Share float64) []store.PrefixRow {
|
||||||
|
r := rand.New(rand.NewSource(42))
|
||||||
|
rows := make([]store.PrefixRow, 0, n)
|
||||||
|
seen := make(map[string]struct{}, n)
|
||||||
|
for len(rows) < n {
|
||||||
|
var pfx netip.Prefix
|
||||||
|
if r.Float64() < v6Share {
|
||||||
|
var a [16]byte
|
||||||
|
a[0], a[1] = 0x20, 0x01
|
||||||
|
a[2], a[3] = 0x0d, 0xb8
|
||||||
|
for i := 4; i < 10; i++ {
|
||||||
|
a[i] = byte(r.Intn(256))
|
||||||
|
}
|
||||||
|
addr := netip.AddrFrom16(a)
|
||||||
|
pfx = netip.PrefixFrom(addr, 48+r.Intn(17))
|
||||||
|
} else {
|
||||||
|
b := []byte{203, 0, 113, 0}
|
||||||
|
b[1] = byte(r.Intn(256))
|
||||||
|
b[2] = byte(r.Intn(256))
|
||||||
|
b[3] = byte(r.Intn(256))
|
||||||
|
addr := netip.AddrFrom4([4]byte{b[0], b[1], b[2], b[3]})
|
||||||
|
pfx = netip.PrefixFrom(addr, 16+r.Intn(9))
|
||||||
|
}
|
||||||
|
s := pfx.Masked().String()
|
||||||
|
if _, ok := seen[s]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[s] = struct{}{}
|
||||||
|
rows = append(rows, store.PrefixRow{Prefix: s, Source: "ip_range"})
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkSmartAggregatePrefixRows(b *testing.B) {
|
||||||
|
for _, n := range []int{1_000, 10_000, 50_000} {
|
||||||
|
rows := benchPrefixRows(n, 0.3)
|
||||||
|
b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_ = smartAggregatePrefixRows(rows)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,9 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"evobgp/internal/store"
|
"evobgp/internal/store"
|
||||||
)
|
)
|
||||||
@@ -194,3 +196,63 @@ func TestCollectModulePrefixRows_CDN304RetriesWithoutETag(t *testing.T) {
|
|||||||
t.Fatalf("unexpected collected rows: %+v", collected)
|
t.Fatalf("unexpected collected rows: %+v", collected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPrefetchCDNSourceETags_SkipsFreshSources(t *testing.T) {
|
||||||
|
t.Setenv("EVOBGP_CDN_ALLOW_PRIVATE", "1")
|
||||||
|
m := store.NewMemory()
|
||||||
|
m.SeedDemo()
|
||||||
|
tenant, _, _, _, _ := m.DemoIDs()
|
||||||
|
|
||||||
|
mod, err := m.CreateModule(tenant, &store.Module{
|
||||||
|
Type: "CDN_CIDRS",
|
||||||
|
Name: "cdn-prefetch-due",
|
||||||
|
Enabled: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var hits atomic.Int32
|
||||||
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hits.Add(1)
|
||||||
|
w.Header().Set("ETag", `"v1"`)
|
||||||
|
_, _ = w.Write([]byte("198.51.100.0/24\n"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// Fresh source: refreshed 30s ago with a 3600s interval — prefetch must skip it.
|
||||||
|
freshAt := time.Now().UTC().Add(-30 * time.Second)
|
||||||
|
interval := 3600
|
||||||
|
if _, err := m.CreateCDNSource(tenant, mod.ID, &store.CDNSource{
|
||||||
|
SourceKind: "txt",
|
||||||
|
URL: srv.URL,
|
||||||
|
RefreshIntervalSec: &interval,
|
||||||
|
LastRefreshedAt: &freshAt,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := PrefetchCDNSourceETags(context.Background(), m, srv.Client()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if hits.Load() != 0 {
|
||||||
|
t.Fatalf("fresh source must be skipped by prefetch, got %d HTTP hits", hits.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stale source: last refresh older than its interval — prefetch must probe it.
|
||||||
|
staleAt := time.Now().UTC().Add(-7200 * time.Second)
|
||||||
|
if _, err := m.CreateCDNSource(tenant, mod.ID, &store.CDNSource{
|
||||||
|
SourceKind: "txt",
|
||||||
|
URL: srv.URL + "?stale",
|
||||||
|
RefreshIntervalSec: &interval,
|
||||||
|
LastRefreshedAt: &staleAt,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := PrefetchCDNSourceETags(context.Background(), m, srv.Client()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if hits.Load() == 0 {
|
||||||
|
t.Fatal("stale source must be probed by prefetch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -42,32 +42,49 @@ func mergeSnapshotDropSource(rows []store.PrefixRow, sourceKey string) []store.P
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// mergeSnapshotDropCDNSources removes all cdn:* rows (used before batch CDN merge).
|
// mergeSnapshotKeepSkippedCDN keeps non-CDN rows and cdn:* rows whose source is still skipped
|
||||||
func mergeSnapshotDropCDNSources(rows []store.PrefixRow) []store.PrefixRow {
|
// (not in fetchedSourceIDs). Deleted sources (absent from allSourceIDs) are dropped.
|
||||||
if len(rows) == 0 {
|
func mergeSnapshotKeepSkippedCDN(rows []store.PrefixRow, fetchedSourceIDs, allSourceIDs []string) []store.PrefixRow {
|
||||||
return nil
|
fetched := make(map[string]struct{}, len(fetchedSourceIDs))
|
||||||
|
for _, id := range fetchedSourceIDs {
|
||||||
|
fetched[cdnSourceKey(id)] = struct{}{}
|
||||||
|
}
|
||||||
|
keepCDN := make(map[string]struct{})
|
||||||
|
for _, id := range allSourceIDs {
|
||||||
|
k := cdnSourceKey(id)
|
||||||
|
if _, ok := fetched[k]; !ok {
|
||||||
|
keepCDN[k] = struct{}{}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
out := make([]store.PrefixRow, 0, len(rows))
|
out := make([]store.PrefixRow, 0, len(rows))
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
if !strings.HasPrefix(strings.TrimSpace(row.Source), "cdn:") {
|
src := strings.TrimSpace(row.Source)
|
||||||
out = append(out, row)
|
if strings.HasPrefix(src, "cdn:") {
|
||||||
|
if _, ok := keepCDN[src]; !ok {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
out = append(out, row)
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// mergeAllCDNSourcesIntoModuleSnapshot replaces all CDN rows in one write (avoids parallel read-modify-write races).
|
// mergeAllCDNSourcesIntoModuleSnapshot replaces fetched CDN source rows in one write.
|
||||||
func mergeAllCDNSourcesIntoModuleSnapshot(st store.Backend, tenantID string, mod *store.Module, priorSnapshot []store.PrefixRow, cdnRows []store.PrefixRow) error {
|
// skipped sources (errors with EVOBGP_CDN_PARTIAL_OK) keep their prior rows.
|
||||||
|
func mergeAllCDNSourcesIntoModuleSnapshot(st store.Backend, tenantID string, mod *store.Module, priorSnapshot []store.PrefixRow, cdnRows []store.PrefixRow, fetchedSourceIDs, allSourceIDs []string) error {
|
||||||
if st == nil || mod == nil {
|
if st == nil || mod == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
unlock := st.LockModuleSnapshot(tenantID, mod.ID)
|
||||||
|
defer unlock()
|
||||||
var base []store.PrefixRow
|
var base []store.PrefixRow
|
||||||
if len(priorSnapshot) > 0 {
|
if len(priorSnapshot) > 0 {
|
||||||
base = mergeSnapshotDropCDNSources(priorSnapshot)
|
base = priorSnapshot
|
||||||
} else if snap, ok, _ := st.GetModulePrefixSnapshot(tenantID, mod.ID); ok && snap != nil {
|
} else if snap, ok, _ := st.GetModulePrefixSnapshot(tenantID, mod.ID); ok && snap != nil {
|
||||||
base = mergeSnapshotDropCDNSources(snap.Prefixes)
|
base = snap.Prefixes
|
||||||
}
|
}
|
||||||
merged := append(base, cdnRows...)
|
kept := mergeSnapshotKeepSkippedCDN(base, fetchedSourceIDs, allSourceIDs)
|
||||||
|
merged := append(kept, cdnRows...)
|
||||||
return persistModuleSnapshot(st, tenantID, mod, merged)
|
return persistModuleSnapshot(st, tenantID, mod, merged)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +110,8 @@ func mergeCDNSourceIntoModuleSnapshot(st store.Backend, tenantID string, mod *st
|
|||||||
if st == nil || mod == nil {
|
if st == nil || mod == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
unlock := st.LockModuleSnapshot(tenantID, mod.ID)
|
||||||
|
defer unlock()
|
||||||
sourceKey := cdnSourceKey(sourceID)
|
sourceKey := cdnSourceKey(sourceID)
|
||||||
var base []store.PrefixRow
|
var base []store.PrefixRow
|
||||||
if snap, ok, _ := st.GetModulePrefixSnapshot(tenantID, mod.ID); ok && snap != nil {
|
if snap, ok, _ := st.GetModulePrefixSnapshot(tenantID, mod.ID); ok && snap != nil {
|
||||||
@@ -114,81 +133,6 @@ func parseCDNBody(body string, src *store.CDNSource) ([]string, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyCDNSourceHTTPResult(ctx context.Context, st store.Backend, hc *http.Client, tenantID, moduleID string, mod *store.Module, src *store.CDNSource, priorSnapshot []store.PrefixRow, now time.Time) ([]store.PrefixRow, error) {
|
|
||||||
u := strings.TrimSpace(src.URL)
|
|
||||||
if u == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if _, err := ValidateCDNURL(u); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := ResolveCDNURLHost(ctx, u); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
sourceKey := cdnSourceKey(src.ID)
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if etag := strings.TrimSpace(src.Etag); etag != "" {
|
|
||||||
req.Header.Set("If-None-Match", etag)
|
|
||||||
}
|
|
||||||
resp, err := upstreamHTTPDo(ctx, hc, req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("cdn fetch %s: %w", u, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusNotModified {
|
|
||||||
if cached := cachedCDNPrefixRows(st, tenantID, moduleID, priorSnapshot, sourceKey); len(cached) > 0 {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
return cached, nil
|
|
||||||
}
|
|
||||||
// ETag is known but local snapshot is empty — force a full download.
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
req2, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
resp, err = upstreamHTTPDo(ctx, hc, req2)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("cdn fetch %s: %w", u, err)
|
|
||||||
}
|
|
||||||
if resp.StatusCode == http.StatusNotModified {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
return nil, fmt.Errorf("cdn url %s: 304 without cached prefixes", u)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
defer func() { _ = resp.Body.Close() }()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
_, _ = io.Copy(io.Discard, resp.Body)
|
|
||||||
return nil, fmt.Errorf("cdn url %s: %s", u, resp.Status)
|
|
||||||
}
|
|
||||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
prefixStrs, err := parseCDNBody(string(body), src)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("cdn parse %s: %w", u, err)
|
|
||||||
}
|
|
||||||
etag := strings.TrimSpace(resp.Header.Get("ETag"))
|
|
||||||
patch := &store.CDNSourcePatch{}
|
|
||||||
if etag != "" && etag != strings.TrimSpace(src.Etag) {
|
|
||||||
e := etag
|
|
||||||
patch.Etag = &e
|
|
||||||
}
|
|
||||||
refreshedAt := now
|
|
||||||
patch.LastRefreshedAt = &refreshedAt
|
|
||||||
_, _ = st.UpdateCDNSource(tenantID, moduleID, src.ID, patch)
|
|
||||||
|
|
||||||
rows := cdnRowsFromParsed(mod, src, prefixStrs)
|
|
||||||
if err := mergeCDNSourceIntoModuleSnapshot(st, tenantID, mod, src.ID, rows); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return rows, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetchCDNSourceRows loads CDN prefixes without persisting the module snapshot (caller merges once).
|
// fetchCDNSourceRows loads CDN prefixes without persisting the module snapshot (caller merges once).
|
||||||
func fetchCDNSourceRows(ctx context.Context, st store.Backend, hc *http.Client, tenantID, moduleID string, mod *store.Module, src *store.CDNSource, priorSnapshot []store.PrefixRow, now time.Time) ([]store.PrefixRow, error) {
|
func fetchCDNSourceRows(ctx context.Context, st store.Backend, hc *http.Client, tenantID, moduleID string, mod *store.Module, src *store.CDNSource, priorSnapshot []store.PrefixRow, now time.Time) ([]store.PrefixRow, error) {
|
||||||
u := strings.TrimSpace(src.URL)
|
u := strings.TrimSpace(src.URL)
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -91,6 +93,9 @@ func ResolveCDNURLHost(ctx context.Context, raw string) error {
|
|||||||
if isBlockedCDNHostname(host) {
|
if isBlockedCDNHostname(host) {
|
||||||
return fmt.Errorf("pipeline: cdn url blocked host")
|
return fmt.Errorf("pipeline: cdn url blocked host")
|
||||||
}
|
}
|
||||||
|
if ok := cdnDNSVerifyCache.hit(host); ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if ctx == nil {
|
if ctx == nil {
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
}
|
}
|
||||||
@@ -112,5 +117,64 @@ func ResolveCDNURLHost(ctx context.Context, raw string) error {
|
|||||||
return fmt.Errorf("pipeline: cdn url resolves to blocked address")
|
return fmt.Errorf("pipeline: cdn url resolves to blocked address")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
cdnDNSVerifyCache.store(host)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cdnDNSVerifyTTL bounds how long a successful SSRF check is trusted for one hostname.
|
||||||
|
// Failures are never cached: a transient DNS outage must not open an unsafe window,
|
||||||
|
// and a blocked host is rejected before this cache anyway.
|
||||||
|
func cdnDNSVerifyTTL() time.Duration {
|
||||||
|
sec := 300
|
||||||
|
if s := strings.TrimSpace(os.Getenv("EVOBGP_CDN_DNS_CACHE_TTL_SEC")); s != "" {
|
||||||
|
if v, err := strconv.Atoi(s); err == nil && v > 0 {
|
||||||
|
sec = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Duration(sec) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
type dnsVerifyCache struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
seen map[string]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var cdnDNSVerifyCache = &dnsVerifyCache{seen: make(map[string]time.Time)}
|
||||||
|
|
||||||
|
func (c *dnsVerifyCache) hit(host string) bool {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
at, ok := c.seen[host]
|
||||||
|
return ok && time.Since(at) < cdnDNSVerifyTTL()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dnsVerifyCache) store(host string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.seen == nil {
|
||||||
|
c.seen = make(map[string]time.Time)
|
||||||
|
}
|
||||||
|
c.seen[host] = time.Now()
|
||||||
|
if len(c.seen) > 4096 {
|
||||||
|
// Size cap for long-running workers: drop expired entries, then the oldest if needed.
|
||||||
|
now := time.Now()
|
||||||
|
for h, at := range c.seen {
|
||||||
|
if now.Sub(at) >= cdnDNSVerifyTTL() {
|
||||||
|
delete(c.seen, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(c.seen) > 4096 {
|
||||||
|
var oldestK string
|
||||||
|
var oldestT time.Time
|
||||||
|
first := true
|
||||||
|
for h, at := range c.seen {
|
||||||
|
if first || at.Before(oldestT) {
|
||||||
|
oldestK, oldestT, first = h, at, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if oldestK != "" {
|
||||||
|
delete(c.seen, oldestK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ func prefixRowsForSource(rows []store.PrefixRow, sourceKey string) []store.Prefi
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func prefixCommunityKey(row store.PrefixRow) string {
|
||||||
|
comm := ""
|
||||||
|
if row.CommunityID != nil {
|
||||||
|
comm = strings.TrimSpace(*row.CommunityID)
|
||||||
|
}
|
||||||
|
return row.Prefix + "\x00" + comm
|
||||||
|
}
|
||||||
|
|
||||||
func collectASPrefixRows(ctx context.Context, st store.Backend, hc *http.Client, tenantID string, mod *store.Module, list []*store.ASEntry, priorSnapshot []store.PrefixRow) ([]store.PrefixRow, error) {
|
func collectASPrefixRows(ctx context.Context, st store.Backend, hc *http.Client, tenantID string, mod *store.Module, list []*store.ASEntry, priorSnapshot []store.PrefixRow) ([]store.PrefixRow, error) {
|
||||||
moduleID := mod.ID
|
moduleID := mod.ID
|
||||||
legacy := strings.TrimSpace(os.Getenv("EVOBGP_ASN_RESOLVE")) == "0"
|
legacy := strings.TrimSpace(os.Getenv("EVOBGP_ASN_RESOLVE")) == "0"
|
||||||
@@ -147,7 +155,7 @@ func collectASPrefixRows(ctx context.Context, st store.Backend, hc *http.Client,
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
for _, row := range r.rows {
|
for _, row := range r.rows {
|
||||||
k := row.Prefix
|
k := prefixCommunityKey(row)
|
||||||
if _, ok := seenPfx[k]; ok {
|
if _, ok := seenPfx[k]; ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -218,9 +226,14 @@ func collectCDNPrefixRows(ctx context.Context, st store.Backend, hc *http.Client
|
|||||||
}
|
}
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
var out []store.PrefixRow
|
var fetchedRows []store.PrefixRow
|
||||||
var skipped int
|
var skipped int
|
||||||
for _, r := range results {
|
var fetchedIDs []string
|
||||||
|
allIDs := make([]string, 0, len(valid))
|
||||||
|
for _, src := range valid {
|
||||||
|
allIDs = append(allIDs, src.ID)
|
||||||
|
}
|
||||||
|
for i, r := range results {
|
||||||
if r.err != nil {
|
if r.err != nil {
|
||||||
if cdnPartialOK() {
|
if cdnPartialOK() {
|
||||||
logging.Default().Info(fmt.Sprintf("pipeline: CDN partial skip source error: %v", r.err))
|
logging.Default().Info(fmt.Sprintf("pipeline: CDN partial skip source error: %v", r.err))
|
||||||
@@ -229,20 +242,35 @@ func collectCDNPrefixRows(ctx context.Context, st store.Backend, hc *http.Client
|
|||||||
}
|
}
|
||||||
return nil, r.err
|
return nil, r.err
|
||||||
}
|
}
|
||||||
out = append(out, r.rows...)
|
fetchedIDs = append(fetchedIDs, valid[i].ID)
|
||||||
|
fetchedRows = append(fetchedRows, r.rows...)
|
||||||
}
|
}
|
||||||
if skipped > 0 && len(out) == 0 && len(valid) > 0 {
|
if skipped > 0 && len(fetchedRows) == 0 && len(valid) > 0 {
|
||||||
return nil, fmt.Errorf("cdn: all %d source(s) failed (partial ok)", len(valid))
|
return nil, fmt.Errorf("cdn: all %d source(s) failed (partial ok)", len(valid))
|
||||||
}
|
}
|
||||||
|
out := fetchedRows
|
||||||
|
if skipped > 0 {
|
||||||
|
base := priorSnapshot
|
||||||
|
if len(base) == 0 {
|
||||||
|
if snap, ok, _ := st.GetModulePrefixSnapshot(tenantID, moduleID); ok && snap != nil {
|
||||||
|
base = snap.Prefixes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, row := range mergeSnapshotKeepSkippedCDN(base, fetchedIDs, allIDs) {
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(row.Source), "cdn:") {
|
||||||
|
out = append(out, row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if len(valid) > 0 {
|
if len(valid) > 0 {
|
||||||
if err := mergeAllCDNSourcesIntoModuleSnapshot(st, tenantID, mod, priorSnapshot, out); err != nil {
|
if err := mergeAllCDNSourcesIntoModuleSnapshot(st, tenantID, mod, priorSnapshot, fetchedRows, fetchedIDs, allIDs); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectDomainPrefixRows(ctx context.Context, hc *http.Client, mod *store.Module, profiles []*store.DohProfile, policy string, entries []*store.DomainEntry, priorSnapshot []store.PrefixRow) ([]store.PrefixRow, error) {
|
func collectDomainPrefixRows(ctx context.Context, st store.Backend, hc *http.Client, mod *store.Module, profiles []*store.DohProfile, policy string, entries []*store.DomainEntry, priorSnapshot []store.PrefixRow) ([]store.PrefixRow, error) {
|
||||||
var validDom []*store.DomainEntry
|
var validDom []*store.DomainEntry
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if e != nil {
|
if e != nil {
|
||||||
@@ -269,7 +297,7 @@ func collectDomainPrefixRows(ctx context.Context, hc *http.Client, mod *store.Mo
|
|||||||
c := *mod.DefaultCommunityID
|
c := *mod.DefaultCommunityID
|
||||||
comm = &c
|
comm = &c
|
||||||
}
|
}
|
||||||
addrs, err := resolveDomainIPsWithPolicy(ctx, hc, profiles, policy, entry.FQDN)
|
addrs, err := resolveDomainIPsCached(ctx, st, hc, profiles, policy, entry.FQDN)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if staleOnUpstreamError() {
|
if staleOnUpstreamError() {
|
||||||
if cached, ok := staleDomainPrefixes(priorSnapshot, entry.FQDN); ok {
|
if cached, ok := staleDomainPrefixes(priorSnapshot, entry.FQDN); ok {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"evobgp/internal/store"
|
"evobgp/internal/store"
|
||||||
@@ -108,8 +109,7 @@ func resolveDomainIPsNoSystemFallback(ctx context.Context, hc *http.Client, prof
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
baseURL := strings.TrimSpace(profile.URL)
|
baseURL := strings.TrimSpace(profile.URL)
|
||||||
v4, err4 := resolveDomainWithDOHMessage(dctx, hc, baseURL, host, dns.TypeA)
|
v4, v6, err4, err6 := resolveDOHMessagePair(dctx, hc, baseURL, host)
|
||||||
v6, err6 := resolveDomainWithDOHMessage(dctx, hc, baseURL, host, dns.TypeAAAA)
|
|
||||||
if err4 != nil {
|
if err4 != nil {
|
||||||
v4, err4 = resolveDomainWithDOHJSON(dctx, hc, baseURL, host, "A")
|
v4, err4 = resolveDomainWithDOHJSON(dctx, hc, baseURL, host, "A")
|
||||||
}
|
}
|
||||||
@@ -122,6 +122,23 @@ func resolveDomainIPsNoSystemFallback(ctx context.Context, hc *http.Client, prof
|
|||||||
return uniqAddrs(append(v4, v6...)), nil
|
return uniqAddrs(append(v4, v6...)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveDOHMessagePair issues RFC8484 dns-message A and AAAA queries concurrently
|
||||||
|
// and waits for both (fallbacks are handled by the caller).
|
||||||
|
func resolveDOHMessagePair(ctx context.Context, hc *http.Client, baseURL, host string) (v4, v6 []netip.Addr, err4, err6 error) {
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
v4, err4 = resolveDomainWithDOHMessage(ctx, hc, baseURL, host, dns.TypeA)
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
v6, err6 = resolveDomainWithDOHMessage(ctx, hc, baseURL, host, dns.TypeAAAA)
|
||||||
|
}()
|
||||||
|
wg.Wait()
|
||||||
|
return v4, v6, err4, err6
|
||||||
|
}
|
||||||
|
|
||||||
func dohProfileTimeout(profile *store.DohProfile) time.Duration {
|
func dohProfileTimeout(profile *store.DohProfile) time.Duration {
|
||||||
timeout := 10 * time.Second
|
timeout := 10 * time.Second
|
||||||
if profile != nil && profile.TimeoutMs != nil && *profile.TimeoutMs > 0 {
|
if profile != nil && profile.TimeoutMs != nil && *profile.TimeoutMs > 0 {
|
||||||
|
|||||||
@@ -2,147 +2,97 @@ package pipeline
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"evobgp/internal/store"
|
"evobgp/internal/store"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestResolveDomainIPsWithPolicy_Union(t *testing.T) {
|
// TestResolveDomainIPs_ParallelAAndAAAA verifies that A and AAAA queries are issued
|
||||||
srvRU := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
// concurrently: with a 250ms upstream latency the combined resolve must stay near
|
||||||
_, _ = w.Write([]byte(`{"Answer":[{"type":1,"data":"198.51.100.1"}]}`))
|
// one round-trip instead of two.
|
||||||
}))
|
func TestResolveDomainIPs_ParallelAAndAAAA(t *testing.T) {
|
||||||
defer srvRU.Close()
|
const delay = 250 * time.Millisecond
|
||||||
srvEU := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
var inflight, maxInflight atomic.Int32
|
||||||
_, _ = w.Write([]byte(`{"Answer":[{"type":1,"data":"203.0.113.1"}]}`))
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
}))
|
cur := inflight.Add(1)
|
||||||
defer srvEU.Close()
|
for {
|
||||||
|
old := maxInflight.Load()
|
||||||
profiles := []*store.DohProfile{
|
if cur <= old || maxInflight.CompareAndSwap(old, cur) {
|
||||||
{URL: srvRU.URL},
|
break
|
||||||
{URL: srvEU.URL},
|
|
||||||
}
|
}
|
||||||
ips, err := resolveDomainIPsWithPolicy(context.Background(), srvRU.Client(), profiles, store.DohPolicyUnion, "example.com")
|
}
|
||||||
|
defer inflight.Add(-1)
|
||||||
|
time.Sleep(delay)
|
||||||
|
|
||||||
|
if wire := r.URL.Query().Get("dns"); wire != "" {
|
||||||
|
// RFC8484 dns-message: decode the query and answer on the wire.
|
||||||
|
raw, err := base64.RawURLEncoding.DecodeString(wire)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg := new(dns.Msg)
|
||||||
|
if err := msg.Unpack(raw); err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp := new(dns.Msg)
|
||||||
|
resp.SetReply(msg)
|
||||||
|
switch msg.Question[0].Qtype {
|
||||||
|
case dns.TypeA:
|
||||||
|
resp.Answer = append(resp.Answer, &dns.A{
|
||||||
|
Hdr: dns.RR_Header{Name: msg.Question[0].Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60},
|
||||||
|
A: []byte{203, 0, 113, 10},
|
||||||
|
})
|
||||||
|
case dns.TypeAAAA:
|
||||||
|
resp.Answer = append(resp.Answer, &dns.AAAA{
|
||||||
|
Hdr: dns.RR_Header{Name: msg.Question[0].Name, Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: 60},
|
||||||
|
AAAA: []byte{0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x10},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
out, err := resp.Pack()
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/dns-message")
|
||||||
|
_, _ = w.Write(out)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/dns-json")
|
||||||
|
switch r.URL.Query().Get("type") {
|
||||||
|
case "A":
|
||||||
|
_, _ = w.Write([]byte(`{"Status":0,"Answer":[{"type":1,"data":"203.0.113.10"}]}`))
|
||||||
|
default:
|
||||||
|
_, _ = w.Write([]byte(`{"Status":0,"Answer":[{"type":28,"data":"2001:db8::10"}]}`))
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
prof := &store.DohProfile{URL: srv.URL, TimeoutMs: ptrInt(5000)}
|
||||||
|
ctx := context.Background()
|
||||||
|
start := time.Now()
|
||||||
|
ips, err := resolveDomainIPsNoSystemFallback(ctx, srv.Client(), prof, "example.test")
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve failed: %v", err)
|
||||||
}
|
}
|
||||||
if len(ips) != 2 {
|
if len(ips) != 2 {
|
||||||
t.Fatalf("want 2 ips, got %v", ips)
|
t.Fatalf("expected 2 addrs, got %+v", ips)
|
||||||
}
|
}
|
||||||
seen := map[string]bool{ips[0].String(): true, ips[1].String(): true}
|
if maxInflight.Load() < 2 {
|
||||||
if !seen["198.51.100.1"] || !seen["203.0.113.1"] {
|
t.Fatalf("expected concurrent A/AAAA queries, max inflight=%d", maxInflight.Load())
|
||||||
t.Fatalf("unexpected ips: %v", ips)
|
}
|
||||||
|
if elapsed >= 2*delay {
|
||||||
|
t.Fatalf("resolve took %v; expected one round-trip (<2*%v)", elapsed, delay)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveDomainIPsWithPolicy_Failover(t *testing.T) {
|
func ptrInt(v int) *int { return &v }
|
||||||
var calls int
|
|
||||||
srvBad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
calls++
|
|
||||||
http.Error(w, "fail", http.StatusBadGateway)
|
|
||||||
}))
|
|
||||||
defer srvBad.Close()
|
|
||||||
srvOK := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
calls++
|
|
||||||
_, _ = w.Write([]byte(`{"Answer":[{"type":1,"data":"198.51.100.5"}]}`))
|
|
||||||
}))
|
|
||||||
defer srvOK.Close()
|
|
||||||
|
|
||||||
profiles := []*store.DohProfile{
|
|
||||||
{URL: srvBad.URL},
|
|
||||||
{URL: srvOK.URL},
|
|
||||||
}
|
|
||||||
ips, err := resolveDomainIPsWithPolicy(context.Background(), srvBad.Client(), profiles, store.DohPolicyFailover, "example.com")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(ips) != 1 || ips[0].String() != "198.51.100.5" {
|
|
||||||
t.Fatalf("unexpected ips: %v", ips)
|
|
||||||
}
|
|
||||||
if calls < 2 {
|
|
||||||
t.Fatalf("want at least 2 resolver calls, got %d", calls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveDomainIPsWithPolicy_PrimaryOnly(t *testing.T) {
|
|
||||||
var secondCalled bool
|
|
||||||
srv1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
_, _ = w.Write([]byte(`{"Answer":[{"type":1,"data":"198.51.100.9"}]}`))
|
|
||||||
}))
|
|
||||||
defer srv1.Close()
|
|
||||||
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
secondCalled = true
|
|
||||||
_, _ = w.Write([]byte(`{"Answer":[{"type":1,"data":"203.0.113.9"}]}`))
|
|
||||||
}))
|
|
||||||
defer srv2.Close()
|
|
||||||
|
|
||||||
profiles := []*store.DohProfile{
|
|
||||||
{URL: srv1.URL},
|
|
||||||
{URL: srv2.URL},
|
|
||||||
}
|
|
||||||
ips, err := resolveDomainIPsWithPolicy(context.Background(), srv1.Client(), profiles, store.DohPolicyPrimaryOnly, "example.com")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(ips) != 1 || ips[0].String() != "198.51.100.9" {
|
|
||||||
t.Fatalf("unexpected ips: %v", ips)
|
|
||||||
}
|
|
||||||
if secondCalled {
|
|
||||||
t.Fatal("secondary resolver must not be queried in primary_only mode")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCollectModulePrefixRows_DohUnion(t *testing.T) {
|
|
||||||
m := store.NewMemory()
|
|
||||||
m.SeedDemo()
|
|
||||||
tenant, _, _, _, _ := m.DemoIDs()
|
|
||||||
|
|
||||||
mod, err := m.CreateModule(tenant, &store.Module{
|
|
||||||
Type: "DOMAINS",
|
|
||||||
Name: "domains-union",
|
|
||||||
Enabled: true,
|
|
||||||
DohResolverPolicy: store.DohPolicyUnion,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
srvRU := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
_, _ = w.Write([]byte(`{"Answer":[{"type":1,"data":"198.51.100.2"}]}`))
|
|
||||||
}))
|
|
||||||
defer srvRU.Close()
|
|
||||||
srvEU := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
_, _ = w.Write([]byte(`{"Answer":[{"type":1,"data":"203.0.113.2"}]}`))
|
|
||||||
}))
|
|
||||||
defer srvEU.Close()
|
|
||||||
|
|
||||||
ru, err := m.CreateDohProfile(tenant, &store.DohProfile{Name: "ru", URL: srvRU.URL})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
eu, err := m.CreateDohProfile(tenant, &store.DohProfile{Name: "eu", URL: srvEU.URL})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := m.UpdateModule(tenant, mod.ID, &store.ModulePatch{
|
|
||||||
DohProfileIDs: &[]string{ru.ID, eu.ID},
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
mod, err = m.GetModule(tenant, mod.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := m.CreateDomainEntry(tenant, mod.ID, &store.DomainEntry{FQDN: "svc.example.com"}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := collectModulePrefixRows(context.Background(), m, srvRU.Client(), tenant, mod, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(rows) != 2 {
|
|
||||||
t.Fatalf("want 2 prefix rows, got %+v", rows)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package pipeline
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"evobgp/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func domainCacheTTL() time.Duration {
|
||||||
|
sec := 300
|
||||||
|
if s := strings.TrimSpace(os.Getenv("EVOBGP_DOMAIN_CACHE_TTL_SEC")); s != "" {
|
||||||
|
if v, err := strconv.Atoi(s); err == nil && v > 0 {
|
||||||
|
sec = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Duration(sec) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveDomainIPsCached(ctx context.Context, st store.Backend, hc *http.Client, profiles []*store.DohProfile, policy, fqdn string) ([]netip.Addr, error) {
|
||||||
|
key := strings.TrimSpace(fqdn)
|
||||||
|
ttl := domainCacheTTL()
|
||||||
|
if st != nil && ttl > 0 {
|
||||||
|
if ent, ok, err := st.GetDomainResolveCache(key); err == nil && ok && ent != nil {
|
||||||
|
if time.Since(ent.ResolvedAt) < ttl {
|
||||||
|
if addrs := parseCachedDomainAddrs(ent.Addrs); len(addrs) > 0 {
|
||||||
|
return addrs, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addrs, err := resolveDomainIPsWithPolicy(ctx, hc, profiles, policy, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if st != nil {
|
||||||
|
_ = st.SetDomainResolveCache(key, domainAddrsToStrings(addrs))
|
||||||
|
}
|
||||||
|
return addrs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCachedDomainAddrs(raw []string) []netip.Addr {
|
||||||
|
out := make([]netip.Addr, 0, len(raw))
|
||||||
|
for _, s := range raw {
|
||||||
|
a, err := netip.ParseAddr(strings.TrimSpace(s))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func domainAddrsToStrings(addrs []netip.Addr) []string {
|
||||||
|
out := make([]string, 0, len(addrs))
|
||||||
|
for _, a := range addrs {
|
||||||
|
if a.IsValid() {
|
||||||
|
out = append(out, a.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -1,10 +1,6 @@
|
|||||||
package pipeline
|
package pipeline
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"evobgp/internal/store"
|
"evobgp/internal/store"
|
||||||
@@ -12,89 +8,7 @@ import (
|
|||||||
|
|
||||||
// moduleIngestInputHash fingerprints module config and child entries so snapshots invalidate on CRUD.
|
// moduleIngestInputHash fingerprints module config and child entries so snapshots invalidate on CRUD.
|
||||||
func moduleIngestInputHash(st store.Backend, tenantID string, mod *store.Module) (string, error) {
|
func moduleIngestInputHash(st store.Backend, tenantID string, mod *store.Module) (string, error) {
|
||||||
if st == nil || mod == nil {
|
return store.ComputeModuleInputHash(st, tenantID, mod)
|
||||||
return "", fmt.Errorf("pipeline: module hash: missing store or module")
|
|
||||||
}
|
|
||||||
h := sha256.New()
|
|
||||||
_, _ = fmt.Fprintf(h, "type=%s\n", strings.TrimSpace(mod.Type))
|
|
||||||
_, _ = fmt.Fprintf(h, "enabled=%t\n", mod.Enabled)
|
|
||||||
if mod.DefaultCommunityID != nil {
|
|
||||||
_, _ = fmt.Fprintf(h, "default_community=%s\n", strings.TrimSpace(*mod.DefaultCommunityID))
|
|
||||||
}
|
|
||||||
_, _ = fmt.Fprintf(h, "doh_policy=%s\n", store.NormalizeDohResolverPolicy(mod.DohResolverPolicy))
|
|
||||||
for _, pid := range mod.EffectiveDohProfileIDs() {
|
|
||||||
_, _ = fmt.Fprintf(h, "doh_profile=%s\n", pid)
|
|
||||||
if prof, err := st.GetDohProfile(tenantID, pid); err == nil && prof != nil {
|
|
||||||
_, _ = fmt.Fprintf(h, "doh_url=%s\n", strings.TrimSpace(prof.URL))
|
|
||||||
if prof.TimeoutMs != nil {
|
|
||||||
_, _ = fmt.Fprintf(h, "doh_timeout=%d\n", *prof.TimeoutMs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch mod.Type {
|
|
||||||
case "IP_RANGES":
|
|
||||||
list, err := st.ListIPRangeEntries(tenantID, mod.ID)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
sort.Slice(list, func(i, j int) bool { return list[i].Prefix < list[j].Prefix })
|
|
||||||
for _, e := range list {
|
|
||||||
comm := ""
|
|
||||||
if e.CommunityID != nil {
|
|
||||||
comm = *e.CommunityID
|
|
||||||
}
|
|
||||||
_, _ = fmt.Fprintf(h, "ip=%s|c=%s\n", e.Prefix, comm)
|
|
||||||
}
|
|
||||||
case "AS_PREFIXES":
|
|
||||||
list, err := st.ListASEntries(tenantID, mod.ID)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
sort.Slice(list, func(i, j int) bool { return list[i].ASN < list[j].ASN })
|
|
||||||
for _, e := range list {
|
|
||||||
comm := ""
|
|
||||||
if e.CommunityID != nil {
|
|
||||||
comm = *e.CommunityID
|
|
||||||
}
|
|
||||||
_, _ = fmt.Fprintf(h, "as=%d|c=%s\n", e.ASN, comm)
|
|
||||||
}
|
|
||||||
case "CDN_CIDRS":
|
|
||||||
list, err := st.ListCDNSources(tenantID, mod.ID)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID })
|
|
||||||
for _, s := range list {
|
|
||||||
comm := ""
|
|
||||||
if s.CommunityID != nil {
|
|
||||||
comm = *s.CommunityID
|
|
||||||
}
|
|
||||||
interval := 0
|
|
||||||
if s.RefreshIntervalSec != nil {
|
|
||||||
interval = *s.RefreshIntervalSec
|
|
||||||
}
|
|
||||||
_, _ = fmt.Fprintf(h, "cdn=%s|url=%s|kind=%s|path=%s|c=%s|etag=%s|interval=%d\n",
|
|
||||||
s.ID, strings.TrimSpace(s.URL), s.SourceKind, strings.TrimSpace(s.PrefixPath), comm,
|
|
||||||
strings.TrimSpace(s.Etag), interval)
|
|
||||||
}
|
|
||||||
case "DOMAINS":
|
|
||||||
list, err := st.ListDomainEntries(tenantID, mod.ID)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
sort.Slice(list, func(i, j int) bool { return list[i].FQDN < list[j].FQDN })
|
|
||||||
for _, e := range list {
|
|
||||||
comm := ""
|
|
||||||
if e.CommunityID != nil {
|
|
||||||
comm = *e.CommunityID
|
|
||||||
}
|
|
||||||
_, _ = fmt.Fprintf(h, "dom=%s|c=%s\n", strings.TrimSpace(e.FQDN), comm)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
_, _ = fmt.Fprintf(h, "unknown_type=%s\n", mod.Type)
|
|
||||||
}
|
|
||||||
return hex.EncodeToString(h.Sum(nil)), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func persistModuleSnapshot(st store.Backend, tenantID string, mod *store.Module, rows []store.PrefixRow) error {
|
func persistModuleSnapshot(st store.Backend, tenantID string, mod *store.Module, rows []store.PrefixRow) error {
|
||||||
@@ -105,18 +19,32 @@ func persistModuleSnapshot(st store.Backend, tenantID string, mod *store.Module,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return st.SetModulePrefixSnapshot(tenantID, mod.ID, hash, rows)
|
if err := st.SetModulePrefixSnapshot(tenantID, mod.ID, hash, rows); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = st.SetModuleInputHash(tenantID, mod.ID, hash)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func moduleRowsFromSnapshot(st store.Backend, tenantID string, mod *store.Module) ([]store.PrefixRow, bool, error) {
|
func moduleRowsFromSnapshot(st store.Backend, tenantID string, mod *store.Module) ([]store.PrefixRow, bool, error) {
|
||||||
|
snap, ok, err := st.GetModulePrefixSnapshot(tenantID, mod.ID)
|
||||||
|
if err != nil || !ok || snap == nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
if h := strings.TrimSpace(mod.InputHash); h != "" {
|
||||||
|
if snap.InputHash != h {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
return append([]store.PrefixRow(nil), snap.Prefixes...), true, nil
|
||||||
|
}
|
||||||
|
// Fallback for rows written before module.input_hash existed: recompute and backfill.
|
||||||
hash, err := moduleIngestInputHash(st, tenantID, mod)
|
hash, err := moduleIngestInputHash(st, tenantID, mod)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
snap, ok, err := st.GetModulePrefixSnapshot(tenantID, mod.ID)
|
if snap.InputHash != hash {
|
||||||
if err != nil || !ok || snap == nil || snap.InputHash != hash {
|
return nil, false, nil
|
||||||
return nil, false, err
|
|
||||||
}
|
}
|
||||||
cp := append([]store.PrefixRow(nil), snap.Prefixes...)
|
_ = st.SetModuleInputHash(tenantID, mod.ID, hash)
|
||||||
return cp, true, nil
|
return append([]store.PrefixRow(nil), snap.Prefixes...), true, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package pipeline
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"evobgp/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestModuleDueForScheduler_JitterSpreads(t *testing.T) {
|
||||||
|
dueTicks := map[int64]int{}
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
mod := &store.Module{
|
||||||
|
ID: fmt.Sprintf("module-%d", i),
|
||||||
|
Enabled: true,
|
||||||
|
Type: "AS_PREFIXES",
|
||||||
|
RefreshIntervalSec: 3600,
|
||||||
|
}
|
||||||
|
for tick := int64(0); tick < 7200; tick += SchedulerTickSec {
|
||||||
|
if ModuleDueForScheduler(mod, time.Unix(tick, 0)) {
|
||||||
|
dueTicks[tick]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(dueTicks) < 10 {
|
||||||
|
t.Fatalf("expected due events across many ticks, got %d buckets", len(dueTicks))
|
||||||
|
}
|
||||||
|
for tick, n := range dueTicks {
|
||||||
|
if n == 100 {
|
||||||
|
t.Fatalf("all 100 modules due on tick %d", tick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectASPrefixRows_KeepsSamePrefixDifferentCommunity(t *testing.T) {
|
||||||
|
st := store.NewMemory()
|
||||||
|
st.SeedDemo()
|
||||||
|
tenant, _, _, _, _ := st.DemoIDs()
|
||||||
|
mod, err := st.CreateModule(tenant, &store.Module{Type: "AS_PREFIXES", Name: "as-dedup", Enabled: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c1, c2 := "comm-a", "comm-b"
|
||||||
|
e1, err := st.CreateASEntry(tenant, mod.ID, &store.ASEntry{ASN: 64500, CommunityID: &c1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
e2, err := st.CreateASEntry(tenant, mod.ID, &store.ASEntry{ASN: 64501, CommunityID: &c2})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.SetASNPrefixCache(64500, "a", []string{"192.0.2.0/24"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.SetASNPrefixCache(64501, "b", []string{"192.0.2.0/24"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rows, err := collectASPrefixRows(context.Background(), st, http.DefaultClient, tenant, mod, []*store.ASEntry{e1, e2}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(rows) != 2 {
|
||||||
|
t.Fatalf("expected 2 rows (same prefix, different community), got %d: %+v", len(rows), rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectCDNPrefixRows_PartialSkipKeepsPrior(t *testing.T) {
|
||||||
|
t.Setenv("EVOBGP_CDN_PARTIAL_OK", "1")
|
||||||
|
t.Setenv("EVOBGP_STALE_ON_UPSTREAM_ERROR", "0")
|
||||||
|
t.Setenv("EVOBGP_CDN_ALLOW_PRIVATE", "1")
|
||||||
|
|
||||||
|
good := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte("198.51.100.0/24\n"))
|
||||||
|
}))
|
||||||
|
defer good.Close()
|
||||||
|
bad := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "down", http.StatusServiceUnavailable)
|
||||||
|
}))
|
||||||
|
defer bad.Close()
|
||||||
|
|
||||||
|
st := store.NewMemory()
|
||||||
|
st.SeedDemo()
|
||||||
|
tenant, _, _, _, _ := st.DemoIDs()
|
||||||
|
mod, err := st.CreateModule(tenant, &store.Module{Type: "CDN_CIDRS", Name: "cdn-partial", Enabled: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
prior := []store.PrefixRow{
|
||||||
|
{Prefix: "203.0.113.0/24", Source: "cdn:bad"},
|
||||||
|
{Prefix: "1.2.3.0/24", Source: "cdn:good"},
|
||||||
|
}
|
||||||
|
sources := []*store.CDNSource{
|
||||||
|
{ID: "good", URL: good.URL, SourceKind: "plain"},
|
||||||
|
{ID: "bad", URL: bad.URL, SourceKind: "plain"},
|
||||||
|
}
|
||||||
|
rows, err := collectCDNPrefixRows(context.Background(), st, good.Client(), tenant, mod, sources, prior)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("partial skip should succeed: %v", err)
|
||||||
|
}
|
||||||
|
got := map[string]string{}
|
||||||
|
for _, r := range rows {
|
||||||
|
got[r.Source] = r.Prefix
|
||||||
|
}
|
||||||
|
if got["cdn:bad"] != "203.0.113.0/24" {
|
||||||
|
t.Fatalf("skipped source lost prior row: %+v", rows)
|
||||||
|
}
|
||||||
|
if got["cdn:good"] != "198.51.100.0/24" {
|
||||||
|
t.Fatalf("fetched source missing new row: %+v", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeCDNSource_ParallelNoLostUpdate(t *testing.T) {
|
||||||
|
st := store.NewMemory()
|
||||||
|
st.SeedDemo()
|
||||||
|
tenant, _, _, _, _ := st.DemoIDs()
|
||||||
|
mod, err := st.CreateModule(tenant, &store.Module{Type: "CDN_CIDRS", Name: "cdn-lock", Enabled: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
_ = mergeCDNSourceIntoModuleSnapshot(st, tenant, mod, "s1", []store.PrefixRow{
|
||||||
|
{Prefix: "1.0.0.0/24", Source: "cdn:s1"},
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
_ = mergeCDNSourceIntoModuleSnapshot(st, tenant, mod, "s2", []store.PrefixRow{
|
||||||
|
{Prefix: "2.0.0.0/24", Source: "cdn:s2"},
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
wg.Wait()
|
||||||
|
snap, ok, err := st.GetModulePrefixSnapshot(tenant, mod.ID)
|
||||||
|
if err != nil || !ok || snap == nil {
|
||||||
|
t.Fatalf("snapshot missing: ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
got := map[string]bool{}
|
||||||
|
for _, r := range snap.Prefixes {
|
||||||
|
got[r.Prefix] = true
|
||||||
|
}
|
||||||
|
if !got["1.0.0.0/24"] || !got["2.0.0.0/24"] {
|
||||||
|
t.Fatalf("lost parallel merge update: %+v", snap.Prefixes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveDomainIPsCached_UsesTTL(t *testing.T) {
|
||||||
|
t.Setenv("EVOBGP_DOMAIN_CACHE_TTL_SEC", "300")
|
||||||
|
st := store.NewMemory()
|
||||||
|
if err := st.SetDomainResolveCache("cached.test", []string{"192.0.2.9"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
addrs, err := resolveDomainIPsCached(context.Background(), st, nil, nil, "", "cached.test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(addrs) != 1 || addrs[0].String() != "192.0.2.9" {
|
||||||
|
t.Fatalf("expected cached addr, got %v", addrs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInputHashInvalidatedOnEntryCRUD(t *testing.T) {
|
||||||
|
st := store.NewMemory()
|
||||||
|
st.SeedDemo()
|
||||||
|
tenant, _, _, _, _ := st.DemoIDs()
|
||||||
|
mod, err := st.CreateModule(tenant, &store.Module{Type: "IP_RANGES", Name: "ip-hash", Enabled: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.SetModuleInputHash(tenant, mod.ID, "pre"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := st.CreateIPRangeEntry(tenant, mod.ID, &store.IPRangeEntry{Prefix: "10.0.0.0/8"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := st.GetModule(tenant, mod.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.InputHash != "" {
|
||||||
|
t.Fatalf("expected hash cleared after CRUD, got %q", got.InputHash)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ package pipeline
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -31,6 +30,7 @@ func PrefetchCDNSourceETags(ctx context.Context, st store.Backend, hc *http.Clie
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var tasks []prefetchTask
|
var tasks []prefetchTask
|
||||||
|
now := time.Now().UTC()
|
||||||
for _, tid := range tenants {
|
for _, tid := range tenants {
|
||||||
for _, mod := range st.ListModules(tid) {
|
for _, mod := range st.ListModules(tid) {
|
||||||
if mod == nil || !mod.Enabled || mod.Type != "CDN_CIDRS" {
|
if mod == nil || !mod.Enabled || mod.Type != "CDN_CIDRS" {
|
||||||
@@ -41,9 +41,15 @@ func PrefetchCDNSourceETags(ctx context.Context, st store.Backend, hc *http.Clie
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, src := range sources {
|
for _, src := range sources {
|
||||||
if src != nil && strings.TrimSpace(src.URL) != "" {
|
if src == nil || strings.TrimSpace(src.URL) == "" {
|
||||||
tasks = append(tasks, prefetchTask{tenantID: tid, mod: mod, src: src})
|
continue
|
||||||
}
|
}
|
||||||
|
// Respect per-source refresh intervals: conditional GET only for due sources.
|
||||||
|
// The ETag probe still lets 304s skip body downloads for the rest.
|
||||||
|
if shouldSkipCDNSourceFetch(src, now) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tasks = append(tasks, prefetchTask{tenantID: tid, mod: mod, src: src})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,53 +74,13 @@ func PrefetchCDNSourceETags(ctx context.Context, st store.Backend, hc *http.Clie
|
|||||||
func prefetchOneCDNSource(ctx context.Context, st store.Backend, hc *http.Client, t prefetchTask) {
|
func prefetchOneCDNSource(ctx context.Context, st store.Backend, hc *http.Client, t prefetchTask) {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
tid, mod, src := t.tenantID, t.mod, t.src
|
tid, mod, src := t.tenantID, t.mod, t.src
|
||||||
u := strings.TrimSpace(src.URL)
|
|
||||||
if _, err := ValidateCDNURL(u); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := ResolveCDNURLHost(ctx, u); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
omod, err := st.GetModule(tid, mod.ID)
|
omod, err := st.GetModule(tid, mod.ID)
|
||||||
|
if err != nil || omod == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := fetchCDNSourceRows(ctx, st, hc, tid, mod.ID, omod, src, nil, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if etag := strings.TrimSpace(src.Etag); etag != "" {
|
|
||||||
req.Header.Set("If-None-Match", etag)
|
|
||||||
}
|
|
||||||
resp, err := upstreamHTTPDo(ctx, hc, req)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if resp.StatusCode == http.StatusNotModified {
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
_, _ = io.Copy(io.Discard, resp.Body)
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
prefixStrs, err := parseCDNBody(string(body), src)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
newEtag := strings.TrimSpace(resp.Header.Get("ETag"))
|
|
||||||
patch := &store.CDNSourcePatch{LastRefreshedAt: &now}
|
|
||||||
if newEtag != "" && newEtag != strings.TrimSpace(src.Etag) {
|
|
||||||
e := newEtag
|
|
||||||
patch.Etag = &e
|
|
||||||
}
|
|
||||||
_, _ = st.UpdateCDNSource(tid, mod.ID, src.ID, patch)
|
|
||||||
rows := cdnRowsFromParsed(omod, src, prefixStrs)
|
|
||||||
_ = mergeCDNSourceIntoModuleSnapshot(st, tid, omod, src.ID, rows)
|
_ = mergeCDNSourceIntoModuleSnapshot(st, tid, omod, src.ID, rows)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math/big"
|
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
@@ -190,7 +189,7 @@ func collectModulePrefixRows(ctx context.Context, st store.Backend, hc *http.Cli
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return collectDomainPrefixRows(ctx, hc, mod, profiles, policy, entries, priorSnapshot)
|
return collectDomainPrefixRows(ctx, st, hc, mod, profiles, policy, entries, priorSnapshot)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("pipeline: unknown module type %q", mod.Type)
|
return nil, fmt.Errorf("pipeline: unknown module type %q", mod.Type)
|
||||||
}
|
}
|
||||||
@@ -230,9 +229,8 @@ func resolveDomainIPs(ctx context.Context, hc *http.Client, profile *store.DohPr
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
baseURL := strings.TrimSpace(profile.URL)
|
baseURL := strings.TrimSpace(profile.URL)
|
||||||
// Prefer RFC8484 dns-message transport. Some providers don't support dns-json.
|
// A and AAAA queries run concurrently: per-domain latency drops from ~2×RTT to ~1×RTT.
|
||||||
v4, err4 := resolveDomainWithDOHMessage(dctx, hc, baseURL, host, dns.TypeA)
|
v4, v6, err4, err6 := resolveDOHMessagePair(dctx, hc, baseURL, host)
|
||||||
v6, err6 := resolveDomainWithDOHMessage(dctx, hc, baseURL, host, dns.TypeAAAA)
|
|
||||||
if err4 != nil {
|
if err4 != nil {
|
||||||
// Fallback to JSON mode for providers that only expose dns-json.
|
// Fallback to JSON mode for providers that only expose dns-json.
|
||||||
v4, err4 = resolveDomainWithDOHJSON(dctx, hc, baseURL, host, "A")
|
v4, err4 = resolveDomainWithDOHJSON(dctx, hc, baseURL, host, "A")
|
||||||
@@ -496,168 +494,11 @@ func prefixRowCommunity(r store.PrefixRow) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func aggregateIPv4Group(rows []store.PrefixRow) []store.PrefixRow {
|
func aggregateIPv4Group(rows []store.PrefixRow) []store.PrefixRow {
|
||||||
return aggregateCIDRGroup(rows, mergeSiblingPrefixesIPv4)
|
return collapsePrefixGroup(rows, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func aggregateIPv6Group(rows []store.PrefixRow) []store.PrefixRow {
|
func aggregateIPv6Group(rows []store.PrefixRow) []store.PrefixRow {
|
||||||
return aggregateCIDRGroup(rows, mergeSiblingPrefixesIPv6)
|
return collapsePrefixGroup(rows, false)
|
||||||
}
|
|
||||||
|
|
||||||
func aggregateCIDRGroup(rows []store.PrefixRow, mergeFn func(map[string]store.PrefixRow) bool) []store.PrefixRow {
|
|
||||||
if len(rows) <= 1 {
|
|
||||||
return rows
|
|
||||||
}
|
|
||||||
set := make(map[string]store.PrefixRow, len(rows))
|
|
||||||
for _, row := range rows {
|
|
||||||
set[row.Prefix] = row
|
|
||||||
}
|
|
||||||
pruneCoveredPrefixes(set)
|
|
||||||
for {
|
|
||||||
if !mergeFn(set) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
pruneCoveredPrefixes(set)
|
|
||||||
}
|
|
||||||
out := make([]store.PrefixRow, 0, len(set))
|
|
||||||
for _, row := range set {
|
|
||||||
out = append(out, row)
|
|
||||||
}
|
|
||||||
sortPrefixRows(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func pruneCoveredPrefixes(set map[string]store.PrefixRow) {
|
|
||||||
type item struct {
|
|
||||||
key string
|
|
||||||
pfx netip.Prefix
|
|
||||||
bits int
|
|
||||||
}
|
|
||||||
items := make([]item, 0, len(set))
|
|
||||||
for k := range set {
|
|
||||||
p, err := netip.ParsePrefix(k)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
items = append(items, item{key: k, pfx: p, bits: p.Bits()})
|
|
||||||
}
|
|
||||||
sort.Slice(items, func(i, j int) bool {
|
|
||||||
if items[i].bits != items[j].bits {
|
|
||||||
return items[i].bits < items[j].bits
|
|
||||||
}
|
|
||||||
return items[i].key < items[j].key
|
|
||||||
})
|
|
||||||
for i := 0; i < len(items); i++ {
|
|
||||||
for j := i + 1; j < len(items); j++ {
|
|
||||||
if items[j].bits <= items[i].bits {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if items[i].pfx.Contains(items[j].pfx.Addr()) {
|
|
||||||
delete(set, items[j].key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mergeSiblingPrefixesIPv4(set map[string]store.PrefixRow) bool {
|
|
||||||
merged := false
|
|
||||||
seen := make(map[string]struct{}, len(set))
|
|
||||||
for key, row := range set {
|
|
||||||
if _, done := seen[key]; done {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
pfx, err := netip.ParsePrefix(key)
|
|
||||||
if err != nil || !pfx.Addr().Is4() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
bits := pfx.Bits()
|
|
||||||
if bits <= 8 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
netNum := ipv4PrefixNetwork(pfx)
|
|
||||||
blockSize := uint32(1) << (32 - bits)
|
|
||||||
siblingNet := netNum ^ blockSize
|
|
||||||
siblingPfx := netip.PrefixFrom(u32ToIPv4(siblingNet), bits).Masked().String()
|
|
||||||
_, ok := set[siblingPfx]
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
parentBits := bits - 1
|
|
||||||
parentBlock := uint32(1) << (32 - parentBits)
|
|
||||||
parentNet := netNum & ^(parentBlock - 1)
|
|
||||||
parentPfx := netip.PrefixFrom(u32ToIPv4(parentNet), parentBits).Masked().String()
|
|
||||||
delete(set, key)
|
|
||||||
delete(set, siblingPfx)
|
|
||||||
parentRow := row
|
|
||||||
parentRow.Prefix = parentPfx
|
|
||||||
set[parentPfx] = parentRow
|
|
||||||
seen[key] = struct{}{}
|
|
||||||
seen[siblingPfx] = struct{}{}
|
|
||||||
merged = true
|
|
||||||
}
|
|
||||||
return merged
|
|
||||||
}
|
|
||||||
|
|
||||||
func ipv4PrefixNetwork(p netip.Prefix) uint32 {
|
|
||||||
a := p.Masked().Addr().As4()
|
|
||||||
return uint32(a[0])<<24 | uint32(a[1])<<16 | uint32(a[2])<<8 | uint32(a[3])
|
|
||||||
}
|
|
||||||
|
|
||||||
func u32ToIPv4(v uint32) netip.Addr {
|
|
||||||
return netip.AddrFrom4([4]byte{byte(v >> 24), byte(v >> 16), byte(v >> 8), byte(v)})
|
|
||||||
}
|
|
||||||
|
|
||||||
func mergeSiblingPrefixesIPv6(set map[string]store.PrefixRow) bool {
|
|
||||||
merged := false
|
|
||||||
seen := make(map[string]struct{}, len(set))
|
|
||||||
for key, row := range set {
|
|
||||||
if _, done := seen[key]; done {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
pfx, err := netip.ParsePrefix(key)
|
|
||||||
if err != nil || !pfx.Addr().Is6() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
bits := pfx.Bits()
|
|
||||||
if bits <= 16 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
netNum := ipv6PrefixNetwork(pfx)
|
|
||||||
blockSize := new(big.Int).Lsh(big.NewInt(1), uint(128-bits))
|
|
||||||
siblingNet := new(big.Int).Xor(netNum, blockSize)
|
|
||||||
siblingPfx := ipv6PrefixFromBigInt(siblingNet, bits).String()
|
|
||||||
if _, ok := set[siblingPfx]; !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
parentBits := bits - 1
|
|
||||||
parentBlock := new(big.Int).Lsh(big.NewInt(1), uint(128-parentBits))
|
|
||||||
mask := new(big.Int).Sub(parentBlock, big.NewInt(1))
|
|
||||||
mask.Not(mask)
|
|
||||||
parentNet := new(big.Int).And(netNum, mask)
|
|
||||||
parentPfx := ipv6PrefixFromBigInt(parentNet, parentBits).String()
|
|
||||||
delete(set, key)
|
|
||||||
delete(set, siblingPfx)
|
|
||||||
parentRow := row
|
|
||||||
parentRow.Prefix = parentPfx
|
|
||||||
set[parentPfx] = parentRow
|
|
||||||
seen[key] = struct{}{}
|
|
||||||
seen[siblingPfx] = struct{}{}
|
|
||||||
merged = true
|
|
||||||
}
|
|
||||||
return merged
|
|
||||||
}
|
|
||||||
|
|
||||||
func ipv6PrefixNetwork(p netip.Prefix) *big.Int {
|
|
||||||
a := p.Masked().Addr().As16()
|
|
||||||
n := new(big.Int)
|
|
||||||
n.SetBytes(a[:])
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
func ipv6PrefixFromBigInt(n *big.Int, bits int) netip.Prefix {
|
|
||||||
b := n.Bytes()
|
|
||||||
var a [16]byte
|
|
||||||
copy(a[16-len(b):], b)
|
|
||||||
return netip.PrefixFrom(netip.AddrFrom16(a), bits).Masked()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func parentRevision(st store.Backend, tenantID, moduleID string) *string {
|
func parentRevision(st store.Backend, tenantID, moduleID string) *string {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package pipeline
|
package pipeline
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"hash/fnv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"evobgp/internal/store"
|
"evobgp/internal/store"
|
||||||
@@ -10,6 +11,8 @@ import (
|
|||||||
const SchedulerTickSec = 30
|
const SchedulerTickSec = 30
|
||||||
|
|
||||||
// ModuleDueForScheduler reports whether a module's refresh interval bucket rolled since the last scheduler tick.
|
// ModuleDueForScheduler reports whether a module's refresh interval bucket rolled since the last scheduler tick.
|
||||||
|
// A stable per-module offset (fnv32 of ID) spreads bucket boundaries so modules with the same interval
|
||||||
|
// do not all become due on the same tick (thundering herd).
|
||||||
func ModuleDueForScheduler(mod *store.Module, now time.Time) bool {
|
func ModuleDueForScheduler(mod *store.Module, now time.Time) bool {
|
||||||
if mod == nil || !mod.Enabled || mod.Type == "IP_RANGES" || mod.RefreshIntervalSec <= 0 {
|
if mod == nil || !mod.Enabled || mod.Type == "IP_RANGES" || mod.RefreshIntervalSec <= 0 {
|
||||||
return false
|
return false
|
||||||
@@ -18,7 +21,21 @@ func ModuleDueForScheduler(mod *store.Module, now time.Time) bool {
|
|||||||
if win < 60 {
|
if win < 60 {
|
||||||
win = 60
|
win = 60
|
||||||
}
|
}
|
||||||
cur := now.Unix() / win
|
offset := moduleSchedulerOffset(mod.ID, win)
|
||||||
prev := (now.Unix() - SchedulerTickSec) / win
|
cur := (now.Unix() - offset) / win
|
||||||
|
prev := (now.Unix() - offset - SchedulerTickSec) / win
|
||||||
return cur != prev
|
return cur != prev
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func moduleSchedulerOffset(moduleID string, win int64) int64 {
|
||||||
|
if win <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return int64(fnv32a(moduleID) % uint32(win))
|
||||||
|
}
|
||||||
|
|
||||||
|
func fnv32a(s string) uint32 {
|
||||||
|
h := fnv.New32a()
|
||||||
|
_, _ = h.Write([]byte(s))
|
||||||
|
return h.Sum32()
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"evobgp/internal/store"
|
"evobgp/internal/store"
|
||||||
@@ -11,13 +12,26 @@ import (
|
|||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// asnCacheRowExistsOnce caches the schema check for the process lifetime (see moduleSnapshotRowTableExists).
|
||||||
|
var (
|
||||||
|
asnCacheRowExistsOnce sync.Once
|
||||||
|
asnCacheRowExistsCached bool
|
||||||
|
)
|
||||||
|
|
||||||
func asnCacheRowTableExists(ctx context.Context, q queryRower) bool {
|
func asnCacheRowTableExists(ctx context.Context, q queryRower) bool {
|
||||||
|
asnCacheRowExistsOnce.Do(func() {
|
||||||
var n int
|
var n int
|
||||||
err := q.QueryRow(ctx, `
|
err := q.QueryRow(ctx, `
|
||||||
SELECT 1 FROM information_schema.tables
|
SELECT 1 FROM information_schema.tables
|
||||||
WHERE table_schema = 'public' AND table_name = 'asn_prefix_cache_row'
|
WHERE table_schema = 'public' AND table_name = 'asn_prefix_cache_row'
|
||||||
LIMIT 1`).Scan(&n)
|
LIMIT 1`).Scan(&n)
|
||||||
return err == nil
|
if err != nil {
|
||||||
|
asnCacheRowExistsOnce = sync.Once{}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
asnCacheRowExistsCached = true
|
||||||
|
})
|
||||||
|
return asnCacheRowExistsCached
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Postgres) GetASNPrefixCache(asn int64) (*store.ASNPrefixCacheEntry, bool, error) {
|
func (p *Postgres) GetASNPrefixCache(asn int64) (*store.ASNPrefixCacheEntry, bool, error) {
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"evobgp/internal/store"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (p *Postgres) GetDomainResolveCache(fqdn string) (*store.DomainResolveCacheEntry, bool, error) {
|
||||||
|
key := strings.ToLower(strings.TrimSpace(fqdn))
|
||||||
|
if key == "" {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
var raw []byte
|
||||||
|
var resolvedAt time.Time
|
||||||
|
err := p.pool.QueryRow(ctx, `
|
||||||
|
SELECT addrs_json, resolved_at FROM domain_resolve_cache WHERE fqdn = $1`, key).
|
||||||
|
Scan(&raw, &resolvedAt)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
var addrs []string
|
||||||
|
if len(raw) > 0 {
|
||||||
|
_ = json.Unmarshal(raw, &addrs)
|
||||||
|
}
|
||||||
|
return &store.DomainResolveCacheEntry{
|
||||||
|
FQDN: key,
|
||||||
|
Addrs: addrs,
|
||||||
|
ResolvedAt: resolvedAt.UTC(),
|
||||||
|
}, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Postgres) SetDomainResolveCache(fqdn string, addrs []string) error {
|
||||||
|
key := strings.ToLower(strings.TrimSpace(fqdn))
|
||||||
|
if key == "" {
|
||||||
|
return store.ErrInvalidInput
|
||||||
|
}
|
||||||
|
if addrs == nil {
|
||||||
|
addrs = []string{}
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(addrs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
_, err = p.pool.Exec(ctx, `
|
||||||
|
INSERT INTO domain_resolve_cache (fqdn, addrs_json, resolved_at)
|
||||||
|
VALUES ($1, $2::jsonb, now())
|
||||||
|
ON CONFLICT (fqdn) DO UPDATE SET
|
||||||
|
addrs_json = EXCLUDED.addrs_json,
|
||||||
|
resolved_at = EXCLUDED.resolved_at`,
|
||||||
|
key, string(raw))
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"evobgp/internal/store"
|
"evobgp/internal/store"
|
||||||
@@ -12,13 +13,29 @@ import (
|
|||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// moduleSnapshotRowExistsOnce caches the schema check for the process lifetime.
|
||||||
|
// The table is created by migrations and never disappears at runtime; DB errors are
|
||||||
|
// not cached so a transient outage falls back to the JSON path only once.
|
||||||
|
var moduleSnapshotRowExistsOnce sync.Once
|
||||||
|
|
||||||
|
var moduleSnapshotRowExistsCached bool
|
||||||
|
|
||||||
func moduleSnapshotRowTableExists(ctx context.Context, q queryRower) bool {
|
func moduleSnapshotRowTableExists(ctx context.Context, q queryRower) bool {
|
||||||
|
moduleSnapshotRowExistsOnce.Do(func() {
|
||||||
var n int
|
var n int
|
||||||
err := q.QueryRow(ctx, `
|
err := q.QueryRow(ctx, `
|
||||||
SELECT 1 FROM information_schema.tables
|
SELECT 1 FROM information_schema.tables
|
||||||
WHERE table_schema = 'public' AND table_name = 'module_prefix_snapshot_row'
|
WHERE table_schema = 'public' AND table_name = 'module_prefix_snapshot_row'
|
||||||
LIMIT 1`).Scan(&n)
|
LIMIT 1`).Scan(&n)
|
||||||
return err == nil
|
// A query error means the backend is unreachable or information_schema is hidden;
|
||||||
|
// treat as "absent" so callers fall back to prefixes_json, but retry next call.
|
||||||
|
if err != nil {
|
||||||
|
moduleSnapshotRowExistsOnce = sync.Once{}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
moduleSnapshotRowExistsCached = true
|
||||||
|
})
|
||||||
|
return moduleSnapshotRowExistsCached
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Postgres) GetModulePrefixSnapshot(tenantID, moduleID string) (*store.ModulePrefixSnapshot, bool, error) {
|
func (p *Postgres) GetModulePrefixSnapshot(tenantID, moduleID string) (*store.ModulePrefixSnapshot, bool, error) {
|
||||||
@@ -97,7 +114,13 @@ func (p *Postgres) SetModulePrefixSnapshot(tenantID, moduleID, inputHash string,
|
|||||||
WHERE tenant_id = $1::uuid AND module_id = $2::uuid`, tenantID, moduleID); err != nil {
|
WHERE tenant_id = $1::uuid AND module_id = $2::uuid`, tenantID, moduleID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for i, pr := range prefixes {
|
if len(prefixes) > 0 {
|
||||||
|
_, err = tx.CopyFrom(
|
||||||
|
ctx,
|
||||||
|
pgx.Identifier{"module_prefix_snapshot_row"},
|
||||||
|
[]string{"tenant_id", "module_id", "ord", "prefix", "community_id", "source"},
|
||||||
|
pgx.CopyFromSlice(len(prefixes), func(i int) ([]any, error) {
|
||||||
|
pr := prefixes[i]
|
||||||
var comm any
|
var comm any
|
||||||
if pr.CommunityID != nil && strings.TrimSpace(*pr.CommunityID) != "" {
|
if pr.CommunityID != nil && strings.TrimSpace(*pr.CommunityID) != "" {
|
||||||
comm = strings.TrimSpace(*pr.CommunityID)
|
comm = strings.TrimSpace(*pr.CommunityID)
|
||||||
@@ -106,10 +129,10 @@ func (p *Postgres) SetModulePrefixSnapshot(tenantID, moduleID, inputHash string,
|
|||||||
if strings.TrimSpace(src) == "" {
|
if strings.TrimSpace(src) == "" {
|
||||||
src = "render"
|
src = "render"
|
||||||
}
|
}
|
||||||
if _, err := tx.Exec(ctx, `
|
return []any{tenantID, moduleID, i, strings.TrimSpace(pr.Prefix), comm, src}, nil
|
||||||
INSERT INTO module_prefix_snapshot_row (tenant_id, module_id, ord, prefix, community_id, source)
|
}),
|
||||||
VALUES ($1::uuid, $2::uuid, $3, $4, $5::uuid, $6)`,
|
)
|
||||||
tenantID, moduleID, i, strings.TrimSpace(pr.Prefix), comm, src); err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,3 +158,35 @@ func (p *Postgres) DeleteModulePrefixSnapshot(tenantID, moduleID string) error {
|
|||||||
tenantID, moduleID)
|
tenantID, moduleID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *Postgres) SetModuleInputHash(tenantID, moduleID, hash string) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
tag, err := p.pool.Exec(ctx, `
|
||||||
|
UPDATE module SET input_hash = $3
|
||||||
|
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL`,
|
||||||
|
moduleID, tenantID, hash)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return store.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Postgres) LockModuleSnapshot(tenantID, moduleID string) func() {
|
||||||
|
ctx := context.Background()
|
||||||
|
conn, err := p.pool.Acquire(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return func() {}
|
||||||
|
}
|
||||||
|
key := strings.TrimSpace(tenantID) + "\x00" + strings.TrimSpace(moduleID)
|
||||||
|
if _, err := conn.Exec(ctx, `SELECT pg_advisory_lock(hashtext($1))`, key); err != nil {
|
||||||
|
conn.Release()
|
||||||
|
return func() {}
|
||||||
|
}
|
||||||
|
return func() {
|
||||||
|
_, _ = conn.Exec(ctx, `SELECT pg_advisory_unlock(hashtext($1))`, key)
|
||||||
|
conn.Release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -119,7 +119,8 @@ func (p *Postgres) ListModules(tenantID string) []*store.Module {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
rows, err := p.pool.Query(ctx, `
|
rows, err := p.pool.Query(ctx, `
|
||||||
SELECT id, type, name, enabled, priority, doh_profile_id::text, doh_resolver_policy,
|
SELECT id, type, name, enabled, priority, doh_profile_id::text, doh_resolver_policy,
|
||||||
refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at, created_by_user_id
|
refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at, created_by_user_id,
|
||||||
|
COALESCE(input_hash, '')
|
||||||
FROM module WHERE tenant_id = $1 AND deleted_at IS NULL ORDER BY priority, name`, tenantID)
|
FROM module WHERE tenant_id = $1 AND deleted_at IS NULL ORDER BY priority, name`, tenantID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -134,7 +135,7 @@ func (p *Postgres) ListModules(tenantID string) []*store.Module {
|
|||||||
var refresh *int32
|
var refresh *int32
|
||||||
var last *time.Time
|
var last *time.Time
|
||||||
var createdBy *string
|
var createdBy *string
|
||||||
if err := rows.Scan(&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &m.DohResolverPolicy, &refresh, &cron, &dc, &last, &createdBy); err != nil {
|
if err := rows.Scan(&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &m.DohResolverPolicy, &refresh, &cron, &dc, &last, &createdBy, &m.InputHash); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
m.DohResolverPolicy = store.NormalizeDohResolverPolicy(m.DohResolverPolicy)
|
m.DohResolverPolicy = store.NormalizeDohResolverPolicy(m.DohResolverPolicy)
|
||||||
@@ -179,7 +180,8 @@ func (p *Postgres) ListModulesPage(tenantID, cursor string, limit int) ([]*store
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
rows, err := p.pool.Query(ctx, `
|
rows, err := p.pool.Query(ctx, `
|
||||||
SELECT id, type, name, enabled, priority, doh_profile_id::text, doh_resolver_policy,
|
SELECT id, type, name, enabled, priority, doh_profile_id::text, doh_resolver_policy,
|
||||||
refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at, created_by_user_id
|
refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at, created_by_user_id,
|
||||||
|
COALESCE(input_hash, '')
|
||||||
FROM module WHERE tenant_id = $1 AND deleted_at IS NULL
|
FROM module WHERE tenant_id = $1 AND deleted_at IS NULL
|
||||||
ORDER BY priority, name
|
ORDER BY priority, name
|
||||||
LIMIT $2 OFFSET $3`, tenantID, limit+1, off)
|
LIMIT $2 OFFSET $3`, tenantID, limit+1, off)
|
||||||
@@ -196,7 +198,7 @@ func (p *Postgres) ListModulesPage(tenantID, cursor string, limit int) ([]*store
|
|||||||
var refresh *int32
|
var refresh *int32
|
||||||
var last *time.Time
|
var last *time.Time
|
||||||
var createdBy *string
|
var createdBy *string
|
||||||
if err := rows.Scan(&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &m.DohResolverPolicy, &refresh, &cron, &dc, &last, &createdBy); err != nil {
|
if err := rows.Scan(&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &m.DohResolverPolicy, &refresh, &cron, &dc, &last, &createdBy, &m.InputHash); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
m.DohResolverPolicy = store.NormalizeDohResolverPolicy(m.DohResolverPolicy)
|
m.DohResolverPolicy = store.NormalizeDohResolverPolicy(m.DohResolverPolicy)
|
||||||
@@ -249,9 +251,10 @@ func (p *Postgres) GetModule(tenantID, moduleID string) (*store.Module, error) {
|
|||||||
var createdBy *string
|
var createdBy *string
|
||||||
err := p.pool.QueryRow(ctx, `
|
err := p.pool.QueryRow(ctx, `
|
||||||
SELECT id, type, name, enabled, priority, doh_profile_id::text, doh_resolver_policy,
|
SELECT id, type, name, enabled, priority, doh_profile_id::text, doh_resolver_policy,
|
||||||
refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at, created_by_user_id
|
refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at, created_by_user_id,
|
||||||
|
COALESCE(input_hash, '')
|
||||||
FROM module WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL`, moduleID, tenantID).Scan(
|
FROM module WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL`, moduleID, tenantID).Scan(
|
||||||
&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &m.DohResolverPolicy, &refresh, &cron, &dc, &last, &createdBy)
|
&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &m.DohResolverPolicy, &refresh, &cron, &dc, &last, &createdBy, &m.InputHash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, store.ErrNotFound
|
return nil, store.ErrNotFound
|
||||||
@@ -325,9 +328,19 @@ func (p *Postgres) CreateModule(tenantID string, in *store.Module) (*store.Modul
|
|||||||
if err := p.setModuleDohProfiles(ctx, id, in.DohProfileIDs); err != nil {
|
if err := p.setModuleDohProfiles(ctx, id, in.DohProfileIDs); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
store.TouchModuleInputHash(p, tenantID, id)
|
||||||
return p.GetModule(tenantID, id)
|
return p.GetModule(tenantID, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *Postgres) invalidateModuleInputHash(tenantID, moduleID string) {
|
||||||
|
_ = p.SetModuleInputHash(tenantID, moduleID, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Postgres) invalidateTenantModuleHashes(tenantID string) {
|
||||||
|
ctx := context.Background()
|
||||||
|
_, _ = p.pool.Exec(ctx, `UPDATE module SET input_hash = '' WHERE tenant_id = $1 AND deleted_at IS NULL`, tenantID)
|
||||||
|
}
|
||||||
|
|
||||||
func (p *Postgres) UpdateModule(tenantID, moduleID string, patch *store.ModulePatch) (*store.Module, error) {
|
func (p *Postgres) UpdateModule(tenantID, moduleID string, patch *store.ModulePatch) (*store.Module, error) {
|
||||||
if patch == nil {
|
if patch == nil {
|
||||||
return nil, store.ErrInvalidInput
|
return nil, store.ErrInvalidInput
|
||||||
@@ -399,6 +412,7 @@ func (p *Postgres) UpdateModule(tenantID, moduleID string, patch *store.ModulePa
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
p.invalidateModuleInputHash(tenantID, moduleID)
|
||||||
return p.GetModule(tenantID, moduleID)
|
return p.GetModule(tenantID, moduleID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1191,6 +1205,7 @@ func (p *Postgres) UpdateDohProfile(tenantID, id string, patch *store.DohProfile
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
p.invalidateTenantModuleHashes(tenantID)
|
||||||
return p.GetDohProfile(tenantID, id)
|
return p.GetDohProfile(tenantID, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ func (p *Postgres) CreateCDNSource(tenantID, moduleID string, in *store.CDNSourc
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
p.invalidateModuleInputHash(tenantID, moduleID)
|
||||||
return p.getCDNSource(ctx, moduleID, id)
|
return p.getCDNSource(ctx, moduleID, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +155,9 @@ func (p *Postgres) UpdateCDNSource(tenantID, moduleID, sourceID string, patch *s
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if store.CDNPatchAffectsInputHash(patch) {
|
||||||
|
p.invalidateModuleInputHash(tenantID, moduleID)
|
||||||
|
}
|
||||||
return p.getCDNSource(ctx, moduleID, sourceID)
|
return p.getCDNSource(ctx, moduleID, sourceID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,6 +173,7 @@ func (p *Postgres) DeleteCDNSource(tenantID, moduleID, sourceID string) error {
|
|||||||
if tag.RowsAffected() == 0 {
|
if tag.RowsAffected() == 0 {
|
||||||
return store.ErrNotFound
|
return store.ErrNotFound
|
||||||
}
|
}
|
||||||
|
p.invalidateModuleInputHash(tenantID, moduleID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,6 +234,7 @@ func (p *Postgres) CreateASEntry(tenantID, moduleID string, in *store.ASEntry) (
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
p.invalidateModuleInputHash(tenantID, moduleID)
|
||||||
return p.getASEntry(ctx, moduleID, id)
|
return p.getASEntry(ctx, moduleID, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,6 +303,7 @@ func (p *Postgres) UpdateASEntry(tenantID, moduleID, entryID string, patch *stor
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
p.invalidateModuleInputHash(tenantID, moduleID)
|
||||||
return p.getASEntry(ctx, moduleID, entryID)
|
return p.getASEntry(ctx, moduleID, entryID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,6 +376,7 @@ func (p *Postgres) DeleteASEntry(tenantID, moduleID, entryID string) error {
|
|||||||
if tag.RowsAffected() == 0 {
|
if tag.RowsAffected() == 0 {
|
||||||
return store.ErrNotFound
|
return store.ErrNotFound
|
||||||
}
|
}
|
||||||
|
p.invalidateModuleInputHash(tenantID, moduleID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,6 +425,7 @@ func (p *Postgres) CreateDomainEntry(tenantID, moduleID string, in *store.Domain
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
p.invalidateModuleInputHash(tenantID, moduleID)
|
||||||
return p.getDomainEntry(ctx, moduleID, id)
|
return p.getDomainEntry(ctx, moduleID, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,6 +471,7 @@ func (p *Postgres) UpdateDomainEntry(tenantID, moduleID, entryID string, patch *
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
p.invalidateModuleInputHash(tenantID, moduleID)
|
||||||
return p.getDomainEntry(ctx, moduleID, entryID)
|
return p.getDomainEntry(ctx, moduleID, entryID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,6 +487,7 @@ func (p *Postgres) DeleteDomainEntry(tenantID, moduleID, entryID string) error {
|
|||||||
if tag.RowsAffected() == 0 {
|
if tag.RowsAffected() == 0 {
|
||||||
return store.ErrNotFound
|
return store.ErrNotFound
|
||||||
}
|
}
|
||||||
|
p.invalidateModuleInputHash(tenantID, moduleID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,6 +536,7 @@ func (p *Postgres) CreateIPRangeEntry(tenantID, moduleID string, in *store.IPRan
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
p.invalidateModuleInputHash(tenantID, moduleID)
|
||||||
return p.getIPRangeEntry(ctx, moduleID, id)
|
return p.getIPRangeEntry(ctx, moduleID, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -570,6 +582,7 @@ func (p *Postgres) UpdateIPRangeEntry(tenantID, moduleID, entryID string, patch
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
p.invalidateModuleInputHash(tenantID, moduleID)
|
||||||
return p.getIPRangeEntry(ctx, moduleID, entryID)
|
return p.getIPRangeEntry(ctx, moduleID, entryID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -585,6 +598,7 @@ func (p *Postgres) DeleteIPRangeEntry(tenantID, moduleID, entryID string) error
|
|||||||
if tag.RowsAffected() == 0 {
|
if tag.RowsAffected() == 0 {
|
||||||
return store.ErrNotFound
|
return store.ErrNotFound
|
||||||
}
|
}
|
||||||
|
p.invalidateModuleInputHash(tenantID, moduleID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -116,11 +116,19 @@ type Backend interface {
|
|||||||
GetModulePrefixSnapshot(tenantID, moduleID string) (*ModulePrefixSnapshot, bool, error)
|
GetModulePrefixSnapshot(tenantID, moduleID string) (*ModulePrefixSnapshot, bool, error)
|
||||||
SetModulePrefixSnapshot(tenantID, moduleID, inputHash string, prefixes []PrefixRow) error
|
SetModulePrefixSnapshot(tenantID, moduleID, inputHash string, prefixes []PrefixRow) error
|
||||||
DeleteModulePrefixSnapshot(tenantID, moduleID string) error
|
DeleteModulePrefixSnapshot(tenantID, moduleID string) error
|
||||||
|
// SetModuleInputHash stores the ingest fingerprint on the module row (O(1) snapshot check).
|
||||||
|
SetModuleInputHash(tenantID, moduleID, hash string) error
|
||||||
|
// LockModuleSnapshot serializes read-modify-write of one module snapshot; unlock must be called.
|
||||||
|
LockModuleSnapshot(tenantID, moduleID string) (unlock func())
|
||||||
|
|
||||||
// ASNPrefixCache stores RIPEstat announced-prefixes per ASN (global TTL cache).
|
// ASNPrefixCache stores RIPEstat announced-prefixes per ASN (global TTL cache).
|
||||||
GetASNPrefixCache(asn int64) (*ASNPrefixCacheEntry, bool, error)
|
GetASNPrefixCache(asn int64) (*ASNPrefixCacheEntry, bool, error)
|
||||||
SetASNPrefixCache(asn int64, holder string, prefixes []string) error
|
SetASNPrefixCache(asn int64, holder string, prefixes []string) error
|
||||||
|
|
||||||
|
// DomainResolveCache stores DoH results per FQDN (global TTL cache).
|
||||||
|
GetDomainResolveCache(fqdn string) (*DomainResolveCacheEntry, bool, error)
|
||||||
|
SetDomainResolveCache(fqdn string, addrs []string) error
|
||||||
|
|
||||||
// Ping verifies backend connectivity (no-op for in-memory).
|
// Ping verifies backend connectivity (no-op for in-memory).
|
||||||
Ping(ctx context.Context) error
|
Ping(ctx context.Context) error
|
||||||
|
|
||||||
@@ -178,6 +186,13 @@ type ASNPrefixCacheEntry struct {
|
|||||||
FetchedAt time.Time
|
FetchedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DomainResolveCacheEntry is a cached DoH A/AAAA result for one FQDN.
|
||||||
|
type DomainResolveCacheEntry struct {
|
||||||
|
FQDN string
|
||||||
|
Addrs []string
|
||||||
|
ResolvedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
// ModulePrefixSnapshot is the cached materialization for one module between refreshes.
|
// ModulePrefixSnapshot is the cached materialization for one module between refreshes.
|
||||||
type ModulePrefixSnapshot struct {
|
type ModulePrefixSnapshot struct {
|
||||||
InputHash string
|
InputHash string
|
||||||
|
|||||||
@@ -44,7 +44,9 @@ type Memory struct {
|
|||||||
settings map[string]map[string]any // tenantID -> key -> JSON-compatible value
|
settings map[string]map[string]any // tenantID -> key -> JSON-compatible value
|
||||||
revPrefixes map[string][]PrefixRow
|
revPrefixes map[string][]PrefixRow
|
||||||
moduleSnapshots map[string]*moduleSnapshotRec
|
moduleSnapshots map[string]*moduleSnapshotRec
|
||||||
|
snapshotLocks sync.Map // key -> *sync.Mutex (per-module snapshot RMW)
|
||||||
asnPrefixCache map[int64]*ASNPrefixCacheEntry
|
asnPrefixCache map[int64]*ASNPrefixCacheEntry
|
||||||
|
domainResolveCache map[string]*DomainResolveCacheEntry
|
||||||
apiKeys map[string]*apiKeyRec
|
apiKeys map[string]*apiKeyRec
|
||||||
firewallClients map[string]*firewallClientRec
|
firewallClients map[string]*firewallClientRec
|
||||||
firewallRules map[string]*FirewallRule
|
firewallRules map[string]*FirewallRule
|
||||||
@@ -99,6 +101,7 @@ type Module struct {
|
|||||||
LastRefreshedAt *time.Time
|
LastRefreshedAt *time.Time
|
||||||
DeletedAt *time.Time
|
DeletedAt *time.Time
|
||||||
CreatedByUserID string // portal JWT sub; empty = system / API key
|
CreatedByUserID string // portal JWT sub; empty = system / API key
|
||||||
|
InputHash string // ingest fingerprint; empty = not yet computed
|
||||||
}
|
}
|
||||||
|
|
||||||
type Revision struct {
|
type Revision struct {
|
||||||
@@ -156,6 +159,7 @@ func NewMemory() *Memory {
|
|||||||
revPrefixes: make(map[string][]PrefixRow),
|
revPrefixes: make(map[string][]PrefixRow),
|
||||||
moduleSnapshots: make(map[string]*moduleSnapshotRec),
|
moduleSnapshots: make(map[string]*moduleSnapshotRec),
|
||||||
asnPrefixCache: make(map[int64]*ASNPrefixCacheEntry),
|
asnPrefixCache: make(map[int64]*ASNPrefixCacheEntry),
|
||||||
|
domainResolveCache: make(map[string]*DomainResolveCacheEntry),
|
||||||
apiKeys: make(map[string]*apiKeyRec),
|
apiKeys: make(map[string]*apiKeyRec),
|
||||||
firewallClients: make(map[string]*firewallClientRec),
|
firewallClients: make(map[string]*firewallClientRec),
|
||||||
firewallRules: make(map[string]*FirewallRule),
|
firewallRules: make(map[string]*FirewallRule),
|
||||||
@@ -696,6 +700,20 @@ func cloneStringPtr(s *string) *string {
|
|||||||
return &v
|
return &v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Memory) clearModuleInputHashLocked(moduleID string) {
|
||||||
|
if mod, ok := m.modules[moduleID]; ok && mod != nil {
|
||||||
|
mod.InputHash = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Memory) clearTenantModuleHashesLocked(tenantID string) {
|
||||||
|
for _, mod := range m.modules {
|
||||||
|
if mod != nil && mod.TenantID == tenantID && mod.DeletedAt == nil {
|
||||||
|
mod.InputHash = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func cloneModule(m *Module) *Module {
|
func cloneModule(m *Module) *Module {
|
||||||
if m == nil {
|
if m == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ func (m *Memory) UpdateModule(tenantID, moduleID string, patch *ModulePatch) (*M
|
|||||||
t := patch.LastRefreshedAt.UTC()
|
t := patch.LastRefreshedAt.UTC()
|
||||||
mod.LastRefreshedAt = &t
|
mod.LastRefreshedAt = &t
|
||||||
}
|
}
|
||||||
|
mod.InputHash = ""
|
||||||
return cloneModule(mod), nil
|
return cloneModule(mod), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +153,7 @@ func (m *Memory) CreateCDNSource(tenantID, moduleID string, in *CDNSource) (*CDN
|
|||||||
LastRefreshedAt: in.LastRefreshedAt,
|
LastRefreshedAt: in.LastRefreshedAt,
|
||||||
}
|
}
|
||||||
m.cdnSources[id] = s
|
m.cdnSources[id] = s
|
||||||
|
mod.InputHash = ""
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +163,8 @@ func (m *Memory) UpdateCDNSource(tenantID, moduleID, sourceID string, patch *CDN
|
|||||||
}
|
}
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
if _, err := m.moduleWriteOK(tenantID, moduleID); err != nil {
|
mod, err := m.moduleWriteOK(tenantID, moduleID)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
s, ok := m.cdnSources[sourceID]
|
s, ok := m.cdnSources[sourceID]
|
||||||
@@ -195,6 +198,9 @@ func (m *Memory) UpdateCDNSource(tenantID, moduleID, sourceID string, patch *CDN
|
|||||||
t := patch.LastRefreshedAt.UTC()
|
t := patch.LastRefreshedAt.UTC()
|
||||||
s.LastRefreshedAt = &t
|
s.LastRefreshedAt = &t
|
||||||
}
|
}
|
||||||
|
if CDNPatchAffectsInputHash(patch) {
|
||||||
|
mod.InputHash = ""
|
||||||
|
}
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,6 +215,7 @@ func (m *Memory) DeleteCDNSource(tenantID, moduleID, sourceID string) error {
|
|||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
delete(m.cdnSources, sourceID)
|
delete(m.cdnSources, sourceID)
|
||||||
|
m.clearModuleInputHashLocked(moduleID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,6 +254,7 @@ func (m *Memory) CreateASEntry(tenantID, moduleID string, in *ASEntry) (*ASEntry
|
|||||||
id := uuid.NewString()
|
id := uuid.NewString()
|
||||||
e := &ASEntry{ID: id, ModuleID: moduleID, ASN: in.ASN, CommunityID: in.CommunityID}
|
e := &ASEntry{ID: id, ModuleID: moduleID, ASN: in.ASN, CommunityID: in.CommunityID}
|
||||||
m.asEntries[id] = e
|
m.asEntries[id] = e
|
||||||
|
mod.InputHash = ""
|
||||||
return e, nil
|
return e, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,7 +264,8 @@ func (m *Memory) UpdateASEntry(tenantID, moduleID, entryID string, patch *ASEntr
|
|||||||
}
|
}
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
if _, err := m.moduleWriteOK(tenantID, moduleID); err != nil {
|
mod, err := m.moduleWriteOK(tenantID, moduleID)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
e, ok := m.asEntries[entryID]
|
e, ok := m.asEntries[entryID]
|
||||||
@@ -283,6 +292,7 @@ func (m *Memory) UpdateASEntry(tenantID, moduleID, entryID string, patch *ASEntr
|
|||||||
e.PrefixCount = nil
|
e.PrefixCount = nil
|
||||||
e.ASNResolvedAt = nil
|
e.ASNResolvedAt = nil
|
||||||
}
|
}
|
||||||
|
mod.InputHash = ""
|
||||||
return e, nil
|
return e, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,6 +334,7 @@ func (m *Memory) DeleteASEntry(tenantID, moduleID, entryID string) error {
|
|||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
delete(m.asEntries, entryID)
|
delete(m.asEntries, entryID)
|
||||||
|
m.clearModuleInputHashLocked(moduleID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,6 +373,7 @@ func (m *Memory) CreateDomainEntry(tenantID, moduleID string, in *DomainEntry) (
|
|||||||
id := uuid.NewString()
|
id := uuid.NewString()
|
||||||
e := &DomainEntry{ID: id, ModuleID: moduleID, FQDN: strings.TrimSpace(in.FQDN), CommunityID: in.CommunityID}
|
e := &DomainEntry{ID: id, ModuleID: moduleID, FQDN: strings.TrimSpace(in.FQDN), CommunityID: in.CommunityID}
|
||||||
m.domainEnt[id] = e
|
m.domainEnt[id] = e
|
||||||
|
mod.InputHash = ""
|
||||||
return e, nil
|
return e, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,7 +383,8 @@ func (m *Memory) UpdateDomainEntry(tenantID, moduleID, entryID string, patch *Do
|
|||||||
}
|
}
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
if _, err := m.moduleWriteOK(tenantID, moduleID); err != nil {
|
mod, err := m.moduleWriteOK(tenantID, moduleID)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
e, ok := m.domainEnt[entryID]
|
e, ok := m.domainEnt[entryID]
|
||||||
@@ -389,6 +402,7 @@ func (m *Memory) UpdateDomainEntry(tenantID, moduleID, entryID string, patch *Do
|
|||||||
e.CommunityID = &v
|
e.CommunityID = &v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
mod.InputHash = ""
|
||||||
return e, nil
|
return e, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,6 +417,7 @@ func (m *Memory) DeleteDomainEntry(tenantID, moduleID, entryID string) error {
|
|||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
delete(m.domainEnt, entryID)
|
delete(m.domainEnt, entryID)
|
||||||
|
m.clearModuleInputHashLocked(moduleID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,6 +456,7 @@ func (m *Memory) CreateIPRangeEntry(tenantID, moduleID string, in *IPRangeEntry)
|
|||||||
id := uuid.NewString()
|
id := uuid.NewString()
|
||||||
e := &IPRangeEntry{ID: id, ModuleID: moduleID, Prefix: strings.TrimSpace(in.Prefix), CommunityID: in.CommunityID}
|
e := &IPRangeEntry{ID: id, ModuleID: moduleID, Prefix: strings.TrimSpace(in.Prefix), CommunityID: in.CommunityID}
|
||||||
m.ipRanges[id] = e
|
m.ipRanges[id] = e
|
||||||
|
mod.InputHash = ""
|
||||||
return e, nil
|
return e, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,7 +466,8 @@ func (m *Memory) UpdateIPRangeEntry(tenantID, moduleID, entryID string, patch *I
|
|||||||
}
|
}
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
if _, err := m.moduleWriteOK(tenantID, moduleID); err != nil {
|
mod, err := m.moduleWriteOK(tenantID, moduleID)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
e, ok := m.ipRanges[entryID]
|
e, ok := m.ipRanges[entryID]
|
||||||
@@ -468,6 +485,7 @@ func (m *Memory) UpdateIPRangeEntry(tenantID, moduleID, entryID string, patch *I
|
|||||||
e.CommunityID = &v
|
e.CommunityID = &v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
mod.InputHash = ""
|
||||||
return e, nil
|
return e, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,6 +500,7 @@ func (m *Memory) DeleteIPRangeEntry(tenantID, moduleID, entryID string) error {
|
|||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
delete(m.ipRanges, entryID)
|
delete(m.ipRanges, entryID)
|
||||||
|
m.clearModuleInputHashLocked(moduleID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -551,6 +570,7 @@ func (m *Memory) UpdateDohProfile(tenantID, id string, patch *DohProfilePatch) (
|
|||||||
if patch.SecretRef != nil {
|
if patch.SecretRef != nil {
|
||||||
p.SecretRef = patch.SecretRef
|
p.SecretRef = patch.SecretRef
|
||||||
}
|
}
|
||||||
|
m.clearTenantModuleHashesLocked(tenantID)
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m *Memory) GetDomainResolveCache(fqdn string) (*DomainResolveCacheEntry, bool, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
if m.domainResolveCache == nil {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
e, ok := m.domainResolveCache[strings.ToLower(strings.TrimSpace(fqdn))]
|
||||||
|
if !ok || e == nil {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
return &DomainResolveCacheEntry{
|
||||||
|
FQDN: e.FQDN,
|
||||||
|
Addrs: append([]string(nil), e.Addrs...),
|
||||||
|
ResolvedAt: e.ResolvedAt,
|
||||||
|
}, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Memory) SetDomainResolveCache(fqdn string, addrs []string) error {
|
||||||
|
key := strings.ToLower(strings.TrimSpace(fqdn))
|
||||||
|
if key == "" {
|
||||||
|
return ErrInvalidInput
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if m.domainResolveCache == nil {
|
||||||
|
m.domainResolveCache = make(map[string]*DomainResolveCacheEntry)
|
||||||
|
}
|
||||||
|
m.domainResolveCache[key] = &DomainResolveCacheEntry{
|
||||||
|
FQDN: key,
|
||||||
|
Addrs: append([]string(nil), addrs...),
|
||||||
|
ResolvedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user