feat(api, web): enhance health check and domain management features
Build, Test, and Push CFDM Docker Image / test (push) Successful in 3m3s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 1m48s
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 3m3s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 1m48s
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
- Integrated domain monitoring routes and bulk update functionality for domains in the API. - Improved health check service to include domain monitoring and logging of health status changes. - Updated web components to reflect health status with new HealthCheckBadge and enhanced domain filtering options. - Refactored domain service to support bulk updates and improved domain management capabilities. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -5,9 +5,15 @@ import { MoreHorizontalIcon, SearchIcon } from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import {
|
||||
DataGridTableRowSelect,
|
||||
DataGridTableRowSelectAll,
|
||||
} from '@/components/reui/data-grid/data-grid-table'
|
||||
import { createFilter, type FilterFieldConfig } from '@/components/reui/filters'
|
||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import type { DomainListItem } from '@/lib/schemas'
|
||||
import { formatRelative, sqliteUtcToIso } from '@/lib/format'
|
||||
import { renderSingleSelectedLabel } from '@/components/reui-kit/filter-utils'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
@@ -19,12 +25,18 @@ import {
|
||||
|
||||
export const DOMAIN_TABS = [
|
||||
{ id: 'all', label: 'Все' },
|
||||
{ id: 'with_group', label: 'С группой' },
|
||||
{ id: 'ok', label: 'OK' },
|
||||
{ id: 'slow', label: 'Slow' },
|
||||
{ id: 'down', label: 'Down' },
|
||||
{ id: 'unknown', label: '—' },
|
||||
{ id: 'without_group', label: 'Без группы' },
|
||||
] as const
|
||||
|
||||
export function domainTabFilter(item: DomainListItem, tabId: string) {
|
||||
if (tabId === 'with_group') return item.group_id != null
|
||||
if (tabId === 'ok') return item.health_status === 'up'
|
||||
if (tabId === 'slow') return item.health_status === 'degraded'
|
||||
if (tabId === 'down') return item.health_status === 'down'
|
||||
if (tabId === 'unknown') return item.health_status === 'unknown'
|
||||
if (tabId === 'without_group') return item.group_id == null
|
||||
return true
|
||||
}
|
||||
@@ -56,6 +68,29 @@ export function useDomainFilterFields(
|
||||
customValueRenderer: (values) =>
|
||||
renderSingleSelectedLabel(values, groupOptions),
|
||||
},
|
||||
{
|
||||
key: 'health_status',
|
||||
label: 'Доступность',
|
||||
type: 'select',
|
||||
className: 'w-[140px]',
|
||||
options: [
|
||||
{ label: 'OK', value: 'up' },
|
||||
{ label: 'Slow', value: 'degraded' },
|
||||
{ label: 'Down', value: 'down' },
|
||||
{ label: '—', value: 'unknown' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'environment',
|
||||
label: 'Env',
|
||||
type: 'select',
|
||||
className: 'w-[120px]',
|
||||
options: [
|
||||
{ label: 'prod', value: 'prod' },
|
||||
{ label: 'staging', value: 'staging' },
|
||||
{ label: 'dev', value: 'dev' },
|
||||
],
|
||||
},
|
||||
],
|
||||
[groupOptions],
|
||||
)
|
||||
@@ -64,123 +99,63 @@ export function useDomainFilterFields(
|
||||
export function domainFilterFieldValue(item: DomainListItem, field: string) {
|
||||
switch (field) {
|
||||
case 'zone_name':
|
||||
return `${item.zone_name} ${item.group_name ?? ''}`.toLowerCase()
|
||||
return `${item.zone_name} ${item.group_name ?? ''} ${(item.tags ?? []).join(' ')}`.toLowerCase()
|
||||
case 'group_id':
|
||||
return item.group_id != null ? String(item.group_id) : 'none'
|
||||
case 'health_status':
|
||||
return item.health_status ?? 'unknown'
|
||||
case 'environment':
|
||||
return item.environment ?? ''
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function EnvChip({ env }: { env: string | null | undefined }) {
|
||||
if (!env) return null
|
||||
return (
|
||||
<Badge variant="outline" size="xs" className="font-mono">
|
||||
{env}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
export function useDomainColumns({
|
||||
onRequestDelete,
|
||||
isDeleting,
|
||||
enableSelection = false,
|
||||
}: {
|
||||
onRequestDelete: (domain: DomainListItem) => void
|
||||
isDeleting?: boolean
|
||||
enableSelection?: boolean
|
||||
}) {
|
||||
const columns = useMemo<ColumnDef<DomainListItem>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'zone_name',
|
||||
accessorKey: 'zone_name',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Зона" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0 font-medium"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link
|
||||
to="/domains/$domainId"
|
||||
params={{ domainId: String(row.original.id) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{row.original.zone_name}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'group',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Группа" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const domain = row.original
|
||||
if (domain.group_id && domain.group_name) {
|
||||
return (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link
|
||||
to="/groups/$groupId"
|
||||
params={{ groupId: String(domain.group_id) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{domain.group_name}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return <Badge variant="outline">Без группы</Badge>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'service_count',
|
||||
accessorKey: 'service_count',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Сервисы" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0 tabular-nums"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/services" search={{ domainId: row.original.id }} />
|
||||
}
|
||||
>
|
||||
{row.original.service_count}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Статус',
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: 'last_synced_at',
|
||||
accessorKey: 'last_synced_at',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Синхронизация" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{row.original.last_synced_at ?? '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => (
|
||||
<div className="text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={<Button variant="outline" size="icon" className="size-8" />}
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
<span className="sr-only">Действия</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
() => {
|
||||
const cols: ColumnDef<DomainListItem>[] = []
|
||||
if (enableSelection) {
|
||||
cols.push({
|
||||
id: 'select',
|
||||
header: () => <DataGridTableRowSelectAll />,
|
||||
cell: ({ row }) => <DataGridTableRowSelect row={row} />,
|
||||
size: 36,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
})
|
||||
}
|
||||
cols.push(
|
||||
{
|
||||
id: 'zone_name',
|
||||
accessorKey: 'zone_name',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Зона" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto max-w-full truncate p-0 font-medium"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link
|
||||
to="/domains/$domainId"
|
||||
@@ -188,33 +163,155 @@ export function useDomainColumns({
|
||||
/>
|
||||
}
|
||||
>
|
||||
Обзор
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
{row.original.zone_name}
|
||||
</Button>
|
||||
<EnvChip env={row.original.environment} />
|
||||
</div>
|
||||
{(row.original.tags ?? []).length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{row.original.tags.slice(0, 3).map((tag) => (
|
||||
<Badge key={tag} variant="secondary" size="xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'health',
|
||||
accessorKey: 'health_status',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Доступность" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<HealthCheckBadge
|
||||
status={row.original.health_status ?? 'unknown'}
|
||||
latencyMs={row.original.health_latency_ms}
|
||||
showLatency
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'group',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Группа" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const domain = row.original
|
||||
if (domain.group_id && domain.group_name) {
|
||||
return (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link
|
||||
to="/domains/$domainId/dns"
|
||||
params={{ domainId: String(row.original.id) }}
|
||||
search={{ host: undefined }}
|
||||
to="/groups/$groupId"
|
||||
params={{ groupId: String(domain.group_id) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
DNS
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
disabled={isDeleting}
|
||||
onClick={() => onRequestDelete(row.original)}
|
||||
{domain.group_name}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return <Badge variant="outline">Без группы</Badge>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'service_count',
|
||||
accessorKey: 'service_count',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Сервисы" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0 tabular-nums"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/services" search={{ domainId: row.original.id }} />
|
||||
}
|
||||
>
|
||||
{row.original.service_count}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Зона CF',
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: 'last_synced_at',
|
||||
accessorKey: 'last_synced_at',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Синхронизация" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{formatRelative(
|
||||
sqliteUtcToIso(row.original.last_synced_at) ??
|
||||
row.original.last_synced_at,
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => (
|
||||
<div className="text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="outline" size="icon" className="size-8" />
|
||||
}
|
||||
>
|
||||
Удалить
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[isDeleting, onRequestDelete],
|
||||
<MoreHorizontalIcon />
|
||||
<span className="sr-only">Действия</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
render={
|
||||
<Link
|
||||
to="/domains/$domainId"
|
||||
params={{ domainId: String(row.original.id) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Обзор
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
render={
|
||||
<Link
|
||||
to="/domains/$domainId/dns"
|
||||
params={{ domainId: String(row.original.id) }}
|
||||
search={{ host: undefined }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
DNS
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
disabled={isDeleting}
|
||||
onClick={() => onRequestDelete(row.original)}
|
||||
>
|
||||
Удалить
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
)
|
||||
return cols
|
||||
},
|
||||
[enableSelection, isDeleting, onRequestDelete],
|
||||
)
|
||||
|
||||
return { columns }
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Link2Icon } from 'lucide-react'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||
import { groupBindingsByHostname } from '@/lib/domain-ips'
|
||||
import { useHealthRows } from '@/lib/use-aggregated-health'
|
||||
import { formatDate } from '@/lib/format'
|
||||
import type { IpHealthStatus, ServiceBinding } from '@/lib/schemas'
|
||||
import type { ServiceBinding } from '@/lib/schemas'
|
||||
import { AppBadge } from '@/components/app-badge'
|
||||
import { AppButton } from '@/components/app-button'
|
||||
import {
|
||||
@@ -24,57 +23,11 @@ import {
|
||||
AppItemSeparator,
|
||||
AppItemTitle,
|
||||
} from '@/components/app-item'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@cfdm/ui/components/tooltip'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
interface DomainBindingsCardProps {
|
||||
bindings: ServiceBinding[]
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
function HostnameIpsHealth({
|
||||
bindings,
|
||||
ips,
|
||||
@@ -95,9 +48,18 @@ function HostnameIpsHealth({
|
||||
{ips.map((ip) => {
|
||||
const row = byIp.get(ip)
|
||||
return (
|
||||
<span key={ip} className="inline-flex items-center gap-1 tabular-nums">
|
||||
{row ? <IpHealthDot health={row} /> : null}
|
||||
{ip}
|
||||
<span key={ip} className="inline-flex items-center gap-1.5 tabular-nums">
|
||||
{row ? (
|
||||
<HealthCheckBadge
|
||||
status={row.status}
|
||||
latencyMs={row.latency_ms}
|
||||
lastCheckedAt={row.last_checked_at}
|
||||
lastError={row.last_error}
|
||||
size="xs"
|
||||
showLatency
|
||||
/>
|
||||
) : null}
|
||||
<span className="font-mono text-sm">{ip}</span>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
@@ -124,83 +86,58 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) {
|
||||
<EmptyState
|
||||
icon={Link2Icon}
|
||||
title="Нет привязок"
|
||||
description="Создайте привязку на странице сервисов или в таблице поддоменов"
|
||||
className="border border-dashed p-4"
|
||||
description="Привяжите сервис к поддомену"
|
||||
/>
|
||||
) : (
|
||||
<AppItemGroup className="gap-3">
|
||||
{entries.map(([hostname, hostnameBindings], index) => {
|
||||
const services = uniqueServices(hostnameBindings)
|
||||
const uniqueIps = [
|
||||
<AppItemGroup className="gap-0">
|
||||
{entries.map(([hostname, groupBindings], index) => {
|
||||
const ips = [
|
||||
...new Set(
|
||||
hostnameBindings
|
||||
.map((b) => b.target_ip)
|
||||
.filter((ip): ip is string => Boolean(ip)),
|
||||
groupBindings.flatMap((b) =>
|
||||
b.target_ips.length > 0
|
||||
? b.target_ips
|
||||
: b.target_ip
|
||||
? [b.target_ip]
|
||||
: [],
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
return (
|
||||
<div key={hostname}>
|
||||
<AppItem variant="outline">
|
||||
<AppItemContent className="gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<AppItemTitle className="cursor-default truncate font-mono">
|
||||
{hostname}
|
||||
</AppItemTitle>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>{hostname}</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{services.map((name) => (
|
||||
<AppBadge key={name}>{name}</AppBadge>
|
||||
))}
|
||||
{uniqueIps.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<HostnameIpsHealth
|
||||
bindings={hostnameBindings}
|
||||
ips={uniqueIps}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{[
|
||||
...new Set(
|
||||
hostnameBindings
|
||||
.map((b) => b.sync_status)
|
||||
.filter((s): s is string => Boolean(s)),
|
||||
),
|
||||
].map((status) => (
|
||||
<StatusBadge key={status} status={status} />
|
||||
))}
|
||||
<AppItem size="sm" variant="muted" className="border-0 px-0">
|
||||
<AppItemContent className="gap-1">
|
||||
<AppItemTitle className="font-mono">{hostname}</AppItemTitle>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{uniqueServices(groupBindings).join(', ')}
|
||||
</div>
|
||||
<HostnameIpsHealth
|
||||
bindings={groupBindings}
|
||||
ips={ips}
|
||||
/>
|
||||
</AppItemContent>
|
||||
<AppItemActions>
|
||||
<AppButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
nativeButton={false}
|
||||
render={<Link to="/services" search={{ domainId: undefined }} />}
|
||||
>
|
||||
Сервисы
|
||||
</AppButton>
|
||||
<AppBadge variant="outline" className="tabular-nums">
|
||||
{ips.length} IP
|
||||
</AppBadge>
|
||||
</AppItemActions>
|
||||
</AppItem>
|
||||
{index < entries.length - 1 && <AppItemSeparator />}
|
||||
{index < entries.length - 1 ? <AppItemSeparator /> : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</AppItemGroup>
|
||||
)}
|
||||
</AppCardContent>
|
||||
{entries.length > 0 && (
|
||||
<AppCardFooter>
|
||||
<AppButton variant="link" className="h-auto p-0" nativeButton={false} render={<Link to="/services" search={{ domainId: undefined }} />}>
|
||||
Управление привязками
|
||||
</AppButton>
|
||||
</AppCardFooter>
|
||||
)}
|
||||
<AppCardFooter>
|
||||
<AppButton
|
||||
variant="link"
|
||||
className="h-auto p-0"
|
||||
nativeButton={false}
|
||||
render={<Link to="/services" search={{ domainId: bindings[0]?.domain_id }} />}
|
||||
>
|
||||
К сервисам
|
||||
</AppButton>
|
||||
</AppCardFooter>
|
||||
</AppCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { ActivityIcon, PlusIcon, Trash2Icon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { DomainMonitorType } from '@/lib/schemas'
|
||||
import {
|
||||
createDomainMonitor,
|
||||
deleteDomainMonitor,
|
||||
domainMonitorKeys,
|
||||
domainMonitorResultsQueryOptions,
|
||||
domainMonitorsQueryOptions,
|
||||
runDomainMonitors,
|
||||
} from '@/queries'
|
||||
import { HealthTimeline } from '@/components/health/health-timeline'
|
||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import {
|
||||
AppField,
|
||||
AppFieldGroup,
|
||||
AppFieldLabel,
|
||||
} from '@/components/app-field'
|
||||
import { AppInput } from '@/components/app-input'
|
||||
import {
|
||||
AppItem,
|
||||
AppItemActions,
|
||||
AppItemContent,
|
||||
AppItemGroup,
|
||||
AppItemSeparator,
|
||||
AppItemTitle,
|
||||
} from '@/components/app-item'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@cfdm/ui/components/select'
|
||||
import { formatDate } from '@/lib/format'
|
||||
|
||||
const MONITOR_TYPE_ITEMS: { label: string; value: DomainMonitorType }[] = [
|
||||
{ label: 'HTTP', value: 'http' },
|
||||
{ label: 'Ping', value: 'ping' },
|
||||
{ label: 'DNS', value: 'dns' },
|
||||
]
|
||||
|
||||
function monitorTypeLabel(type: string): string {
|
||||
const found = MONITOR_TYPE_ITEMS.find((item) => item.value === type)
|
||||
return found?.label ?? type
|
||||
}
|
||||
|
||||
interface DomainAvailabilityPanelProps {
|
||||
domainId: number
|
||||
}
|
||||
|
||||
export function DomainAvailabilityPanel({
|
||||
domainId,
|
||||
}: DomainAvailabilityPanelProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [hostname, setHostname] = useState('')
|
||||
const [monitorType, setMonitorType] = useState<DomainMonitorType>('http')
|
||||
|
||||
const monitorsQuery = useQuery(domainMonitorsQueryOptions(domainId))
|
||||
const resultsQuery = useQuery(domainMonitorResultsQueryOptions(domainId, 50))
|
||||
|
||||
const invalidateMonitors = async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: domainMonitorKeys.all,
|
||||
})
|
||||
}
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createDomainMonitor(domainId, {
|
||||
hostname: hostname.trim(),
|
||||
type: monitorType,
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
setHostname('')
|
||||
setMonitorType('http')
|
||||
toast.success('Монитор создан')
|
||||
await invalidateMonitors()
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : 'Не удалось создать монитор',
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (monitorId: number) =>
|
||||
deleteDomainMonitor(domainId, monitorId),
|
||||
onSuccess: async () => {
|
||||
toast.success('Монитор удалён')
|
||||
await invalidateMonitors()
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : 'Не удалось удалить монитор',
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const runMutation = useMutation({
|
||||
mutationFn: () => runDomainMonitors(domainId),
|
||||
onSuccess: async (data) => {
|
||||
toast.success(`Проверено мониторов: ${data.checked}`)
|
||||
await invalidateMonitors()
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : 'Не удалось запустить проверку',
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const timelineEvents = useMemo(
|
||||
() =>
|
||||
(resultsQuery.data ?? []).map((row) => ({
|
||||
id: row.id,
|
||||
hostname: row.hostname,
|
||||
type: row.type,
|
||||
status: row.status,
|
||||
latency_ms: row.latency_ms,
|
||||
error: row.error,
|
||||
checked_at: row.checked_at,
|
||||
})),
|
||||
[resultsQuery.data],
|
||||
)
|
||||
|
||||
const monitors = monitorsQuery.data ?? []
|
||||
const canCreate = hostname.trim().length > 0 && !createMutation.isPending
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 md:gap-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Мониторы доступности hostname в зоне (HTTP, Ping, DNS)
|
||||
</p>
|
||||
<LoadingButton
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => runMutation.mutate()}
|
||||
isLoading={runMutation.isPending}
|
||||
loadingLabel="Проверка…"
|
||||
disabled={monitors.length === 0}
|
||||
>
|
||||
Запустить проверку
|
||||
</LoadingButton>
|
||||
</div>
|
||||
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Новый монитор</FrameTitle>
|
||||
<FrameDescription>
|
||||
Укажите hostname и тип проверки
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
if (!canCreate) return
|
||||
createMutation.mutate()
|
||||
}}
|
||||
>
|
||||
<AppFieldGroup className="grid gap-4 sm:grid-cols-[1fr_10rem_auto] sm:items-end">
|
||||
<AppField>
|
||||
<AppFieldLabel htmlFor="monitor-hostname">
|
||||
Hostname
|
||||
</AppFieldLabel>
|
||||
<AppInput
|
||||
id="monitor-hostname"
|
||||
value={hostname}
|
||||
placeholder="example.com"
|
||||
onChange={(e) => setHostname(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</AppField>
|
||||
<AppField>
|
||||
<AppFieldLabel htmlFor="monitor-type">Тип</AppFieldLabel>
|
||||
<Select
|
||||
items={MONITOR_TYPE_ITEMS}
|
||||
value={monitorType}
|
||||
onValueChange={(value) =>
|
||||
setMonitorType((value ?? 'http') as DomainMonitorType)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="monitor-type" className="w-full">
|
||||
<SelectValue>
|
||||
{monitorTypeLabel(monitorType)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MONITOR_TYPE_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</AppField>
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
isLoading={createMutation.isPending}
|
||||
loadingLabel="Создание…"
|
||||
disabled={!canCreate}
|
||||
>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
Добавить
|
||||
</LoadingButton>
|
||||
</AppFieldGroup>
|
||||
</form>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Мониторы</FrameTitle>
|
||||
<FrameDescription>
|
||||
Последний статус каждой проверки
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
<QueryState
|
||||
isLoading={monitorsQuery.isLoading}
|
||||
isError={monitorsQuery.isError}
|
||||
error={monitorsQuery.error}
|
||||
onRetry={() => void monitorsQuery.refetch()}
|
||||
skeleton={<TableSkeleton rows={3} cols={3} />}
|
||||
>
|
||||
{monitors.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={ActivityIcon}
|
||||
title="Мониторы не настроены"
|
||||
description="Добавьте hostname для проверки доступности"
|
||||
/>
|
||||
) : (
|
||||
<AppItemGroup className="gap-0">
|
||||
{monitors.map((monitor, index) => (
|
||||
<div key={monitor.id}>
|
||||
<AppItem size="sm" variant="muted" className="border-0 px-0">
|
||||
<AppItemContent className="gap-1">
|
||||
<AppItemTitle className="font-mono">
|
||||
{monitor.hostname}
|
||||
</AppItemTitle>
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="uppercase">
|
||||
{monitorTypeLabel(monitor.type)}
|
||||
</span>
|
||||
{monitor.last_checked_at ? (
|
||||
<span className="tabular-nums">
|
||||
{formatDate(monitor.last_checked_at)}
|
||||
</span>
|
||||
) : (
|
||||
<span>Ещё не проверялся</span>
|
||||
)}
|
||||
</div>
|
||||
</AppItemContent>
|
||||
<AppItemActions className="gap-2">
|
||||
<HealthCheckBadge
|
||||
status={monitor.last_status}
|
||||
latencyMs={monitor.last_latency_ms}
|
||||
lastCheckedAt={monitor.last_checked_at}
|
||||
lastError={monitor.last_error}
|
||||
size="xs"
|
||||
showLatency
|
||||
/>
|
||||
<ConfirmDialog
|
||||
title="Удалить монитор?"
|
||||
description={`Будет удалён монитор ${monitor.hostname} (${monitorTypeLabel(monitor.type)}).`}
|
||||
onConfirm={() =>
|
||||
deleteMutation.mutate(monitor.id)
|
||||
}
|
||||
disabled={deleteMutation.isPending}
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Удалить монитор ${monitor.hostname}`}
|
||||
>
|
||||
<Trash2Icon />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</AppItemActions>
|
||||
</AppItem>
|
||||
{index < monitors.length - 1 ? (
|
||||
<AppItemSeparator />
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</AppItemGroup>
|
||||
)}
|
||||
</QueryState>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>История проверок</FrameTitle>
|
||||
<FrameDescription>
|
||||
Последние результаты мониторов зоны
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
<QueryState
|
||||
isLoading={resultsQuery.isLoading}
|
||||
isError={resultsQuery.isError}
|
||||
error={resultsQuery.error}
|
||||
onRetry={() => void resultsQuery.refetch()}
|
||||
skeleton={<TableSkeleton rows={4} cols={2} />}
|
||||
>
|
||||
<HealthTimeline events={timelineEvents} />
|
||||
</QueryState>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@cfdm/ui/components/select'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
|
||||
interface DomainsBulkToolbarProps {
|
||||
count: number
|
||||
isPending?: boolean
|
||||
groupItems: { label: string; value: string }[]
|
||||
onAssignGroup: (groupId: number | null) => void
|
||||
onSetEnvironment: (env: 'prod' | 'staging' | 'dev' | null) => void
|
||||
onAddTag: (tag: string) => void
|
||||
onClear: () => void
|
||||
}
|
||||
|
||||
export function DomainsBulkToolbar({
|
||||
count,
|
||||
isPending = false,
|
||||
groupItems,
|
||||
onAssignGroup,
|
||||
onSetEnvironment,
|
||||
onAddTag,
|
||||
onClear,
|
||||
}: DomainsBulkToolbarProps) {
|
||||
const [groupValue, setGroupValue] = useState('none')
|
||||
const [envValue, setEnvValue] = useState('none')
|
||||
const [tag, setTag] = useState('')
|
||||
|
||||
if (count === 0) return null
|
||||
|
||||
const envItems = [
|
||||
{ label: 'Без env', value: 'none' },
|
||||
{ label: 'prod', value: 'prod' },
|
||||
{ label: 'staging', value: 'staging' },
|
||||
{ label: 'dev', value: 'dev' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2 rounded-lg border bg-muted/40 px-3 py-2">
|
||||
<span className="text-muted-foreground text-sm tabular-nums">
|
||||
Выбрано: {count}
|
||||
</span>
|
||||
<Select
|
||||
items={groupItems}
|
||||
value={groupValue}
|
||||
onValueChange={(v) => setGroupValue(v ?? 'none')}
|
||||
>
|
||||
<SelectTrigger className="w-44" size="sm">
|
||||
<SelectValue placeholder="Группа" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{groupItems.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isPending}
|
||||
onClick={() =>
|
||||
onAssignGroup(groupValue === 'none' ? null : Number(groupValue))
|
||||
}
|
||||
>
|
||||
В группу
|
||||
</Button>
|
||||
<Select
|
||||
items={envItems}
|
||||
value={envValue}
|
||||
onValueChange={(v) => setEnvValue(v ?? 'none')}
|
||||
>
|
||||
<SelectTrigger className="w-32" size="sm">
|
||||
<SelectValue placeholder="Env" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{envItems.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isPending}
|
||||
onClick={() =>
|
||||
onSetEnvironment(
|
||||
envValue === 'none'
|
||||
? null
|
||||
: (envValue as 'prod' | 'staging' | 'dev'),
|
||||
)
|
||||
}
|
||||
>
|
||||
Env
|
||||
</Button>
|
||||
<Input
|
||||
className="h-8 w-32"
|
||||
placeholder="тег"
|
||||
value={tag}
|
||||
onChange={(e) => setTag(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isPending || !tag.trim()}
|
||||
onClick={() => {
|
||||
onAddTag(tag.trim())
|
||||
setTag('')
|
||||
}}
|
||||
>
|
||||
+ тег
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" disabled={isPending} onClick={onClear}>
|
||||
Снять выделение
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,32 +1,41 @@
|
||||
import { Badge, badgeVariants } from '@cfdm/ui/components/badge'
|
||||
import type { ComponentProps } from 'react'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@cfdm/ui/components/tooltip'
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import type { IpHealthStatus } from '@/lib/schemas'
|
||||
import { formatDate } from '@/lib/format'
|
||||
|
||||
type BadgeVariant = NonNullable<VariantProps<typeof badgeVariants>['variant']>
|
||||
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
|
||||
|
||||
const healthVariants: Record<IpHealthStatus['status'], BadgeVariant> = {
|
||||
up: 'success',
|
||||
degraded: 'secondary',
|
||||
down: 'destructive',
|
||||
up: 'success-light',
|
||||
degraded: 'warning-light',
|
||||
down: 'destructive-light',
|
||||
unknown: 'outline',
|
||||
}
|
||||
|
||||
const healthLabels: Record<IpHealthStatus['status'], string> = {
|
||||
up: 'OK',
|
||||
degraded: 'Деград.',
|
||||
degraded: 'Slow',
|
||||
down: 'Down',
|
||||
unknown: '—',
|
||||
}
|
||||
|
||||
const dotColor: Record<IpHealthStatus['status'], string> = {
|
||||
up: 'bg-success',
|
||||
degraded: 'bg-warning',
|
||||
down: 'bg-destructive',
|
||||
unknown: 'bg-muted-foreground',
|
||||
}
|
||||
|
||||
interface HealthCheckBadgeProps {
|
||||
status: IpHealthStatus['status']
|
||||
latencyMs?: number | null
|
||||
lastCheckedAt?: string | null
|
||||
lastError?: string | null
|
||||
title?: string
|
||||
showLatency?: boolean
|
||||
size?: 'xs' | 'sm'
|
||||
className?: string
|
||||
}
|
||||
|
||||
@@ -36,6 +45,8 @@ export function HealthCheckBadge({
|
||||
lastCheckedAt,
|
||||
lastError,
|
||||
title,
|
||||
showLatency = false,
|
||||
size = 'sm',
|
||||
className,
|
||||
}: HealthCheckBadgeProps) {
|
||||
const variant = healthVariants[status]
|
||||
@@ -53,16 +64,24 @@ export function HealthCheckBadge({
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span tabIndex={0} className="inline-flex cursor-default" />
|
||||
<span tabIndex={0} className="inline-flex cursor-default items-center gap-1.5" />
|
||||
}
|
||||
>
|
||||
<Badge variant={variant} className={cn('gap-1.5', className)}>
|
||||
<Badge
|
||||
variant={variant}
|
||||
size={size}
|
||||
radius="full"
|
||||
className={cn('gap-1.5', className)}
|
||||
>
|
||||
<span
|
||||
className="size-1.5 rounded-full bg-current opacity-70"
|
||||
className={cn('size-1.5 shrink-0 rounded-full', dotColor[status])}
|
||||
aria-hidden
|
||||
/>
|
||||
{label}
|
||||
</Badge>
|
||||
{showLatency && latencyMs != null ? (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">{latencyMs} мс</span>
|
||||
) : null}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="whitespace-pre-line">{tooltipParts.join('\n')}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Link2Icon } from 'lucide-react'
|
||||
|
||||
import {
|
||||
Timeline,
|
||||
TimelineContent,
|
||||
TimelineDate,
|
||||
TimelineHeader,
|
||||
TimelineIndicator,
|
||||
TimelineItem,
|
||||
TimelineSeparator,
|
||||
TimelineTitle,
|
||||
} from '@/components/reui/timeline'
|
||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||
import { formatDate, formatRelative, sqliteUtcToIso } from '@/lib/format'
|
||||
import type { IpHealthStatus } from '@/lib/schemas'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
|
||||
export interface HealthTimelineEvent {
|
||||
id: string | number
|
||||
hostname?: string
|
||||
type?: string
|
||||
status: IpHealthStatus['status']
|
||||
latency_ms?: number | null
|
||||
error?: string | null
|
||||
checked_at: string
|
||||
}
|
||||
|
||||
interface HealthTimelineProps {
|
||||
events: HealthTimelineEvent[]
|
||||
}
|
||||
|
||||
export function HealthTimeline({ events }: HealthTimelineProps) {
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Link2Icon}
|
||||
title="Нет событий"
|
||||
description="Результаты проверок появятся после первого прогона"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Timeline defaultValue={1} className="gap-0">
|
||||
{events.map((event, index) => {
|
||||
const checkedIso =
|
||||
sqliteUtcToIso(event.checked_at) ?? event.checked_at
|
||||
return (
|
||||
<TimelineItem key={event.id} step={index + 1}>
|
||||
<TimelineSeparator />
|
||||
<TimelineIndicator />
|
||||
<TimelineHeader>
|
||||
<TimelineTitle className="flex flex-wrap items-center gap-2">
|
||||
{event.hostname ? (
|
||||
<span className="font-mono text-sm">{event.hostname}</span>
|
||||
) : null}
|
||||
{event.type ? (
|
||||
<span className="text-muted-foreground text-xs uppercase">
|
||||
{event.type}
|
||||
</span>
|
||||
) : null}
|
||||
<HealthCheckBadge
|
||||
status={event.status}
|
||||
latencyMs={event.latency_ms}
|
||||
size="xs"
|
||||
showLatency
|
||||
/>
|
||||
</TimelineTitle>
|
||||
<TimelineDate>
|
||||
{formatRelative(checkedIso)} · {formatDate(checkedIso)}
|
||||
</TimelineDate>
|
||||
</TimelineHeader>
|
||||
{event.error ? (
|
||||
<TimelineContent>
|
||||
<code className="bg-muted block overflow-x-auto rounded-md px-2 py-1.5 font-mono text-xs">
|
||||
{event.error}
|
||||
</code>
|
||||
</TimelineContent>
|
||||
) : null}
|
||||
</TimelineItem>
|
||||
)
|
||||
})}
|
||||
</Timeline>
|
||||
)
|
||||
}
|
||||
@@ -98,7 +98,7 @@ export function OpsDashboard({
|
||||
<FrameHeader>
|
||||
<FrameTitle>Требуют внимания</FrameTitle>
|
||||
<FrameDescription>
|
||||
Истекающие сертификаты и домены без группы
|
||||
Проблемы health-check, истекающие сертификаты и домены без группы
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>{queue}</FramePanel>
|
||||
|
||||
@@ -67,6 +67,11 @@ export interface ResourcePageProps<T extends object> {
|
||||
emptyState?: { title: string; description?: string; action?: ReactNode }
|
||||
pageSize?: number
|
||||
enableRowSelection?: boolean
|
||||
selectionToolbar?: (ctx: {
|
||||
selectedIds: string[]
|
||||
selectedCount: number
|
||||
clearSelection: () => void
|
||||
}) => ReactNode
|
||||
toolbarExtra?: ReactNode
|
||||
hideHeader?: boolean
|
||||
}
|
||||
@@ -113,6 +118,7 @@ export function ResourcePage<T extends object>({
|
||||
emptyState,
|
||||
pageSize = 10,
|
||||
enableRowSelection = false,
|
||||
selectionToolbar,
|
||||
toolbarExtra,
|
||||
hideHeader = false,
|
||||
}: ResourcePageProps<T>) {
|
||||
@@ -154,11 +160,17 @@ export function ResourcePage<T extends object>({
|
||||
return counts
|
||||
}, [tabs, tabFilter, data, filters, getFilterFieldValue])
|
||||
|
||||
const selectedCount = useMemo(
|
||||
() => Object.keys(rowSelection).length,
|
||||
const selectedIds = useMemo(
|
||||
() => Object.keys(rowSelection).filter((id) => rowSelection[id]),
|
||||
[rowSelection],
|
||||
)
|
||||
|
||||
const selectedCount = selectedIds.length
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
setRowSelection({})
|
||||
}, [])
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredData,
|
||||
columns,
|
||||
@@ -245,6 +257,13 @@ export function ResourcePage<T extends object>({
|
||||
|
||||
return (
|
||||
<div ref={frameRef} className="w-full">
|
||||
{selectionToolbar && selectedCount > 0
|
||||
? selectionToolbar({
|
||||
selectedIds,
|
||||
selectedCount,
|
||||
clearSelection,
|
||||
})
|
||||
: null}
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={filteredData.length}
|
||||
|
||||
@@ -1,21 +1,38 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
|
||||
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
|
||||
|
||||
const STATUS_VARIANT: Record<string, BadgeVariant> = {
|
||||
active: 'success',
|
||||
synced: 'success',
|
||||
ok: 'success',
|
||||
active: 'success-light',
|
||||
synced: 'success-light',
|
||||
ok: 'success-light',
|
||||
up: 'success-light',
|
||||
pending_push: 'secondary',
|
||||
warning: 'warning',
|
||||
conflict: 'destructive',
|
||||
error: 'destructive',
|
||||
expired: 'destructive',
|
||||
warning: 'warning-light',
|
||||
degraded: 'warning-light',
|
||||
conflict: 'destructive-light',
|
||||
error: 'destructive-light',
|
||||
expired: 'destructive-light',
|
||||
down: 'destructive-light',
|
||||
unknown: 'outline',
|
||||
}
|
||||
|
||||
const DOT_COLOR: Record<string, string> = {
|
||||
'success-light': 'bg-success',
|
||||
success: 'bg-success',
|
||||
'warning-light': 'bg-warning',
|
||||
warning: 'bg-warning',
|
||||
'destructive-light': 'bg-destructive',
|
||||
destructive: 'bg-destructive',
|
||||
'info-light': 'bg-info',
|
||||
secondary: 'bg-muted-foreground',
|
||||
outline: 'bg-muted-foreground',
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
active: 'Активен',
|
||||
synced: 'Синхронизировано',
|
||||
@@ -23,12 +40,29 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
conflict: 'Конфликт',
|
||||
error: 'Ошибка',
|
||||
ok: 'OK',
|
||||
up: 'OK',
|
||||
warning: 'Предупреждение',
|
||||
degraded: 'Slow',
|
||||
down: 'Down',
|
||||
expired: 'Истёк',
|
||||
unknown: 'Неизвестно',
|
||||
}
|
||||
|
||||
export function StatusBadge({ status, label }: { status: string; label?: string }) {
|
||||
export function StatusBadge({
|
||||
status,
|
||||
label,
|
||||
className,
|
||||
}: {
|
||||
status: string
|
||||
label?: string
|
||||
className?: string
|
||||
}) {
|
||||
const variant = STATUS_VARIANT[status] ?? 'outline'
|
||||
return <Badge variant={variant}>{label ?? STATUS_LABELS[status] ?? status}</Badge>
|
||||
const dotColor = DOT_COLOR[variant] ?? 'bg-muted-foreground'
|
||||
return (
|
||||
<Badge variant={variant} size="sm" radius="full" className={cn('gap-1.5', className)}>
|
||||
<span className={cn('size-1.5 shrink-0 rounded-full', dotColor)} aria-hidden />
|
||||
{label ?? STATUS_LABELS[status] ?? status}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user