feat(health-checks): implement health check IP toggling and configuration updates
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 8s
quality / changes (push) Successful in 5s
quality / docker-check (push) Skipped
quality / web (push) Successful in 51s
quality / api (push) Successful in 43s
CD / quality (push) Successful in 1m43s
CD / publish (push) Successful in 1m38s

- Added functionality to toggle individual IPs for services, allowing for dynamic management of IP health status.
- Enhanced the health check configuration in the UI, enabling users to set parameters directly from the settings page.
- Updated service views to include IP health tracking, improving visibility into the status of each IP associated with a service.
- Refactored relevant components to support the new IP toggling feature, ensuring a seamless user experience.

This commit significantly enhances the health management capabilities of services, providing users with more control over IP configurations and health monitoring.
This commit is contained in:
Denozordec
2026-08-19 15:33:47 +07:00
parent 4224db8eb3
commit d63c86065c
35 changed files with 1538 additions and 123 deletions
@@ -20,6 +20,7 @@ import { Switch } from '@cfdm/ui/components/switch'
import { Button } from '@cfdm/ui/components/button'
import { ButtonGroup } from '@cfdm/ui/components/button-group'
import { FieldGroup } from '@cfdm/ui/components/field'
import { Link } from '@tanstack/react-router'
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { cn } from '@cfdm/ui/lib/utils'
@@ -213,7 +214,18 @@ export function HealthCheckConfigFields({
Checks, API вернёт ошибку останется Local. Workers не используются.
</AlertDescription>
</Alert>
) : null}
) : (
<Alert>
<AlertTitle>Local health-check</AlertTitle>
<AlertDescription>
Интервал и таймаут пробы ниже. Cron и пороги Slow/Down задаются в{' '}
<Link to="/settings/health" className="text-foreground underline">
Настройках Health-check
</Link>
, как параметры Cloudflare Health Checks в этой форме.
</AlertDescription>
</Alert>
)}
<SettingRow
title="Health-check"
@@ -3,6 +3,7 @@ import { Link, useNavigate } from '@tanstack/react-router'
import {
FolderTreeIcon,
GlobeIcon,
HeartPulseIcon,
LayoutDashboardIcon,
SearchIcon,
ServerIcon,
@@ -57,6 +58,12 @@ const NAV_ITEMS = [
keywords: ['certificates', 'ssl', 'tls'],
icon: ShieldCheckIcon,
},
{
to: '/settings/health',
label: 'Health-check',
keywords: ['health', 'health-check', 'cron', 'пороги', 'настройки'],
icon: HeartPulseIcon,
},
{
to: '/settings/integrations',
label: 'Настройки',
@@ -24,6 +24,7 @@ const routeTitles: Record<string, string> = {
'/services': 'Сервисы',
'/certificates': 'Сертификаты',
'/settings/appearance': 'Внешний вид',
'/settings/health': 'Health-check',
'/settings/integrations': 'Интеграции',
}
@@ -64,6 +65,8 @@ function getBreadcrumbs(
{ label: 'Настройки', href: '/settings/appearance' },
...(pathname === '/settings/integrations'
? [{ label: 'Интеграции', href: pathname }]
: pathname === '/settings/health'
? [{ label: 'Health-check', href: pathname }]
: pathname === '/settings/appearance'
? [{ label: 'Внешний вид', href: pathname }]
: []),
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react'
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
import { PaletteIcon, SettingsIcon } from 'lucide-react'
import { HeartPulseIcon, PaletteIcon, SettingsIcon } from 'lucide-react'
import { useIsMobile } from '@cfdm/ui/hooks/use-mobile'
import { cn } from '@cfdm/ui/lib/utils'
@@ -21,6 +21,12 @@ const DEFAULT_TABS: SettingsTabConfig[] = [
label: 'Внешний вид',
icon: <PaletteIcon className="size-4" aria-hidden="true" />,
},
{
id: 'health',
to: '/settings/health',
label: 'Health-check',
icon: <HeartPulseIcon className="size-4" aria-hidden="true" />,
},
{
id: 'integrations',
to: '/settings/integrations',
@@ -37,7 +43,7 @@ interface SettingsShellProps {
export function SettingsShell({
title = 'Настройки',
description = 'Внешний вид и интеграции',
description = 'Внешний вид, health-check и интеграции',
tabs = DEFAULT_TABS,
}: SettingsShellProps) {
const isMobile = useIsMobile()
@@ -26,9 +26,11 @@ interface ServiceCatalogSectionProps {
group: ServiceGroupView | null
services: ServiceView[]
togglingId: number | null
togglingIp: { serviceId: number; ip: string } | null
onEditService: (service: ServiceView) => void
onDeleteService: (service: ServiceView) => void
onToggleService: (serviceId: number, enabled: boolean) => void
onToggleServiceIp: (serviceId: number, ip: string, enabled: boolean) => void
onEditGroup: (group: ServiceGroupView) => void
onDeleteGroup: (group: ServiceGroupView) => void
onAddServiceToGroup: (groupId: number | null) => void
@@ -38,9 +40,11 @@ export function ServiceCatalogSection({
group,
services,
togglingId,
togglingIp,
onEditService,
onDeleteService,
onToggleService,
onToggleServiceIp,
onEditGroup,
onDeleteGroup,
onAddServiceToGroup,
@@ -148,9 +152,13 @@ export function ServiceCatalogSection({
key={service.id}
service={service}
togglingId={togglingId}
togglingIp={
togglingIp?.serviceId === service.id ? togglingIp.ip : null
}
onEditService={onEditService}
onDeleteService={onDeleteService}
onToggleService={onToggleService}
onToggleServiceIp={onToggleServiceIp}
/>
))}
</div>
@@ -8,6 +8,7 @@ import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { serviceDisplayFqdns } from '@/lib/service-utils'
import type { ServiceView } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button'
import { Switch } from '@cfdm/ui/components/switch'
import {
Tooltip,
TooltipContent,
@@ -121,6 +122,10 @@ const VISIBLE_IP_LIMIT = 6
interface ServiceIpListProps {
ips: string[]
ipHealth?: ServiceView['ip_health']
ipEnabled?: Record<string, boolean>
togglingIp?: string | null
ipToggleDisabled?: boolean
onToggleIp?: (ip: string, enabled: boolean) => void
className?: string
emptyLabel?: string
copyable?: boolean
@@ -130,6 +135,10 @@ interface ServiceIpListProps {
export function ServiceIpList({
ips,
ipHealth = [],
ipEnabled = {},
togglingIp = null,
ipToggleDisabled = false,
onToggleIp,
className,
emptyLabel = 'Нет IP',
copyable = false,
@@ -144,13 +153,14 @@ export function ServiceIpList({
}
const healthByIp = new Map(ipHealth.map((row) => [row.ip, row]))
const visible = ips.slice(0, VISIBLE_IP_LIMIT)
const visible = onToggleIp ? ips : ips.slice(0, VISIBLE_IP_LIMIT)
const extraCount = ips.length - visible.length
return (
<div className={cn('flex min-w-0 flex-col gap-1', className)}>
{visible.map((ip) => {
const health = healthByIp.get(ip)
const enabled = ipEnabled[ip] !== false
return (
<div key={ip} className="flex min-w-0 items-center gap-1.5">
<HealthCheckBadge
@@ -160,13 +170,29 @@ export function ServiceIpList({
/>
<TruncatedText
className={cn(
'text-muted-foreground min-w-0 font-mono text-xs',
'min-w-0 font-mono text-xs',
enabled ? 'text-muted-foreground' : 'text-muted-foreground/60',
textClassName,
)}
>
{ip}
</TruncatedText>
{copyable ? <CopyFqdnButton value={ip} /> : null}
{onToggleIp ? (
<Switch
size="sm"
className="ml-auto shrink-0"
checked={enabled}
disabled={ipToggleDisabled || togglingIp === ip}
onClick={(event) => {
event.stopPropagation()
}}
onCheckedChange={(checked) => onToggleIp(ip, Boolean(checked))}
aria-label={
enabled ? `Выключить IP ${ip}` : `Включить IP ${ip}`
}
/>
) : null}
</div>
)
})}
@@ -1,6 +1,7 @@
import { Link } from '@tanstack/react-router'
import { MoreHorizontalIcon, ServerIcon } from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import {
Frame,
FrameDescription,
@@ -10,9 +11,10 @@ import {
} from '@/components/reui/frame'
import { IconTile } from '@/components/reui/icon-tile'
import {
ServiceFqdnList,
CopyFqdnButton,
ServiceIpList,
} from '@/components/services/service-fqdn-list'
import { serviceDisplayFqdn, serviceDisplayFqdns } from '@/lib/service-utils'
import type { ServiceView } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button'
import {
@@ -22,22 +24,36 @@ import {
DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu'
import { Switch } from '@cfdm/ui/components/switch'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@cfdm/ui/components/tooltip'
interface ServiceUnitCardProps {
service: ServiceView
togglingId: number | null
togglingIp: string | null
onEditService: (service: ServiceView) => void
onDeleteService: (service: ServiceView) => void
onToggleService: (serviceId: number, enabled: boolean) => void
onToggleServiceIp: (serviceId: number, ip: string, enabled: boolean) => void
}
export function ServiceUnitCard({
service,
togglingId,
togglingIp,
onEditService,
onDeleteService,
onToggleService,
onToggleServiceIp,
}: ServiceUnitCardProps) {
const fqdns = serviceDisplayFqdns(service)
const primaryDomain = serviceDisplayFqdn(service)
const extraCount = Math.max(0, fqdns.length - 1)
return (
<Frame dense spacing="sm" className="h-full min-w-0 overflow-hidden">
<FrameHeader className="flex-row items-center justify-between gap-2">
@@ -60,9 +76,38 @@ export function ServiceUnitCard({
{service.name}
</Link>
</FrameTitle>
<FrameDescription className="truncate font-mono text-xs">
{service.slug}
</FrameDescription>
<div className="flex min-w-0 items-center gap-1">
<FrameDescription className="min-w-0 truncate font-mono text-xs">
{primaryDomain}
</FrameDescription>
{extraCount > 0 ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Badge
variant="outline"
size="xs"
className="shrink-0 tabular-nums"
/>
}
>
+{extraCount}
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<ul className="flex flex-col gap-0.5 font-mono text-xs">
{fqdns.map((fqdn) => (
<li key={fqdn}>{fqdn}</li>
))}
</ul>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
{primaryDomain !== '—' ? (
<CopyFqdnButton value={fqdns.join('\n')} />
) : null}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
@@ -115,12 +160,17 @@ export function ServiceUnitCard({
</div>
</FrameHeader>
<FramePanel className="flex min-w-0 flex-col gap-1.5 pt-0 shadow-none!">
<ServiceFqdnList copyable service={service} emptyLabel="Не задан" />
<FramePanel className="flex min-w-0 flex-col gap-1 pt-0 shadow-none!">
<ServiceIpList
copyable
ips={service.ips ?? []}
ipHealth={service.ip_health ?? []}
ipEnabled={service.ip_enabled ?? {}}
ipToggleDisabled={togglingId === service.id}
togglingIp={togglingIp}
onToggleIp={(ip, enabled) =>
onToggleServiceIp(service.id, ip, enabled)
}
/>
</FramePanel>
</Frame>
@@ -160,11 +160,13 @@ interface ServicesGroupedCatalogProps {
primaryAction?: ReactNode
hideHeader?: boolean
togglingId: number | null
togglingIp: { serviceId: number; ip: string } | null
activeTab?: string
onTabChange?: (tabId: string) => void
onEditService: (service: ServiceView) => void
onDeleteService: (service: ServiceView) => void
onToggleService: (serviceId: number, enabled: boolean) => void
onToggleServiceIp: (serviceId: number, ip: string, enabled: boolean) => void
onEditGroup: (group: ServiceGroupView) => void
onDeleteGroup: (group: ServiceGroupView) => void
onAddServiceToGroup: (groupId: number | null) => void
@@ -179,10 +181,12 @@ export function ServicesGroupedCatalog({
primaryAction,
hideHeader = false,
togglingId,
togglingIp,
activeTab = 'all',
onEditService,
onDeleteService,
onToggleService,
onToggleServiceIp,
onEditGroup,
onDeleteGroup,
onAddServiceToGroup,
@@ -304,9 +308,11 @@ export function ServicesGroupedCatalog({
group={unit.group}
services={unit.services}
togglingId={togglingId}
togglingIp={togglingIp}
onEditService={onEditService}
onDeleteService={onDeleteService}
onToggleService={onToggleService}
onToggleServiceIp={onToggleServiceIp}
onEditGroup={onEditGroup}
onDeleteGroup={onDeleteGroup}
onAddServiceToGroup={onAddServiceToGroup}