feat(services): enhance service health management with IP health tracking
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / changes (push) Successful in 5s
quality / docker-check (push) Skipped
quality / web (push) Successful in 55s
quality / api (push) Successful in 41s
CD / quality (push) Successful in 1m45s
CD / publish (push) Successful in 1m33s

- Introduced IP health tracking in service views, allowing for detailed monitoring of individual IP statuses and latencies.
- Updated the service configuration to include an `ip_health` array, providing structured health data for each IP.
- Enhanced the `attachServiceHealth` function to aggregate IP health data alongside overall service health.
- Modified relevant components to display IP health information, improving visibility and user experience in service management interfaces.

This commit significantly improves the health monitoring capabilities of services, enabling better insights into the status of individual IPs associated with each service.
This commit is contained in:
Denozordec
2026-08-19 14:57:19 +07:00
parent 4ca948292d
commit 4224db8eb3
15 changed files with 346 additions and 157 deletions
@@ -88,7 +88,11 @@ export function ServiceKanbanCard({
</div>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-muted-foreground text-xs">IP</span>
<ServiceIpList copyable ips={service.ips ?? []} />
<ServiceIpList
copyable
ips={service.ips ?? []}
ipHealth={service.ip_health ?? []}
/>
</div>
</ItemContent>
+51 -3
View File
@@ -3,9 +3,11 @@ import { PlusIcon, Trash2Icon } from 'lucide-react'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { TaggedInput, isValidIpv4 } from '@/components/tagged-input'
import { ServiceBindingIpInput } from '@/components/service-binding-ip-input'
import type {
LbMode,
HealthCheckType,
import {
HealthCheckConfigFields,
type LbAndHealthConfig,
type LbMode,
type HealthCheckType,
} from '@/components/health-check-config-fields'
import type {
CreateServiceWithConfigInput,
@@ -319,6 +321,43 @@ export function ServiceEditSheet({
)
}
function healthFromConfig(next: LbAndHealthConfig): BindingHealthConfig {
return {
enabled: next.enabled,
type: next.type,
port: next.port,
path: next.path,
expected_status: next.expected_status,
interval_sec: next.interval_sec,
timeout_ms: next.timeout_ms,
verify_tls: next.verify_tls,
provider: next.provider,
}
}
function handlePrimaryHealthChange(next: LbAndHealthConfig) {
const health = healthFromConfig(next)
setBindings((current) => {
if (current.length === 0) {
return [
{
...withPoolIps(emptyBindingDraft(commonFqdn), ips),
lb_mode: next.lb_mode,
health,
},
]
}
return current.map((item, index) =>
index === 0 ? { ...item, lb_mode: next.lb_mode, health } : item,
)
})
}
const primaryHealthValue: LbAndHealthConfig = {
lb_mode: bindings[0]?.lb_mode ?? 'round_robin',
...(bindings[0]?.health ?? defaultHealth),
}
function resolveServiceGroupId(): number | null {
return serviceGroupId === 'none' ? null : Number(serviceGroupId)
}
@@ -464,6 +503,15 @@ export function ServiceEditSheet({
</FieldGroup>
</section>
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium">Health check</h3>
<HealthCheckConfigFields
idPrefix="service-health"
value={primaryHealthValue}
onChange={handlePrimaryHealthChange}
/>
</section>
<section className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-medium">Доп. FQDN</h3>
@@ -1,6 +1,7 @@
import { CheckIcon, CopyIcon } from 'lucide-react'
import { toast } from 'sonner'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { Badge } from '@/components/reui/badge'
import { TruncatedText } from '@/components/truncated-text'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
@@ -119,6 +120,7 @@ const VISIBLE_IP_LIMIT = 6
interface ServiceIpListProps {
ips: string[]
ipHealth?: ServiceView['ip_health']
className?: string
emptyLabel?: string
copyable?: boolean
@@ -127,6 +129,7 @@ interface ServiceIpListProps {
export function ServiceIpList({
ips,
ipHealth = [],
className,
emptyLabel = 'Нет IP',
copyable = false,
@@ -140,20 +143,33 @@ export function ServiceIpList({
)
}
const healthByIp = new Map(ipHealth.map((row) => [row.ip, row]))
const visible = ips.slice(0, VISIBLE_IP_LIMIT)
const extraCount = ips.length - visible.length
const copyValue = ips.join('\n')
return (
<div className={cn('flex min-w-0 items-center gap-1.5', className)}>
<TruncatedText
className={cn(
'text-muted-foreground min-w-0 font-mono text-xs',
textClassName,
)}
>
{visible.join(' · ')}
</TruncatedText>
<div className={cn('flex min-w-0 flex-col gap-1', className)}>
{visible.map((ip) => {
const health = healthByIp.get(ip)
return (
<div key={ip} className="flex min-w-0 items-center gap-1.5">
<HealthCheckBadge
status={health?.status ?? 'unknown'}
latencyMs={health?.latency_ms}
size="xs"
/>
<TruncatedText
className={cn(
'text-muted-foreground min-w-0 font-mono text-xs',
textClassName,
)}
>
{ip}
</TruncatedText>
{copyable ? <CopyFqdnButton value={ip} /> : null}
</div>
)
})}
{extraCount > 0 ? (
<TooltipProvider>
<Tooltip>
@@ -162,7 +178,7 @@ export function ServiceIpList({
<Badge
variant="outline"
size="xs"
className="shrink-0 tabular-nums"
className="w-fit shrink-0 tabular-nums"
/>
}
>
@@ -170,7 +186,7 @@ export function ServiceIpList({
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<ul className="flex flex-col gap-0.5 font-mono text-xs">
{ips.map((ip) => (
{ips.slice(VISIBLE_IP_LIMIT).map((ip) => (
<li key={ip}>{ip}</li>
))}
</ul>
@@ -178,7 +194,6 @@ export function ServiceIpList({
</Tooltip>
</TooltipProvider>
) : null}
{copyable ? <CopyFqdnButton value={copyValue} /> : null}
</div>
)
}
@@ -1,7 +1,6 @@
import { Link } from '@tanstack/react-router'
import { MoreHorizontalIcon, ServerIcon } from 'lucide-react'
import { HealthCheckBadge } from '@/components/health-check-badge'
import {
Frame,
FrameDescription,
@@ -40,9 +39,9 @@ export function ServiceUnitCard({
onToggleService,
}: ServiceUnitCardProps) {
return (
<Frame dense spacing="sm" className="h-full min-w-0">
<FrameHeader className="flex-row items-start justify-between gap-2">
<div className="flex min-w-0 items-start gap-2">
<Frame dense spacing="sm" className="h-full min-w-0 overflow-hidden">
<FrameHeader className="flex-row items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<IconTile
variant="elevated"
size="sm"
@@ -67,11 +66,6 @@ export function ServiceUnitCard({
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<HealthCheckBadge
status={service.health_status ?? 'unknown'}
latencyMs={service.health_latency_ms}
size="xs"
/>
<Switch
size="sm"
checked={service.enabled}
@@ -121,13 +115,13 @@ export function ServiceUnitCard({
</div>
</FrameHeader>
<FramePanel className="flex flex-col gap-1 pt-0 shadow-none!">
<ServiceFqdnList
<FramePanel className="flex min-w-0 flex-col gap-1.5 pt-0 shadow-none!">
<ServiceFqdnList copyable service={service} emptyLabel="Не задан" />
<ServiceIpList
copyable
service={service}
emptyLabel="Не задан"
ips={service.ips ?? []}
ipHealth={service.ip_health ?? []}
/>
<ServiceIpList copyable ips={service.ips ?? []} />
</FramePanel>
</Frame>
)
@@ -1,14 +1,12 @@
import { useMemo, useState, type ReactNode } from 'react'
import {
ChevronDownIcon,
FilterIcon,
FolderPlusIcon,
FunnelXIcon,
PlusIcon,
SearchIcon,
ServerIcon,
} from 'lucide-react'
import { Filters, type Filter } from '@/components/reui/filters'
import {
Frame,
FrameDescription,
@@ -16,21 +14,13 @@ import {
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import { EmptyState } from '@/components/empty-state'
import {
applyFiltersToData,
getActiveFilters,
} from '@/components/reui-kit/filter-utils'
import { ServiceCatalogSection } from '@/components/services/service-catalog-section'
import {
SERVICE_TABS,
createDefaultServiceFilters,
serviceFilterFieldValue,
serviceTabFilter,
useServiceFilterFields,
type ServiceCatalogRow,
} from '@/components/columns/services-columns'
import { serviceDisplayFqdns } from '@/lib/service-utils'
import type {
ServiceGroupView,
ServiceGroupsResponse,
@@ -43,23 +33,29 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu'
import { Separator } from '@cfdm/ui/components/separator'
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from '@cfdm/ui/components/input-group'
import { Skeleton } from '@cfdm/ui/components/skeleton'
const HEALTH_TABS = [
{ id: 'health-ok', label: 'OK' },
{ id: 'health-slow', label: 'Slow' },
{ id: 'health-down', label: 'Down' },
{ id: 'health-unknown', label: '—' },
] as const
const ALL_TABS = [...SERVICE_TABS, ...HEALTH_TABS] as const
function serviceMatchesDomain(service: ServiceView, domainId?: number) {
if (domainId == null) return true
return service.domains.some((d) => d.domain_id === domainId)
}
function serviceMatchesQuery(service: ServiceView, query: string) {
const needle = query.trim().toLowerCase()
if (!needle) return true
if (service.name.toLowerCase().includes(needle)) return true
if (service.slug.toLowerCase().includes(needle)) return true
return serviceDisplayFqdns(service).some((fqdn) =>
fqdn.toLowerCase().includes(needle),
)
}
function toCatalogRow(
service: ServiceView,
groupId: number | null,
@@ -77,18 +73,6 @@ function toCatalogRow(
}
}
function catalogTabFilter(row: ServiceCatalogRow, tabId: string) {
if (tabId.startsWith('health-')) {
const status = row.service.health_status ?? 'unknown'
if (tabId === 'health-ok') return status === 'up'
if (tabId === 'health-slow') return status === 'degraded'
if (tabId === 'health-down') return status === 'down'
if (tabId === 'health-unknown') return status === 'unknown'
return true
}
return serviceTabFilter(row, tabId)
}
interface GroupUnitData {
id: string
group: ServiceGroupView | null
@@ -195,8 +179,7 @@ export function ServicesGroupedCatalog({
primaryAction,
hideHeader = false,
togglingId,
activeTab: controlledTab,
onTabChange,
activeTab = 'all',
onEditService,
onDeleteService,
onToggleService,
@@ -205,13 +188,7 @@ export function ServicesGroupedCatalog({
onAddServiceToGroup,
emptyAction,
}: ServicesGroupedCatalogProps) {
const [internalTab, setInternalTab] = useState('all')
const tab = controlledTab ?? internalTab
const setTab = onTabChange ?? setInternalTab
const [filters, setFilters] = useState<Filter[]>(() =>
createDefaultServiceFilters(),
)
const filterFields = useServiceFilterFields()
const [query, setQuery] = useState('')
const flatRows = useMemo(() => {
const rows: ServiceCatalogRow[] = []
@@ -228,24 +205,16 @@ export function ServicesGroupedCatalog({
return rows
}, [data, domainId])
const tabCounts = useMemo(() => {
const counts: Record<string, number> = {}
for (const t of ALL_TABS) {
counts[t.id] = flatRows.filter((row) => catalogTabFilter(row, t.id)).length
}
return counts
}, [flatRows])
const filteredIds = useMemo(() => {
const afterTab = flatRows.filter((row) => catalogTabFilter(row, tab))
const afterFilters = applyFiltersToData(afterTab, filters, (item, field) =>
serviceFilterFieldValue(item, field),
const afterTab = flatRows.filter((row) => serviceTabFilter(row, activeTab))
const afterQuery = afterTab.filter((row) =>
serviceMatchesQuery(row.service, query),
)
return new Set(afterFilters.map((r) => r.id))
}, [flatRows, tab, filters])
return new Set(afterQuery.map((r) => r.id))
}, [flatRows, activeTab, query])
const showEmptyGroups =
tab === 'all' && domainId == null && getActiveFilters(filters).length === 0
activeTab === 'all' && domainId == null && query.trim().length === 0
const units = useMemo(
() => buildGroupUnits(data, filteredIds, domainId, showEmptyGroups),
@@ -260,7 +229,7 @@ export function ServicesGroupedCatalog({
<Skeleton className="h-4 w-72" />
</FrameHeader>
<FramePanel className="flex flex-col gap-3 p-4">
<Skeleton className="h-9 w-full max-w-md" />
<Skeleton className="h-8 w-full max-w-md" />
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-36 w-full rounded-xl" />
))}
@@ -307,70 +276,28 @@ export function ServicesGroupedCatalog({
</FrameHeader>
) : null}
<FramePanel className="p-0 shadow-none!">
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
<CountedLineTabs
tabs={ALL_TABS.map((t) => ({
id: t.id,
label: t.label,
count: tabCounts[t.id] ?? 0,
}))}
value={tab}
onValueChange={setTab}
<FramePanel className="flex flex-col gap-4">
<InputGroup className="max-w-md">
<InputGroupAddon>
<InputGroupText>
<SearchIcon aria-hidden />
</InputGroupText>
</InputGroupAddon>
<InputGroupInput
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Поиск по названию"
aria-label="Поиск по названию"
/>
</div>
<Separator />
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
<Filters
filters={filters}
fields={filterFields}
onChange={setFilters}
size="default"
trigger={
<Button type="button" variant="outline" aria-label="Фильтры">
<FilterIcon className="size-4" aria-hidden />
Фильтры
</Button>
}
/>
<Button
type="button"
variant="outline"
onClick={() => {
setTab('all')
setFilters(createDefaultServiceFilters())
}}
>
<FunnelXIcon className="size-4" aria-hidden />
Сбросить
</Button>
</div>
<Separator />
</InputGroup>
{units.length === 0 ? (
<div className="p-6">
<EmptyState
title="Нет совпадений"
description="Измените фильтры или вкладку."
action={
<Button
type="button"
variant="outline"
onClick={() => {
setTab('all')
setFilters(createDefaultServiceFilters())
}}
>
Сбросить
</Button>
}
/>
</div>
<EmptyState
title="Нет совпадений"
description="Измените запрос поиска."
/>
) : (
<div className="flex flex-col gap-6 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
<div className="flex flex-col gap-6">
{units.map((unit) => (
<ServiceCatalogSection
key={unit.id}
+7
View File
@@ -94,6 +94,12 @@ export const serviceDomainBindingSchema = z
: (binding.record_type ?? 'A'),
}))
export const serviceIpHealthSchema = z.object({
ip: z.string(),
status: z.enum(['up', 'down', 'degraded', 'unknown']),
latency_ms: z.number().nullable(),
})
export const serviceViewSchema = serviceSchema.extend({
subdomain: z.string().default(''),
enabled: z.coerce.boolean().default(false),
@@ -101,6 +107,7 @@ export const serviceViewSchema = serviceSchema.extend({
domains: z.array(serviceDomainBindingSchema).default([]),
health_status: z.enum(['up', 'down', 'degraded', 'unknown']).default('unknown'),
health_latency_ms: z.number().nullable().default(null),
ip_health: z.array(serviceIpHealthSchema).default([]),
})
export const serviceGroupViewSchema = serviceGroupSchema.extend({