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
@@ -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