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
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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user