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>
)
}
+32
View File
@@ -0,0 +1,32 @@
import { useQueries } from '@tanstack/react-query'
import { useMemo } from 'react'
import { healthStatusQueryOptions } from '@/queries'
import type { IpHealthStatus, ServiceBinding } from '@/lib/schemas'
/** Map IP → worst health status across enabled bindings. */
export function useDomainHealthByIp(bindings: ServiceBinding[] | undefined) {
const enabledBindings = useMemo(
() => (bindings ?? []).filter((b) => b.health_check_enabled),
[bindings],
)
const queries = useQueries({
queries: enabledBindings.map((b) => ({
...healthStatusQueryOptions('binding', b.id),
})),
})
return useMemo(() => {
const map: Record<string, IpHealthStatus> = {}
const rank = { up: 0, unknown: 1, degraded: 2, down: 3 } as const
for (const q of queries) {
for (const row of q.data ?? []) {
const prev = map[row.ip]
if (!prev || rank[row.status] > rank[prev.status]) {
map[row.ip] = row
}
}
}
return map
}, [queries])
}
+15
View File
@@ -115,6 +115,7 @@ export const domainSchema = z.object({
cf_zone_id: z.string(),
status: z.string(),
cert_monitoring: z.enum(['auto', 'required', 'skipped']).default('auto'),
environment: z.enum(['prod', 'staging', 'dev']).nullable().optional().default(null),
last_synced_at: z.string().nullable(),
created_at: z.string(),
updated_at: z.string(),
@@ -123,6 +124,9 @@ export const domainSchema = z.object({
export const domainListItemSchema = domainSchema.extend({
group_name: z.string().nullable(),
service_count: z.number(),
health_status: z.enum(['up', 'down', 'degraded', 'unknown']).default('unknown'),
health_latency_ms: z.number().nullable().default(null),
tags: z.array(z.string()).default([]),
})
export const serviceBindingSchema = z
@@ -369,11 +373,22 @@ export type LoginInput = z.infer<typeof loginSchema>
export type CreateDnsRecordInput = z.infer<typeof createDnsRecordSchema>
export {
bulkUpdateDomainsSchema,
createDomainMonitorSchema,
createSubdomainSchema,
domainMonitorResultSchema,
domainMonitorSchema,
notificationLogSchema,
subdomainSchema,
updateDomainSchema,
updateSubdomainSchema,
type BulkUpdateDomainsInput,
type CreateDomainMonitorInput,
type CreateSubdomainInput,
type DomainMonitor,
type DomainMonitorResult,
type DomainMonitorType,
type NotificationLog,
type SubdomainRecord,
type UpdateDomainInput,
type UpdateSubdomainInput,
+25
View File
@@ -0,0 +1,25 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import { certificateSchema } from '@/lib/schemas'
import { z } from 'zod'
export const certKeys = {
all: ['certificates'] as const,
summary: ['certificates', 'summary'] as const,
}
export const certificatesQueryOptions = () =>
queryOptions({
queryKey: certKeys.all,
queryFn: async () => {
const data = await api.get<unknown[]>('/api/v1/certificates')
return z.array(certificateSchema).parse(data)
},
staleTime: 1000 * 60 * 5,
})
export const certSummaryQueryOptions = () =>
queryOptions({
queryKey: certKeys.summary,
queryFn: () => api.get<[string, number][]>('/api/v1/certificates/summary'),
})
+19
View File
@@ -0,0 +1,19 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import { dnsRecordSchema } from '@/lib/schemas'
import { z } from 'zod'
export const dnsKeys = {
all: ['dns'] as const,
list: (domainId: number) => [...dnsKeys.all, domainId] as const,
}
export const dnsListQueryOptions = (domainId: number) =>
queryOptions({
queryKey: dnsKeys.list(domainId),
queryFn: async () => {
const data = await api.get<unknown[]>(`/api/v1/domains/${domainId}/dns`)
return z.array(dnsRecordSchema).parse(data)
},
staleTime: 1000 * 30,
})
+111
View File
@@ -0,0 +1,111 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import {
domainMonitorResultSchema,
domainMonitorSchema,
ipHealthStatusSchema,
notificationLogSchema,
createDomainMonitorSchema,
} from '@/lib/schemas'
import { z } from 'zod'
type CreateDomainMonitorBody = z.input<typeof createDomainMonitorSchema>
export const healthStatusKeys = {
all: ['health-status'] as const,
list: (scope: 'binding' | 'group', refId: number) =>
[...healthStatusKeys.all, scope, refId] as const,
}
export function healthStatusQueryOptions(
scope: 'binding' | 'group',
refId: number,
) {
return queryOptions({
queryKey: healthStatusKeys.list(scope, refId),
queryFn: async () => {
const data = await api.get<unknown[]>(
`/api/v1/health-status?scope=${scope}&ref_id=${refId}`,
)
return z.array(ipHealthStatusSchema).parse(data)
},
refetchInterval: 10_000,
staleTime: 5_000,
})
}
export async function runHealthCheck() {
return api.post<{ checked: number }>('/api/v1/health-check/run', {})
}
export const domainMonitorKeys = {
all: ['domain-monitors'] as const,
list: (domainId: number) => [...domainMonitorKeys.all, 'list', domainId] as const,
results: (domainId: number, limit?: number) =>
[...domainMonitorKeys.all, 'results', domainId, limit] as const,
}
export const domainMonitorsQueryOptions = (domainId: number) =>
queryOptions({
queryKey: domainMonitorKeys.list(domainId),
queryFn: async () => {
const data = await api.get<unknown[]>(`/api/v1/domains/${domainId}/monitors`)
return z.array(domainMonitorSchema).parse(data)
},
})
/** Domain-scoped results include joined hostname/type from monitors. */
export const domainMonitorResultEventSchema = domainMonitorResultSchema.extend({
hostname: z.string(),
type: z.string(),
})
export type DomainMonitorResultEvent = z.infer<
typeof domainMonitorResultEventSchema
>
export const domainMonitorResultsQueryOptions = (domainId: number, limit = 50) =>
queryOptions({
queryKey: domainMonitorKeys.results(domainId, limit),
queryFn: async () => {
const data = await api.get<unknown[]>(
`/api/v1/domains/${domainId}/monitor-results?limit=${limit}`,
)
return z.array(domainMonitorResultEventSchema).parse(data)
},
})
export async function createDomainMonitor(
domainId: number,
body: CreateDomainMonitorBody,
) {
const data = await api.post<unknown>(
`/api/v1/domains/${domainId}/monitors`,
createDomainMonitorSchema.parse(body),
)
return domainMonitorSchema.parse(data)
}
export async function deleteDomainMonitor(domainId: number, monitorId: number) {
return api.delete<{ deleted: boolean }>(
`/api/v1/domains/${domainId}/monitors/${monitorId}`,
)
}
export async function runDomainMonitors(domainId: number) {
return api.post<{ checked: number }>(`/api/v1/domains/${domainId}/monitors/run`, {})
}
export const notificationLogKeys = {
all: ['notification-log'] as const,
list: (limit?: number) => [...notificationLogKeys.all, 'list', limit] as const,
}
export const notificationLogQueryOptions = (limit = 50) =>
queryOptions({
queryKey: notificationLogKeys.list(limit),
queryFn: async () => {
const data = await api.get<unknown[]>(`/api/v1/notifications/log?limit=${limit}`)
return z.array(notificationLogSchema).parse(data)
},
})
+86
View File
@@ -0,0 +1,86 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import {
domainListItemSchema,
domainSchema,
subdomainSchema,
type BulkUpdateDomainsInput,
type CreateSubdomainInput,
type UpdateDomainInput,
type UpdateSubdomainInput,
} from '@/lib/schemas'
import { dnsKeys } from '@/queries/dns'
import { serviceBindingKeys } from '@/queries/services'
import { z } from 'zod'
export const domainKeys = {
all: ['domains'] as const,
list: (groupId?: number) => [...domainKeys.all, 'list', groupId] as const,
detail: (id: number) => [...domainKeys.all, 'detail', id] as const,
}
export const domainsListQueryOptions = (groupId?: number) =>
queryOptions({
queryKey: domainKeys.list(groupId),
queryFn: async () => {
const qs = groupId ? `?group_id=${groupId}` : ''
const data = await api.get<unknown[]>(`/api/v1/domains${qs}`)
return z.array(domainListItemSchema).parse(data)
},
})
export const domainDetailQueryOptions = (id: number) =>
queryOptions({
queryKey: domainKeys.detail(id),
queryFn: async () => {
const data = await api.get<unknown>(`/api/v1/domains/${id}`)
return domainSchema.parse(data)
},
})
export const subdomainKeys = {
all: ['subdomains'] as const,
list: (domainId: number) => [...subdomainKeys.all, domainId] as const,
}
export const subdomainsListQueryOptions = (domainId: number) =>
queryOptions({
queryKey: subdomainKeys.list(domainId),
queryFn: async () => {
const data = await api.get<unknown[]>(`/api/v1/domains/${domainId}/subdomains`)
return z.array(subdomainSchema).parse(data)
},
})
export async function createSubdomain(domainId: number, body: CreateSubdomainInput) {
const data = await api.post<unknown>(`/api/v1/domains/${domainId}/subdomains`, body)
return subdomainSchema.parse(data)
}
export async function updateSubdomain(id: number, body: UpdateSubdomainInput) {
const data = await api.patch<unknown>(`/api/v1/subdomains/${id}`, body)
return subdomainSchema.parse(data)
}
export async function updateDomain(id: number, body: UpdateDomainInput) {
const data = await api.patch<unknown>(`/api/v1/domains/${id}`, body)
return domainSchema.parse(data)
}
export async function bulkUpdateDomains(body: BulkUpdateDomainsInput) {
return api.post<{ updated: number }>('/api/v1/domains/bulk', body)
}
export async function deleteSubdomain(id: number) {
return api.delete<{ deleted: boolean }>(`/api/v1/subdomains/${id}`)
}
export function invalidateDomainPage(
queryClient: import('@tanstack/react-query').QueryClient,
domainId: number,
) {
void queryClient.invalidateQueries({ queryKey: subdomainKeys.list(domainId) })
void queryClient.invalidateQueries({ queryKey: serviceBindingKeys.byDomain(domainId) })
void queryClient.invalidateQueries({ queryKey: domainKeys.detail(domainId) })
void queryClient.invalidateQueries({ queryKey: dnsKeys.list(domainId) })
}
+27
View File
@@ -0,0 +1,27 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import { groupSchema, groupWithStatsSchema } from '@/lib/schemas'
import { z } from 'zod'
export const groupKeys = {
all: ['groups'] as const,
detail: (id: number) => [...groupKeys.all, 'detail', id] as const,
}
export const groupsQueryOptions = () =>
queryOptions({
queryKey: groupKeys.all,
queryFn: async () => {
const data = await api.get<unknown[]>('/api/v1/groups')
return z.array(groupSchema).parse(data)
},
})
export const groupDetailQueryOptions = (id: number) =>
queryOptions({
queryKey: groupKeys.detail(id),
queryFn: async () => {
const data = await api.get<unknown>(`/api/v1/groups/${id}`)
return groupWithStatsSchema.parse(data)
},
})
+6 -238
View File
@@ -1,238 +1,6 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import {
certificateSchema,
dnsRecordSchema,
domainListItemSchema,
domainSchema,
groupSchema,
groupWithStatsSchema,
ipHealthStatusSchema,
serviceBindingSchema,
serviceGroupsResponseSchema,
serviceViewSchema,
subdomainSchema,
type CreateSubdomainInput,
type UpdateDomainInput,
type UpdateSubdomainInput,
} from '@/lib/schemas'
import { z } from 'zod'
export const groupKeys = {
all: ['groups'] as const,
detail: (id: number) => [...groupKeys.all, 'detail', id] as const,
}
export const groupsQueryOptions = () =>
queryOptions({
queryKey: groupKeys.all,
queryFn: async () => {
const data = await api.get<unknown[]>('/api/v1/groups')
return z.array(groupSchema).parse(data)
},
})
export const groupDetailQueryOptions = (id: number) =>
queryOptions({
queryKey: groupKeys.detail(id),
queryFn: async () => {
const data = await api.get<unknown>(`/api/v1/groups/${id}`)
return groupWithStatsSchema.parse(data)
},
})
export const serviceKeys = {
all: ['services'] as const,
}
export const serviceGroupKeys = {
all: ['service-groups'] as const,
}
export const serviceGroupsQueryOptions = () =>
queryOptions({
queryKey: serviceGroupKeys.all,
queryFn: async () => {
const data = await api.get<unknown>('/api/v1/service-groups')
return serviceGroupsResponseSchema.parse(data)
},
})
export const servicesQueryOptions = () =>
queryOptions({
queryKey: serviceKeys.all,
queryFn: async () => {
const data = await api.get<unknown[]>('/api/v1/services')
return z.array(serviceViewSchema).parse(data)
},
})
export const serviceBindingKeys = {
all: ['service-bindings'] as const,
byDomain: (domainId: number) => [...serviceBindingKeys.all, 'domain', domainId] as const,
}
export const serviceBindingsQueryOptions = () =>
queryOptions({
queryKey: serviceBindingKeys.all,
queryFn: async () => {
const data = await api.get<unknown[]>('/api/v1/service-bindings')
return z.array(serviceBindingSchema).parse(data)
},
})
export const domainServiceBindingsQueryOptions = (domainId: number) =>
queryOptions({
queryKey: serviceBindingKeys.byDomain(domainId),
queryFn: async () => {
const data = await api.get<unknown[]>(`/api/v1/domains/${domainId}/service-bindings`)
return z.array(serviceBindingSchema).parse(data)
},
})
export const domainKeys = {
all: ['domains'] as const,
list: (groupId?: number) => [...domainKeys.all, 'list', groupId] as const,
detail: (id: number) => [...domainKeys.all, 'detail', id] as const,
}
export const domainsListQueryOptions = (groupId?: number) =>
queryOptions({
queryKey: domainKeys.list(groupId),
queryFn: async () => {
const qs = groupId ? `?group_id=${groupId}` : ''
const data = await api.get<unknown[]>(`/api/v1/domains${qs}`)
return z.array(domainListItemSchema).parse(data)
},
})
export const domainDetailQueryOptions = (id: number) =>
queryOptions({
queryKey: domainKeys.detail(id),
queryFn: async () => {
const data = await api.get<unknown>(`/api/v1/domains/${id}`)
return domainSchema.parse(data)
},
})
export const dnsKeys = {
all: ['dns'] as const,
list: (domainId: number) => [...dnsKeys.all, domainId] as const,
}
export const dnsListQueryOptions = (domainId: number) =>
queryOptions({
queryKey: dnsKeys.list(domainId),
queryFn: async () => {
const data = await api.get<unknown[]>(`/api/v1/domains/${domainId}/dns`)
return z.array(dnsRecordSchema).parse(data)
},
staleTime: 1000 * 30,
})
export const certKeys = {
all: ['certificates'] as const,
summary: ['certificates', 'summary'] as const,
}
export const certificatesQueryOptions = () =>
queryOptions({
queryKey: certKeys.all,
queryFn: async () => {
const data = await api.get<unknown[]>('/api/v1/certificates')
return z.array(certificateSchema).parse(data)
},
staleTime: 1000 * 60 * 5,
})
export const certSummaryQueryOptions = () =>
queryOptions({
queryKey: certKeys.summary,
queryFn: () => api.get<[string, number][]>('/api/v1/certificates/summary'),
})
export const subdomainKeys = {
all: ['subdomains'] as const,
list: (domainId: number) => [...subdomainKeys.all, domainId] as const,
}
export const subdomainsListQueryOptions = (domainId: number) =>
queryOptions({
queryKey: subdomainKeys.list(domainId),
queryFn: async () => {
const data = await api.get<unknown[]>(`/api/v1/domains/${domainId}/subdomains`)
return z.array(subdomainSchema).parse(data)
},
})
export interface CreateServiceBindingBody {
domain_id: number
service_id: number
hostname?: string
target_ip?: string
}
export async function createSubdomain(domainId: number, body: CreateSubdomainInput) {
const data = await api.post<unknown>(`/api/v1/domains/${domainId}/subdomains`, body)
return subdomainSchema.parse(data)
}
export async function updateSubdomain(id: number, body: UpdateSubdomainInput) {
const data = await api.patch<unknown>(`/api/v1/subdomains/${id}`, body)
return subdomainSchema.parse(data)
}
export async function updateDomain(id: number, body: UpdateDomainInput) {
const data = await api.patch<unknown>(`/api/v1/domains/${id}`, body)
return domainSchema.parse(data)
}
export async function deleteSubdomain(id: number) {
return api.delete<{ deleted: boolean }>(`/api/v1/subdomains/${id}`)
}
export async function createServiceBinding(body: CreateServiceBindingBody) {
const data = await api.post<unknown>('/api/v1/service-bindings', body)
return serviceBindingSchema.parse(data)
}
export async function deleteServiceBinding(id: number) {
return api.delete<{ deleted: boolean }>(`/api/v1/service-bindings/${id}`)
}
export function invalidateDomainPage(
queryClient: import('@tanstack/react-query').QueryClient,
domainId: number,
) {
void queryClient.invalidateQueries({ queryKey: subdomainKeys.list(domainId) })
void queryClient.invalidateQueries({ queryKey: serviceBindingKeys.byDomain(domainId) })
void queryClient.invalidateQueries({ queryKey: domainKeys.detail(domainId) })
void queryClient.invalidateQueries({ queryKey: dnsKeys.list(domainId) })
}
export const healthStatusKeys = {
all: ['health-status'] as const,
list: (scope: 'binding' | 'group', refId: number) =>
[...healthStatusKeys.all, scope, refId] as const,
}
export function healthStatusQueryOptions(
scope: 'binding' | 'group',
refId: number,
) {
return queryOptions({
queryKey: healthStatusKeys.list(scope, refId),
queryFn: async () => {
const data = await api.get<unknown[]>(
`/api/v1/health-status?scope=${scope}&ref_id=${refId}`,
)
return z.array(ipHealthStatusSchema).parse(data)
},
refetchInterval: 10_000,
staleTime: 5_000,
})
}
export async function runHealthCheck() {
return api.post<{ checked: number }>('/api/v1/health-check/run', {})
}
export * from '@/queries/certificates'
export * from '@/queries/dns'
export * from '@/queries/domain-health'
export * from '@/queries/domains'
export * from '@/queries/groups'
export * from '@/queries/services'
+73
View File
@@ -0,0 +1,73 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import {
serviceBindingSchema,
serviceGroupsResponseSchema,
serviceViewSchema,
} from '@/lib/schemas'
import { z } from 'zod'
export const serviceKeys = {
all: ['services'] as const,
}
export const serviceGroupKeys = {
all: ['service-groups'] as const,
}
export const serviceGroupsQueryOptions = () =>
queryOptions({
queryKey: serviceGroupKeys.all,
queryFn: async () => {
const data = await api.get<unknown>('/api/v1/service-groups')
return serviceGroupsResponseSchema.parse(data)
},
})
export const servicesQueryOptions = () =>
queryOptions({
queryKey: serviceKeys.all,
queryFn: async () => {
const data = await api.get<unknown[]>('/api/v1/services')
return z.array(serviceViewSchema).parse(data)
},
})
export const serviceBindingKeys = {
all: ['service-bindings'] as const,
byDomain: (domainId: number) => [...serviceBindingKeys.all, 'domain', domainId] as const,
}
export const serviceBindingsQueryOptions = () =>
queryOptions({
queryKey: serviceBindingKeys.all,
queryFn: async () => {
const data = await api.get<unknown[]>('/api/v1/service-bindings')
return z.array(serviceBindingSchema).parse(data)
},
})
export const domainServiceBindingsQueryOptions = (domainId: number) =>
queryOptions({
queryKey: serviceBindingKeys.byDomain(domainId),
queryFn: async () => {
const data = await api.get<unknown[]>(`/api/v1/domains/${domainId}/service-bindings`)
return z.array(serviceBindingSchema).parse(data)
},
})
export interface CreateServiceBindingBody {
domain_id: number
service_id: number
hostname?: string
target_ip?: string
}
export async function createServiceBinding(body: CreateServiceBindingBody) {
const data = await api.post<unknown>('/api/v1/service-bindings', body)
return serviceBindingSchema.parse(data)
}
export async function deleteServiceBinding(id: number) {
return api.delete<{ deleted: boolean }>(`/api/v1/service-bindings/${id}`)
}
@@ -7,7 +7,8 @@ import { toast } from 'sonner'
import type { Filter } from '@/components/reui/filters'
import { api } from '@/lib/api-client'
import { createDnsRecordSchema, type CreateDnsRecordInput } from '@/lib/schemas'
import { domainDetailQueryOptions, dnsKeys, dnsListQueryOptions, subdomainKeys } from '@/queries'
import { domainDetailQueryOptions, dnsKeys, dnsListQueryOptions, domainServiceBindingsQueryOptions, subdomainKeys } from '@/queries'
import { useDomainHealthByIp } from '@/hooks/use-domain-health'
import { PageShell } from '@/components/page-shell'
import { DetailPanel, ResourcePage } from '@/components/reui-kit'
import {
@@ -44,7 +45,10 @@ export const Route = createFileRoute('/_auth/domains/$domainId/dns')({
loader: async ({ context: { queryClient }, params }) => {
const id = Number(params.domainId)
const domain = await queryClient.ensureQueryData(domainDetailQueryOptions(id))
await queryClient.ensureQueryData(dnsListQueryOptions(id))
await Promise.all([
queryClient.ensureQueryData(dnsListQueryOptions(id)),
queryClient.ensureQueryData(domainServiceBindingsQueryOptions(id)),
])
return { breadcrumb: domain.zone_name }
},
component: DnsPage,
@@ -74,6 +78,8 @@ function DnsPage() {
error: recordsErr,
refetch: refetchRecords,
} = useQuery(dnsListQueryOptions(id))
const { data: bindings } = useQuery(domainServiceBindingsQueryOptions(id))
const healthByIp = useDomainHealthByIp(bindings)
const isLoading = domainLoading || recordsLoading
const isError = domainError || recordsError
@@ -138,6 +144,7 @@ function DnsPage() {
const columns = useDnsColumns({
onDelete: (recordId) => deleteMutation.mutate(recordId),
isDeleting: deleteMutation.isPending,
healthByIp,
})
const displayRecords = useMemo(() => {
@@ -1,5 +1,6 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import {
FolderTreeIcon,
GlobeIcon,
@@ -7,17 +8,22 @@ import {
ServerIcon,
ShieldCheckIcon,
} from 'lucide-react'
import { toast } from 'sonner'
import type { Filter } from '@/components/reui/filters'
import type { CertMonitoring } from '@cfdm/shared'
import {
domainDetailQueryOptions,
domainServiceBindingsQueryOptions,
healthStatusKeys,
runHealthCheck,
serviceGroupsQueryOptions,
servicesQueryOptions,
subdomainsListQueryOptions,
} from '@/queries'
import { useDomainPage } from '@/hooks/use-domain-page'
import type { SubdomainTableRow } from '@/hooks/use-domain-page'
import { useDomainHealthByIp } from '@/hooks/use-domain-health'
import { aggregateHealth } from '@/lib/use-aggregated-health'
import { PageShell } from '@/components/page-shell'
import { QueryState } from '@/components/query-state'
import { DetailPanel, ResourcePage } from '@/components/reui-kit'
@@ -34,6 +40,8 @@ import {
type SubdomainEditValues,
} from '@/components/subdomain-edit-sheet'
import { DomainBindingsCard } from '@/components/domain-bindings-card'
import { DomainAvailabilityPanel } from '@/components/domains/domain-availability-panel'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { StatusBadge } from '@/components/status-badge'
import { certMonitoringLabel, certMonitoringOptions } from '@/lib/cert-monitoring'
import { formatDate } from '@/lib/format'
@@ -48,6 +56,12 @@ import {
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@cfdm/ui/components/tabs'
export const Route = createFileRoute('/_auth/domains/$domainId/')({
loader: async ({ context: { queryClient }, params }) => {
@@ -81,10 +95,12 @@ function DomainPageSkeleton() {
function DomainOverviewPage() {
const { domainId } = Route.useParams()
const id = Number(domainId)
const queryClient = useQueryClient()
const [filters, setFilters] = useState<Filter[]>(createDefaultSubdomainFilters)
const [sheetOpen, setSheetOpen] = useState(false)
const [sheetMode, setSheetMode] = useState<'create' | 'edit'>('create')
const [editTarget, setEditTarget] = useState<SubdomainTableRow | null>(null)
const [activeTab, setActiveTab] = useState('overview')
const {
domain,
@@ -104,6 +120,25 @@ function DomainOverviewPage() {
linkServiceMutation,
} = useDomainPage(id)
const healthByIp = useDomainHealthByIp(bindings)
const aggregatedHealth = useMemo(
() => aggregateHealth(Object.values(healthByIp)),
[healthByIp],
)
const runCheckMutation = useMutation({
mutationFn: runHealthCheck,
onSuccess: async (data) => {
toast.success(`Проверено IP: ${data.checked}`)
await queryClient.invalidateQueries({ queryKey: healthStatusKeys.all })
},
onError: (err) => {
toast.error(
err instanceof Error ? err.message : 'Не удалось запустить проверку',
)
},
})
function openCreateSheet() {
setSheetMode('create')
setEditTarget(null)
@@ -271,8 +306,16 @@ function DomainOverviewPage() {
/>
}
>
DNS-записи
DNS
</Button>
<LoadingButton
variant="outline"
onClick={() => runCheckMutation.mutate()}
isLoading={runCheckMutation.isPending}
loadingLabel="Проверка…"
>
Проверить сейчас
</LoadingButton>
</>
)
@@ -289,80 +332,181 @@ function DomainOverviewPage() {
<DetailPanel>
<DetailPanel.Header
title={domain.zone_name}
description="Обзор домена и поддоменов"
description="Карточка домена"
actions={headerActions}
>
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:gap-3">
<span className="text-muted-foreground flex items-center gap-2 text-sm">
<ShieldCheckIcon className="size-4" aria-hidden="true" />
Мониторинг SSL (apex):
</span>
<Select
items={certMonitoringItems}
value={domain.cert_monitoring}
onValueChange={(value) =>
updateDomainCertMonitoringMutation.mutate(
(value ?? 'auto') as CertMonitoring,
)
}
disabled={updateDomainCertMonitoringMutation.isPending}
>
<SelectTrigger className="w-full sm:w-56">
<SelectValue>
{certMonitoringLabel(domain.cert_monitoring)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{certMonitoringOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex flex-wrap items-center gap-2">
<HealthCheckBadge
status={aggregatedHealth.status}
latencyMs={aggregatedHealth.worstLatencyMs}
lastCheckedAt={aggregatedHealth.lastCheckedAt}
lastError={aggregatedHealth.lastError}
title="Агрегат по привязкам"
showLatency
/>
{aggregatedHealth.total > 0 ? (
<span className="text-muted-foreground text-xs tabular-nums">
{aggregatedHealth.upCount}/{aggregatedHealth.total} OK
</span>
) : null}
</div>
</DetailPanel.Header>
<DetailPanel.Metrics cards={metricCards} />
<DetailPanel.Section
title="Привязки сервисов"
description="Сервисы, назначенные hostname в этой зоне"
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex w-full flex-col gap-4"
>
<DomainBindingsCard bindings={bindings} />
</DetailPanel.Section>
<TabsList variant="line" className="gap-5">
<TabsTrigger
value="overview"
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
>
<span>Обзор</span>
</TabsTrigger>
<TabsTrigger
value="dns"
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
>
<span>DNS</span>
</TabsTrigger>
<TabsTrigger
value="availability"
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
>
<span>Доступность</span>
</TabsTrigger>
<TabsTrigger
value="subdomains"
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
>
<span>Поддомены</span>
<span className="bg-muted text-muted-foreground inline-flex min-w-5 items-center justify-center rounded-md px-1.5 py-0.5 text-xs tabular-nums">
{subdomainRows.length}
</span>
</TabsTrigger>
<TabsTrigger
value="bindings"
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
>
<span>Привязки</span>
<span className="bg-muted text-muted-foreground inline-flex min-w-5 items-center justify-center rounded-md px-1.5 py-0.5 text-xs tabular-nums">
{bindings.length}
</span>
</TabsTrigger>
</TabsList>
<DetailPanel.Section title="Поддомены">
<ResourcePage
title="Поддомены"
description={`Записи в зоне ${domain.zone_name}`}
hideHeader
tabs={SUBDOMAIN_TABS.map((tab) => ({ ...tab }))}
tabFilter={subdomainTabFilter}
filterFields={filterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={() => setFilters(createDefaultSubdomainFilters())}
getFilterFieldValue={subdomainFilterFieldValue}
columns={columns}
data={subdomainRows}
getRowId={(row) => String(row.subdomain.id)}
toolbarExtra={
<Button type="button" onClick={openCreateSheet}>
Добавить поддомен
<TabsContent value="overview" className="flex flex-col gap-4">
<DetailPanel.Metrics cards={metricCards} />
<DetailPanel.Section
title="Мониторинг SSL"
description="Настройка проверки сертификата для apex-зоны"
>
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:gap-3">
<span className="text-muted-foreground flex items-center gap-2 text-sm">
<ShieldCheckIcon className="size-4" aria-hidden="true" />
Мониторинг SSL (apex):
</span>
<Select
items={certMonitoringItems}
value={domain.cert_monitoring}
onValueChange={(value) =>
updateDomainCertMonitoringMutation.mutate(
(value ?? 'auto') as CertMonitoring,
)
}
disabled={updateDomainCertMonitoringMutation.isPending}
>
<SelectTrigger className="w-full sm:w-56">
<SelectValue>
{certMonitoringLabel(domain.cert_monitoring)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{certMonitoringOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</DetailPanel.Section>
</TabsContent>
<TabsContent value="dns" className="flex flex-col gap-4">
<DetailPanel.Section
title="DNS-записи"
description="Управление записями зоны в Cloudflare"
>
<p className="text-muted-foreground text-sm">
Полный редактор DNS вынесен на отдельную страницу.
</p>
<Button
variant="outline"
nativeButton={false}
render={
<Link
to="/domains/$domainId/dns"
params={{ domainId }}
search={{ host: undefined }}
/>
}
>
Открыть DNS
</Button>
}
emptyState={{
title: 'Поддомены не созданы',
description: 'Добавьте поддомен для привязки сервисов',
action: (
<Button type="button" onClick={openCreateSheet}>
Добавить поддомен
</Button>
),
}}
/>
</DetailPanel.Section>
</DetailPanel.Section>
</TabsContent>
<TabsContent value="availability" className="flex flex-col gap-4">
<DomainAvailabilityPanel domainId={id} />
</TabsContent>
<TabsContent value="subdomains" className="flex flex-col gap-4">
<DetailPanel.Section title="Поддомены">
<ResourcePage
title="Поддомены"
description={`Записи в зоне ${domain.zone_name}`}
hideHeader
tabs={SUBDOMAIN_TABS.map((tab) => ({ ...tab }))}
tabFilter={subdomainTabFilter}
filterFields={filterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={() =>
setFilters(createDefaultSubdomainFilters())
}
getFilterFieldValue={subdomainFilterFieldValue}
columns={columns}
data={subdomainRows}
getRowId={(row) => String(row.subdomain.id)}
toolbarExtra={
<Button type="button" onClick={openCreateSheet}>
Добавить поддомен
</Button>
}
emptyState={{
title: 'Поддомены не созданы',
description: 'Добавьте поддомен для привязки сервисов',
action: (
<Button type="button" onClick={openCreateSheet}>
Добавить поддомен
</Button>
),
}}
/>
</DetailPanel.Section>
</TabsContent>
<TabsContent value="bindings" className="flex flex-col gap-4">
<DetailPanel.Section
title="Привязки сервисов"
description="Сервисы, назначенные hostname в этой зоне"
>
<DomainBindingsCard bindings={bindings} />
</DetailPanel.Section>
</TabsContent>
</Tabs>
<SubdomainEditSheet
mode={sheetMode}
+46 -10
View File
@@ -1,4 +1,4 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { createFileRoute } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { useForm } from 'react-hook-form'
@@ -6,8 +6,9 @@ import { zodResolver } from '@hookform/resolvers/zod'
import { toast } from 'sonner'
import type { Filter } from '@/components/reui/filters'
import { api } from '@/lib/api-client'
import { createDomainSchema, type CreateDomainInput } from '@/lib/schemas'
import { createDomainSchema, type BulkUpdateDomainsInput, type CreateDomainInput } from '@/lib/schemas'
import {
bulkUpdateDomains,
domainKeys,
domainsListQueryOptions,
groupsQueryOptions,
@@ -23,6 +24,7 @@ import {
useDomainColumns,
useDomainFilterFields,
} from '@/components/columns/domains-columns'
import { DomainsBulkToolbar } from '@/components/domains/domains-bulk-toolbar'
import { FormSheet } from '@/components/form-sheet'
import { FormFieldSimple } from '@/components/form-field'
import { LoadingButton } from '@/components/loading-button'
@@ -106,9 +108,21 @@ function DomainsPage() {
},
})
const bulkMutation = useMutation({
mutationFn: (body: BulkUpdateDomainsInput) => bulkUpdateDomains(body),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: domainKeys.all })
toast.success(`Обновлено: ${data.updated}`)
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось обновить')
},
})
const { columns } = useDomainColumns({
onRequestDelete: setDeleteTarget,
isDeleting: deleteMutation.isPending,
enableSelection: true,
})
const handleCreate = (values: CreateDomainInput) => {
@@ -125,14 +139,9 @@ function DomainsPage() {
}
const primaryAction = (
<>
<Button type="button" variant="outline" nativeButton={false} render={<Link to="/groups" />}>
Группы
</Button>
<Button type="button" onClick={() => setSheetOpen(true)}>
Импортировать домен
</Button>
</>
<Button type="button" onClick={() => setSheetOpen(true)}>
Импортировать домен
</Button>
)
return (
@@ -155,6 +164,33 @@ function DomainsPage() {
error={error}
onRetry={refetch}
primaryAction={primaryAction}
enableRowSelection
selectionToolbar={({ selectedIds, clearSelection }) => (
<DomainsBulkToolbar
count={selectedIds.length}
isPending={bulkMutation.isPending}
groupItems={groupItems}
onAssignGroup={(groupId) => {
bulkMutation.mutate(
{ ids: selectedIds.map(Number), group_id: groupId },
{ onSuccess: () => clearSelection() },
)
}}
onSetEnvironment={(environment) => {
bulkMutation.mutate(
{ ids: selectedIds.map(Number), environment },
{ onSuccess: () => clearSelection() },
)
}}
onAddTag={(tag) => {
bulkMutation.mutate(
{ ids: selectedIds.map(Number), tags_add: [tag] },
{ onSuccess: () => clearSelection() },
)
}}
onClear={clearSelection}
/>
)}
emptyState={{
title: 'Домены не импортированы',
description: 'Импортируйте зону из аккаунта Cloudflare',
+66 -4
View File
@@ -2,6 +2,7 @@ import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { useMemo, useState, useEffect } from 'react'
import {
ActivityIcon,
AlertTriangleIcon,
FolderTreeIcon,
} from 'lucide-react'
@@ -19,6 +20,7 @@ import {
OpsDashboard,
type KpiStatCard,
} from '@/components/reui-kit'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { StatusBadge } from '@/components/status-badge'
import {
Item,
@@ -99,6 +101,22 @@ function DashboardPage() {
[domains],
)
const attentionDomains = useMemo(
() =>
(domains ?? [])
.filter((d) => d.health_status === 'down' || d.health_status === 'degraded')
.slice(0, 8),
[domains],
)
const attentionCount = useMemo(
() =>
(domains ?? []).filter(
(d) => d.health_status === 'down' || d.health_status === 'degraded',
).length,
[domains],
)
const certWarnings = countByStatus(summary, ['warning', 'expired', 'error'])
const certOk = countByStatus(summary, ['active', 'ok'])
@@ -128,9 +146,11 @@ function DashboardPage() {
label: 'Домены',
value: domains?.length ?? 0,
hint:
ungroupedCount > 0
? `${ungroupedCount} без группы`
: 'Все в группах',
attentionCount > 0
? `${attentionCount} требуют внимания`
: ungroupedCount > 0
? `${ungroupedCount} без группы`
: 'Все в группах',
to: '/domains',
},
{
@@ -174,7 +194,49 @@ function DashboardPage() {
</>
}
queue={
<div className="grid gap-3 lg:grid-cols-2">
<div className="grid gap-3 lg:grid-cols-3">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<ActivityIcon
className="text-destructive size-4 shrink-0"
aria-hidden="true"
/>
<h3 className="text-sm font-semibold">Проблемы health-check</h3>
{attentionCount > 0 ? (
<span className="text-muted-foreground text-xs tabular-nums">
({attentionCount})
</span>
) : null}
</div>
{attentionDomains.length === 0 ? (
<p className="text-muted-foreground text-sm">
Нет доменов со статусом Down или Slow
</p>
) : (
<ItemGroup className="gap-2">
{attentionDomains.map((domain) => (
<Item key={domain.id} variant="outline" size="sm">
<ItemContent className="flex flex-row items-center justify-between gap-2">
<ItemTitle className="truncate font-medium">
<Link
to="/domains/$domainId"
params={{ domainId: String(domain.id) }}
className="hover:underline"
>
{domain.zone_name}
</Link>
</ItemTitle>
<HealthCheckBadge
status={domain.health_status ?? 'unknown'}
latencyMs={domain.health_latency_ms}
size="xs"
/>
</ItemContent>
</Item>
))}
</ItemGroup>
)}
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<AlertTriangleIcon