Build, Test, and Push CFDM Docker Image / test (push) Successful in 3m14s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 2m5s
Build, Test, and Push CFDM Docker Image / update-wiki (push) Successful in 6s
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped
Синхронизированы тема и layout (ModeToggle, sticky header), списки переведены на DataGridCard, дашборд на компактные SectionCards. Co-authored-by: Cursor <[email protected]>
189 lines
5.6 KiB
TypeScript
189 lines
5.6 KiB
TypeScript
import { useMemo, useState } from 'react'
|
|
import { cn } from '@cfdm/ui/lib/utils'
|
|
import { Button } from '@cfdm/ui/components/button'
|
|
import { Badge } from '@cfdm/ui/components/badge'
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from '@cfdm/ui/components/tooltip'
|
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
|
import {
|
|
DataGridCard,
|
|
columnDefFromDataGrid,
|
|
} from '@/components/data-grid-card'
|
|
import type { DataGridColumn } from '@/components/data-grid-types'
|
|
import { ListFiltersBar } from '@/components/list-filters-bar'
|
|
import { StatusBadge } from '@/components/status-badge'
|
|
import type { DnsRecord, IpHealthStatus } from '@/lib/schemas'
|
|
import { formatDate } from '@/lib/format'
|
|
|
|
interface DnsRecordsDataGridProps {
|
|
records: DnsRecord[]
|
|
onDelete: (recordId: number) => void
|
|
isDeleting?: boolean
|
|
healthByIp?: Record<string, IpHealthStatus>
|
|
search?: string
|
|
onSearchChange?: (value: string) => void
|
|
hideSearch?: boolean
|
|
}
|
|
|
|
const healthDotClass: Record<IpHealthStatus['status'], string> = {
|
|
up: 'bg-success',
|
|
degraded: 'bg-warning',
|
|
down: 'bg-destructive',
|
|
unknown: 'bg-muted-foreground/40',
|
|
}
|
|
|
|
const healthLabel: Record<IpHealthStatus['status'], string> = {
|
|
up: 'OK',
|
|
degraded: 'Деград.',
|
|
down: 'Down',
|
|
unknown: '—',
|
|
}
|
|
|
|
function IpHealthDot({ health }: { health: IpHealthStatus }) {
|
|
const tooltipParts: string[] = [`Статус: ${healthLabel[health.status]}`]
|
|
if (health.latency_ms != null) tooltipParts.push(`Задержка: ${health.latency_ms} мс`)
|
|
if (health.last_checked_at) tooltipParts.push(`Проверка: ${formatDate(health.last_checked_at)}`)
|
|
if (health.last_error) tooltipParts.push(`Ошибка: ${health.last_error}`)
|
|
return (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger
|
|
render={
|
|
<span
|
|
className={cn(
|
|
'inline-flex size-2 shrink-0 cursor-default rounded-full',
|
|
healthDotClass[health.status],
|
|
)}
|
|
aria-label={`Health: ${healthLabel[health.status]}`}
|
|
/>
|
|
}
|
|
/>
|
|
<TooltipContent className="whitespace-pre-line">{tooltipParts.join('\n')}</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
)
|
|
}
|
|
|
|
export function DnsRecordsDataGrid({
|
|
records,
|
|
onDelete,
|
|
isDeleting = false,
|
|
healthByIp,
|
|
search: controlledSearch,
|
|
onSearchChange,
|
|
hideSearch = false,
|
|
}: DnsRecordsDataGridProps) {
|
|
const [internalSearch, setInternalSearch] = useState('')
|
|
const search = controlledSearch ?? internalSearch
|
|
const setSearch = onSearchChange ?? setInternalSearch
|
|
|
|
const filteredRecords = useMemo(() => {
|
|
const query = search.trim().toLowerCase()
|
|
if (!query) return records
|
|
return records.filter(
|
|
(r) =>
|
|
r.name.toLowerCase().includes(query) ||
|
|
r.content.toLowerCase().includes(query) ||
|
|
r.record_type.toLowerCase().includes(query),
|
|
)
|
|
}, [records, search])
|
|
|
|
const columns = useMemo<DataGridColumn<DnsRecord>[]>(
|
|
() => [
|
|
{
|
|
key: 'record_type',
|
|
header: 'Тип',
|
|
sortable: true,
|
|
sortValue: (row) => row.record_type,
|
|
cell: (row) => <Badge variant="outline">{row.record_type}</Badge>,
|
|
},
|
|
{
|
|
key: 'name',
|
|
header: 'Имя',
|
|
sortable: true,
|
|
sortValue: (row) => row.name,
|
|
cell: (row) => <span className="font-medium">{row.name}</span>,
|
|
},
|
|
{
|
|
key: 'content',
|
|
header: 'Значение',
|
|
sortable: true,
|
|
sortValue: (row) => row.content,
|
|
cell: (row) => {
|
|
const health = healthByIp?.[row.content]
|
|
return (
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
{health ? <IpHealthDot health={health} /> : null}
|
|
<span className="truncate font-mono text-sm">{row.content}</span>
|
|
</div>
|
|
)
|
|
},
|
|
},
|
|
{
|
|
key: 'ttl',
|
|
header: 'TTL',
|
|
sortable: true,
|
|
sortValue: (row) => row.ttl,
|
|
className: 'tabular-nums',
|
|
cell: (row) => row.ttl,
|
|
},
|
|
{
|
|
key: 'sync_status',
|
|
header: 'Синхронизация',
|
|
cell: (row) => <StatusBadge status={row.sync_status} />,
|
|
},
|
|
{
|
|
key: 'actions',
|
|
header: '',
|
|
enableHiding: false,
|
|
className: 'text-right',
|
|
cell: (row) => (
|
|
<ConfirmDialog
|
|
trigger={
|
|
<Button variant="destructive" size="sm" disabled={isDeleting}>
|
|
Удалить
|
|
</Button>
|
|
}
|
|
title="Удалить DNS-запись?"
|
|
description={`Запись ${row.name} (${row.record_type}) будет удалена из зоны.`}
|
|
onConfirm={() => onDelete(row.id)}
|
|
/>
|
|
),
|
|
},
|
|
],
|
|
[healthByIp, isDeleting, onDelete],
|
|
)
|
|
|
|
return (
|
|
<DataGridCard
|
|
title="DNS-записи"
|
|
description="Записи в зоне"
|
|
columns={columnDefFromDataGrid(columns)}
|
|
data={filteredRecords}
|
|
rowId={(row) => String(row.id)}
|
|
emptyTitle="DNS-записи не найдены"
|
|
emptyDescription="Создайте запись или измените фильтры"
|
|
pinLastColumn
|
|
actions={
|
|
hideSearch ? undefined : (
|
|
<ListFiltersBar
|
|
search={{
|
|
value: search,
|
|
onChange: setSearch,
|
|
placeholder: 'Поиск по имени или значению…',
|
|
}}
|
|
shown={filteredRecords.length}
|
|
total={records.length}
|
|
showReset={Boolean(search.trim())}
|
|
onReset={() => setSearch('')}
|
|
/>
|
|
)
|
|
}
|
|
/>
|
|
)
|
|
}
|