Выравнивание UI с vps-tracker: тема b2fA, ReUI DataGrid и shell
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
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]>
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
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('')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user