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
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:
@@ -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}
|
||||
|
||||
@@ -108,6 +108,7 @@ export const serviceViewSchema = serviceSchema.extend({
|
||||
health_status: z.enum(['up', 'down', 'degraded', 'unknown']).default('unknown'),
|
||||
health_latency_ms: z.number().nullable().default(null),
|
||||
ip_health: z.array(serviceIpHealthSchema).default([]),
|
||||
ip_enabled: z.record(z.string(), z.boolean()).default({}),
|
||||
})
|
||||
|
||||
export const serviceGroupViewSchema = serviceGroupSchema.extend({
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Route as AuthSettingsIndexRouteImport } from './routes/_auth/settings/i
|
||||
import { Route as AuthServicesIndexRouteImport } from './routes/_auth/services/index'
|
||||
import { Route as AuthDomainsIndexRouteImport } from './routes/_auth/domains/index'
|
||||
import { Route as AuthSettingsIntegrationsRouteImport } from './routes/_auth/settings/integrations'
|
||||
import { Route as AuthSettingsHealthRouteImport } from './routes/_auth/settings/health'
|
||||
import { Route as AuthSettingsAppearanceRouteImport } from './routes/_auth/settings/appearance'
|
||||
import { Route as AuthGroupsGroupIdRouteImport } from './routes/_auth/groups/$groupId'
|
||||
import { Route as AuthServicesServiceIdRouteRouteImport } from './routes/_auth/services/$serviceId/route'
|
||||
@@ -86,6 +87,11 @@ const AuthSettingsIntegrationsRoute =
|
||||
path: '/integrations',
|
||||
getParentRoute: () => AuthSettingsRouteRoute,
|
||||
} as any)
|
||||
const AuthSettingsHealthRoute = AuthSettingsHealthRouteImport.update({
|
||||
id: '/health',
|
||||
path: '/health',
|
||||
getParentRoute: () => AuthSettingsRouteRoute,
|
||||
} as any)
|
||||
const AuthSettingsAppearanceRoute = AuthSettingsAppearanceRouteImport.update({
|
||||
id: '/appearance',
|
||||
path: '/appearance',
|
||||
@@ -154,6 +160,7 @@ export interface FileRoutesByFullPath {
|
||||
'/services/$serviceId': typeof AuthServicesServiceIdRouteRouteWithChildren
|
||||
'/groups/$groupId': typeof AuthGroupsGroupIdRoute
|
||||
'/settings/appearance': typeof AuthSettingsAppearanceRoute
|
||||
'/settings/health': typeof AuthSettingsHealthRoute
|
||||
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
||||
'/domains/': typeof AuthDomainsIndexRoute
|
||||
'/services/': typeof AuthServicesIndexRoute
|
||||
@@ -174,6 +181,7 @@ export interface FileRoutesByTo {
|
||||
'/': typeof AuthIndexRoute
|
||||
'/groups/$groupId': typeof AuthGroupsGroupIdRoute
|
||||
'/settings/appearance': typeof AuthSettingsAppearanceRoute
|
||||
'/settings/health': typeof AuthSettingsHealthRoute
|
||||
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
||||
'/domains': typeof AuthDomainsIndexRoute
|
||||
'/services': typeof AuthServicesIndexRoute
|
||||
@@ -198,6 +206,7 @@ export interface FileRoutesById {
|
||||
'/_auth/services/$serviceId': typeof AuthServicesServiceIdRouteRouteWithChildren
|
||||
'/_auth/groups/$groupId': typeof AuthGroupsGroupIdRoute
|
||||
'/_auth/settings/appearance': typeof AuthSettingsAppearanceRoute
|
||||
'/_auth/settings/health': typeof AuthSettingsHealthRoute
|
||||
'/_auth/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
||||
'/_auth/domains/': typeof AuthDomainsIndexRoute
|
||||
'/_auth/services/': typeof AuthServicesIndexRoute
|
||||
@@ -222,6 +231,7 @@ export interface FileRouteTypes {
|
||||
| '/services/$serviceId'
|
||||
| '/groups/$groupId'
|
||||
| '/settings/appearance'
|
||||
| '/settings/health'
|
||||
| '/settings/integrations'
|
||||
| '/domains/'
|
||||
| '/services/'
|
||||
@@ -242,6 +252,7 @@ export interface FileRouteTypes {
|
||||
| '/'
|
||||
| '/groups/$groupId'
|
||||
| '/settings/appearance'
|
||||
| '/settings/health'
|
||||
| '/settings/integrations'
|
||||
| '/domains'
|
||||
| '/services'
|
||||
@@ -265,6 +276,7 @@ export interface FileRouteTypes {
|
||||
| '/_auth/services/$serviceId'
|
||||
| '/_auth/groups/$groupId'
|
||||
| '/_auth/settings/appearance'
|
||||
| '/_auth/settings/health'
|
||||
| '/_auth/settings/integrations'
|
||||
| '/_auth/domains/'
|
||||
| '/_auth/services/'
|
||||
@@ -363,6 +375,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthSettingsIntegrationsRouteImport
|
||||
parentRoute: typeof AuthSettingsRouteRoute
|
||||
}
|
||||
'/_auth/settings/health': {
|
||||
id: '/_auth/settings/health'
|
||||
path: '/health'
|
||||
fullPath: '/settings/health'
|
||||
preLoaderRoute: typeof AuthSettingsHealthRouteImport
|
||||
parentRoute: typeof AuthSettingsRouteRoute
|
||||
}
|
||||
'/_auth/settings/appearance': {
|
||||
id: '/_auth/settings/appearance'
|
||||
path: '/appearance'
|
||||
@@ -438,12 +457,14 @@ declare module '@tanstack/react-router' {
|
||||
|
||||
interface AuthSettingsRouteRouteChildren {
|
||||
AuthSettingsAppearanceRoute: typeof AuthSettingsAppearanceRoute
|
||||
AuthSettingsHealthRoute: typeof AuthSettingsHealthRoute
|
||||
AuthSettingsIntegrationsRoute: typeof AuthSettingsIntegrationsRoute
|
||||
AuthSettingsIndexRoute: typeof AuthSettingsIndexRoute
|
||||
}
|
||||
|
||||
const AuthSettingsRouteRouteChildren: AuthSettingsRouteRouteChildren = {
|
||||
AuthSettingsAppearanceRoute: AuthSettingsAppearanceRoute,
|
||||
AuthSettingsHealthRoute: AuthSettingsHealthRoute,
|
||||
AuthSettingsIntegrationsRoute: AuthSettingsIntegrationsRoute,
|
||||
AuthSettingsIndexRoute: AuthSettingsIndexRoute,
|
||||
}
|
||||
|
||||
@@ -81,6 +81,29 @@ function setServiceEnabled(
|
||||
}
|
||||
}
|
||||
|
||||
function setCatalogServiceIpEnabled(
|
||||
data: ServiceGroupsResponse,
|
||||
serviceId: number,
|
||||
ip: string,
|
||||
enabled: boolean,
|
||||
): ServiceGroupsResponse {
|
||||
const patch = (service: ServiceView): ServiceView =>
|
||||
service.id === serviceId
|
||||
? {
|
||||
...service,
|
||||
ip_enabled: { ...(service.ip_enabled ?? {}), [ip]: enabled },
|
||||
}
|
||||
: service
|
||||
|
||||
return {
|
||||
groups: data.groups.map((group) => ({
|
||||
...group,
|
||||
services: group.services.map(patch),
|
||||
})),
|
||||
ungrouped: data.ungrouped.map(patch),
|
||||
}
|
||||
}
|
||||
|
||||
const GROUP_DOT_COLORS = [
|
||||
'bg-chart-1',
|
||||
'bg-chart-2',
|
||||
@@ -150,6 +173,10 @@ function ServicesPage() {
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||
const [deletingGroupId, setDeletingGroupId] = useState<number | null>(null)
|
||||
const [togglingServiceId, setTogglingServiceId] = useState<number | null>(null)
|
||||
const [togglingIp, setTogglingIp] = useState<{
|
||||
serviceId: number
|
||||
ip: string
|
||||
} | null>(null)
|
||||
const [bulkToggling, setBulkToggling] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState('all')
|
||||
const queryClient = useQueryClient()
|
||||
@@ -364,6 +391,54 @@ function ServicesPage() {
|
||||
},
|
||||
})
|
||||
|
||||
const toggleServiceIpMutation = useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
ip,
|
||||
enabled,
|
||||
}: {
|
||||
id: number
|
||||
ip: string
|
||||
enabled: boolean
|
||||
}) =>
|
||||
api.patch<ServiceView>(`/api/v1/services/${id}/ips/toggle`, {
|
||||
ip,
|
||||
enabled,
|
||||
}),
|
||||
onMutate: async ({ id, ip, enabled }) => {
|
||||
await queryClient.cancelQueries({ queryKey: serviceGroupKeys.all })
|
||||
const previous = queryClient.getQueryData<ServiceGroupsResponse>(
|
||||
serviceGroupKeys.all,
|
||||
)
|
||||
if (previous) {
|
||||
queryClient.setQueryData(
|
||||
serviceGroupKeys.all,
|
||||
setCatalogServiceIpEnabled(previous, id, ip, enabled),
|
||||
)
|
||||
}
|
||||
return { previous }
|
||||
},
|
||||
onError: (err, _vars, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(serviceGroupKeys.all, context.previous)
|
||||
}
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : 'Не удалось переключить IP',
|
||||
)
|
||||
},
|
||||
onSuccess: (_data, { enabled }) => {
|
||||
toast.success(
|
||||
enabled
|
||||
? 'IP включён и добавлен в DNS-привязки'
|
||||
: 'IP выключен и снят с DNS-привязок',
|
||||
)
|
||||
},
|
||||
onSettled: async () => {
|
||||
setTogglingIp(null)
|
||||
await invalidateAll()
|
||||
},
|
||||
})
|
||||
|
||||
const deleteGroupMutation = useMutation({
|
||||
mutationFn: (id: number) => api.delete(`/api/v1/service-groups/${id}`),
|
||||
onSuccess: async () => {
|
||||
@@ -398,6 +473,15 @@ function ServicesPage() {
|
||||
toggleServiceMutation.mutate({ id: serviceId, enabled })
|
||||
}
|
||||
|
||||
function handleServiceIpToggle(
|
||||
serviceId: number,
|
||||
ip: string,
|
||||
enabled: boolean,
|
||||
) {
|
||||
setTogglingIp({ serviceId, ip })
|
||||
toggleServiceIpMutation.mutate({ id: serviceId, ip, enabled })
|
||||
}
|
||||
|
||||
function handleOpenCreateService(groupId: number | null = null) {
|
||||
setDefaultGroupId(groupId)
|
||||
setCreateSheetOpen(true)
|
||||
@@ -602,9 +686,11 @@ function ServicesPage() {
|
||||
isLoading
|
||||
hideHeader
|
||||
togglingId={null}
|
||||
togglingIp={null}
|
||||
onEditService={() => {}}
|
||||
onDeleteService={() => {}}
|
||||
onToggleService={() => {}}
|
||||
onToggleServiceIp={() => {}}
|
||||
onEditGroup={() => {}}
|
||||
onDeleteGroup={() => {}}
|
||||
onAddServiceToGroup={() => {}}
|
||||
@@ -702,12 +788,14 @@ function ServicesPage() {
|
||||
domainId={domainId}
|
||||
domainLabel={filteredDomain?.zone_name}
|
||||
togglingId={togglingServiceId}
|
||||
togglingIp={togglingIp}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
hideHeader
|
||||
onEditService={setEditingService}
|
||||
onDeleteService={setDeletingService}
|
||||
onToggleService={handleServiceToggle}
|
||||
onToggleServiceIp={handleServiceIpToggle}
|
||||
onEditGroup={setEditingGroup}
|
||||
onDeleteGroup={setDeletingGroup}
|
||||
onAddServiceToGroup={handleOpenCreateService}
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import { useEffect } from 'react'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useForm, Controller } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { toast } from 'sonner'
|
||||
import { HeartPulseIcon } from 'lucide-react'
|
||||
|
||||
import { api } from '@/lib/api-client'
|
||||
import { SettingRow } from '@/components/setting-row'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import {
|
||||
NumberField,
|
||||
NumberFieldDecrement,
|
||||
NumberFieldGroup,
|
||||
NumberFieldIncrement,
|
||||
NumberFieldInput,
|
||||
} from '@/components/reui/number-field'
|
||||
import { FieldGroup } from '@cfdm/ui/components/field'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
|
||||
const formSchema = z.object({
|
||||
healthCheckCron: z.string().trim().min(1, 'Укажите cron').max(64),
|
||||
healthDegradedFailures: z.number().int().min(1).max(20),
|
||||
healthDownFailures: z.number().int().min(1).max(50),
|
||||
healthLatencyWarnMs: z.number().int().min(50).max(60_000),
|
||||
healthSuccessRecoveries: z.number().int().min(1).max(20),
|
||||
}).superRefine((data, ctx) => {
|
||||
if (data.healthDownFailures < data.healthDegradedFailures) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Не меньше порога degraded',
|
||||
path: ['healthDownFailures'],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>
|
||||
|
||||
type SettingsResponse = FormValues & {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/_auth/settings/health')({
|
||||
component: HealthSettingsPage,
|
||||
})
|
||||
|
||||
function CompactNumberInput({
|
||||
id,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
disabled,
|
||||
onValueChange,
|
||||
}: {
|
||||
id: string
|
||||
value: number
|
||||
min: number
|
||||
max: number
|
||||
disabled?: boolean
|
||||
onValueChange: (next: number) => void
|
||||
}) {
|
||||
return (
|
||||
<NumberField
|
||||
id={id}
|
||||
size="sm"
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
disabled={disabled}
|
||||
onValueChange={(next) => {
|
||||
if (next != null) onValueChange(next)
|
||||
}}
|
||||
>
|
||||
<NumberFieldGroup className="w-36">
|
||||
<NumberFieldDecrement />
|
||||
<NumberFieldInput />
|
||||
<NumberFieldIncrement />
|
||||
</NumberFieldGroup>
|
||||
</NumberField>
|
||||
)
|
||||
}
|
||||
|
||||
function HealthSettingsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['app-settings'],
|
||||
queryFn: () => api.get<SettingsResponse>('/api/v1/settings'),
|
||||
})
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
healthCheckCron: '0 */2 * * * *',
|
||||
healthDegradedFailures: 1,
|
||||
healthDownFailures: 2,
|
||||
healthLatencyWarnMs: 1000,
|
||||
healthSuccessRecoveries: 2,
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return
|
||||
form.reset({
|
||||
healthCheckCron: data.healthCheckCron,
|
||||
healthDegradedFailures: data.healthDegradedFailures,
|
||||
healthDownFailures: data.healthDownFailures,
|
||||
healthLatencyWarnMs: data.healthLatencyWarnMs,
|
||||
healthSuccessRecoveries: data.healthSuccessRecoveries,
|
||||
})
|
||||
}, [data, form])
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: (values: FormValues) =>
|
||||
api.patch<SettingsResponse>('/api/v1/settings', values),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['app-settings'] })
|
||||
toast.success('Настройки health-check сохранены')
|
||||
},
|
||||
onError: (e: unknown) =>
|
||||
toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
|
||||
})
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex w-full flex-col gap-4"
|
||||
onSubmit={(event) =>
|
||||
void form.handleSubmit((values) => saveMut.mutate(values))(event)
|
||||
}
|
||||
>
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle className="flex items-center gap-2">
|
||||
<HeartPulseIcon className="size-4" aria-hidden />
|
||||
Local health-check
|
||||
</FrameTitle>
|
||||
<FrameDescription>
|
||||
Расписание и пороги движка. Параметры Cloudflare Health Checks
|
||||
задаются в карточке сервиса.
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
<FieldGroup className="gap-0">
|
||||
<SettingRow
|
||||
title="Cron"
|
||||
description="Расписание проб (6 полей: сек мин час день месяц день-недели). Env: HEALTH_CHECK_CRON."
|
||||
labelFor="health-cron"
|
||||
stacked
|
||||
>
|
||||
<Input
|
||||
id="health-cron"
|
||||
className="font-mono"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
disabled={isLoading || saveMut.isPending}
|
||||
{...form.register('healthCheckCron')}
|
||||
/>
|
||||
</SettingRow>
|
||||
{form.formState.errors.healthCheckCron ? (
|
||||
<p className="text-destructive px-5 pb-2 text-sm">
|
||||
{form.formState.errors.healthCheckCron.message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<SettingRow
|
||||
title="Ошибок до Slow"
|
||||
description="Подряд неуспешных проб до статуса degraded. Env: HEALTH_DEGRADED_FAILURES."
|
||||
labelFor="health-degraded"
|
||||
compact
|
||||
>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="healthDegradedFailures"
|
||||
render={({ field }) => (
|
||||
<CompactNumberInput
|
||||
id="health-degraded"
|
||||
value={field.value}
|
||||
min={1}
|
||||
max={20}
|
||||
disabled={isLoading || saveMut.isPending}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Ошибок до Down"
|
||||
description="Подряд неуспешных проб до статуса down. Env: HEALTH_DOWN_FAILURES."
|
||||
labelFor="health-down"
|
||||
compact
|
||||
>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="healthDownFailures"
|
||||
render={({ field }) => (
|
||||
<CompactNumberInput
|
||||
id="health-down"
|
||||
value={field.value}
|
||||
min={1}
|
||||
max={50}
|
||||
disabled={isLoading || saveMut.isPending}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</SettingRow>
|
||||
{form.formState.errors.healthDownFailures ? (
|
||||
<p className="text-destructive px-5 pb-2 text-sm">
|
||||
{form.formState.errors.healthDownFailures.message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<SettingRow
|
||||
title="Латентность Slow, мс"
|
||||
description="Порог задержки для degraded при успешной пробе. Env: HEALTH_LATENCY_WARN_MS."
|
||||
labelFor="health-latency"
|
||||
compact
|
||||
>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="healthLatencyWarnMs"
|
||||
render={({ field }) => (
|
||||
<CompactNumberInput
|
||||
id="health-latency"
|
||||
value={field.value}
|
||||
min={50}
|
||||
max={60_000}
|
||||
disabled={isLoading || saveMut.isPending}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Успехов для recovery"
|
||||
description="Подряд успешных проб, чтобы выйти из Checking в Healthy. Env: HEALTH_SUCCESS_RECOVERIES."
|
||||
labelFor="health-recoveries"
|
||||
compact
|
||||
last
|
||||
>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="healthSuccessRecoveries"
|
||||
render={({ field }) => (
|
||||
<CompactNumberInput
|
||||
id="health-recoveries"
|
||||
value={field.value}
|
||||
min={1}
|
||||
max={20}
|
||||
disabled={isLoading || saveMut.isPending}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</SettingRow>
|
||||
</FieldGroup>
|
||||
<FrameFooter className="flex flex-row justify-end">
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
isLoading={saveMut.isPending}
|
||||
disabled={isLoading || !form.formState.isDirty}
|
||||
>
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
</FrameFooter>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user