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