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

- 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:
Denozordec
2026-07-17 02:25:12 +07:00
co-authored by Cursor
parent dd167a4fec
commit 64585ccd47
38 changed files with 4199 additions and 677 deletions
@@ -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 }
+48 -111
View File
@@ -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>
)
}
+29 -10
View File
@@ -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}
+43 -9
View File
@@ -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>
)
}