Compare commits

..
5 Commits
Author SHA1 Message Date
DenozordecandCursor 153be28799 fix(health): показывать пинг при наведении на график uptime
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / changes (push) Successful in 9s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m11s
CD / quality (push) Successful in 1m23s
CD / publish (push) Successful in 1m57s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 12:44:17 +07:00
DenozordecandCursor 39fac7834f fix(ui): не дублировать крошку Настройки при смене раздела
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 8s
quality / changes (push) Successful in 9s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m2s
CD / quality (push) Successful in 1m15s
CD / publish (push) Successful in 1m58s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 12:28:06 +07:00
DenozordecandCursor d267e40157 fix(health): выровнять график источников и KPI на карточке сервиса
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 12:27:21 +07:00
DenozordecandCursor 634a9dc362 fix(health): заменить плитки источников на компактный мультивыбор
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 7s
quality / changes (push) Successful in 9s
quality / docker-check (push) Skipped
quality / web (push) Successful in 54s
quality / api (push) Successful in 43s
CD / quality (push) Successful in 1m48s
CD / publish (push) Successful in 1m39s
Крупные KPI-кнопки и вкладка «Все» заменены на ToggleGroup: фильтр занимает меньше места и не сбрасывает последний источник.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 02:13:57 +07:00
DenozordecandCursor 1bf6cfa0d0 fix(health): разворачивать CNAME до IP в статусе таблицы
Пробы CNAME больше не ключуются hostname: цель разворачивается до origin/пула, а уже сохранённый статус по CNAME копируется на IP сервиса.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 02:13:09 +07:00
13 changed files with 840 additions and 353 deletions
@@ -18,6 +18,7 @@ import {
SYNC_PENDING_PUSH,
SYNC_SYNCED,
dnsRecordNamesMatch,
isIpLiteral,
normalizeDnsRecordName,
} from "@cfdm/shared";
import type { CloudflareClient } from "../lib/cf-client.js";
@@ -344,6 +345,64 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
};
}
const HEALTH_RANK: Record<string, number> = {
down: 3,
degraded: 2,
unknown: 1,
up: 0,
};
function cnameLookupKeys(value: string, zoneName?: string | null): string[] {
const trimmed = value.trim();
if (!trimmed) return [];
const noDot = trimmed.replace(/\.+$/, "");
const lower = noDot.toLowerCase();
const keys = new Set([trimmed, noDot, lower]);
if (zoneName && !lower.includes(".")) {
keys.add(`${lower}.${zoneName.trim().toLowerCase().replace(/\.+$/, "")}`);
}
return [...keys];
}
type ServiceHealthRow = {
ip: string;
status: IpHealthState;
latency_ms: number | null;
last_checked_at: string | null;
last_error: string | null;
provider: ServiceView["ip_health"][number]["provider"];
colo: string | null;
};
/** Health rows keyed by CNAME hostname (legacy probes) applied to service IPs. */
function fallbackCnameHealth(
rows: ServiceHealthRow[],
view: ServiceView,
): ServiceHealthRow | undefined {
const cnameKeys = new Set<string>();
for (const domain of view.domains ?? []) {
const cname = domain.target_cname?.trim();
if (!cname) continue;
for (const key of cnameLookupKeys(cname, domain.zone_name)) {
cnameKeys.add(key);
}
}
const hostnameRows = rows.filter((row) => !isIpLiteral(row.ip));
if (hostnameRows.length === 0) return undefined;
const matched =
cnameKeys.size === 0
? hostnameRows
: hostnameRows.filter((row) =>
cnameLookupKeys(row.ip).some((key) => cnameKeys.has(key)),
);
const candidates = matched.length > 0 ? matched : hostnameRows;
return candidates.reduce((worst, row) =>
(HEALTH_RANK[row.status] ?? 0) > (HEALTH_RANK[worst.status] ?? 0)
? row
: worst,
);
}
function attachServiceHealth(
db: Db,
views: ServiceView[],
@@ -353,11 +412,16 @@ function attachServiceHealth(
const ipHealthByService = repos.listIpHealthByServiceIds(db, ids);
return views.map((view) => {
const health = healthByService.get(view.id);
const byIp = new Map(
(ipHealthByService.get(view.id) ?? []).map((row) => [row.ip, row]),
const rows = ipHealthByService.get(view.id) ?? [];
const byIp = new Map(rows.map((row) => [row.ip, row]));
const cnameFallback = fallbackCnameHealth(rows, view);
const aRecordIps = new Set(
(view.domains ?? []).flatMap((domain) =>
domain.target_cname?.trim() ? [] : (domain.target_ips ?? []),
),
);
const ip_health = (view.ips ?? []).map((ip) => {
const row = byIp.get(ip);
const row = byIp.get(ip) ?? (aRecordIps.has(ip) ? undefined : cnameFallback);
return {
ip,
status: row?.status ?? ("unknown" as const),
+91
View File
@@ -235,4 +235,95 @@ describe("health-check state derivation via runAllChecks", () => {
expect(targets).toHaveLength(1);
expect(targets[0]?.verify_tls).toBe(true);
});
it("unwraps CNAME target to origin A record IPs", async () => {
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
const { db, sqlite } = createMemoryDb();
runMigrations(sqlite);
const domain = repos.createDomain(db, null, "rkns.top", "zone-id");
repos.insertDnsRecord(
db,
domain.id,
"A",
"ihome",
"2.59.161.102",
1,
false,
null,
"synced",
"cf",
null,
);
const service = repos.createService(db, "RW Sub", "rw-sub");
const binding = repos.insertBinding(db, domain.id, service.id, "s", null);
repos.setBindingCnameTarget(db, binding.id, "ihome.rkns.top");
repos.updateBindingLbConfig(db, binding.id, {
health_check_enabled: true,
health_check_type: "tcp",
health_check_port: 443,
});
const targets = repos.listHealthCheckTargets(db);
expect(targets).toHaveLength(1);
expect(targets[0]?.ip).toBe("2.59.161.102");
expect(targets[0]?.hostname).toBe("s.rkns.top");
});
it("unwraps CNAME target to service IP pool when origin DNS is empty", async () => {
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
const { db, sqlite } = createMemoryDb();
runMigrations(sqlite);
const domain = repos.createDomain(db, null, "rkns.top", "zone-id");
const service = repos.createService(db, "RW Sub", "rw-sub");
repos.replaceServiceIps(db, service.id, ["2.59.161.102"]);
const binding = repos.insertBinding(db, domain.id, service.id, "s", null);
repos.setBindingCnameTarget(db, binding.id, "ihome.rkns.top");
repos.updateBindingLbConfig(db, binding.id, {
health_check_enabled: true,
health_check_type: "tcp",
health_check_port: 443,
});
const targets = repos.listHealthCheckTargets(db);
expect(targets).toHaveLength(1);
expect(targets[0]?.ip).toBe("2.59.161.102");
expect(targets[0]?.hostname).toBe("s.rkns.top");
});
});
describe("CNAME health mapped onto service IPs", () => {
it("getView copies CNAME-keyed health onto the service IP row", async () => {
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
const { getView } = await import("../src/services/service-config-service.js");
const { db, sqlite } = createMemoryDb();
runMigrations(sqlite);
const domain = repos.createDomain(db, null, "rkns.top", "zone-id");
const service = repos.createService(db, "RW Sub", "rw-sub");
repos.replaceServiceIps(db, service.id, ["2.59.161.102"]);
const binding = repos.insertBinding(db, domain.id, service.id, "s", null);
repos.setBindingCnameTarget(db, binding.id, "ihome.rkns.top");
repos.upsertIpHealthStatus(
db,
"binding",
binding.id,
"ihome.rkns.top",
"up",
12,
0,
null,
);
const view = await getView(db, service.id);
expect(view.health_status).toBe("up");
expect(view.ip_health).toEqual([
expect.objectContaining({
ip: "2.59.161.102",
status: "up",
latency_ms: 12,
}),
]);
});
});
+10 -77
View File
@@ -1,5 +1,5 @@
import { Fragment, useMemo } from 'react'
import { Link, useMatches, useRouterState } from '@tanstack/react-router'
import { useMemo } from 'react'
import {
Breadcrumb,
BreadcrumbItem,
@@ -12,82 +12,12 @@ import { Separator } from '@cfdm/ui/components/separator'
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
import { AppsMenu } from '@/components/layout/apps-menu'
import { SidebarTrigger } from '@cfdm/ui/components/sidebar'
import { getBreadcrumbs } from '@/lib/breadcrumbs'
export interface RouteBreadcrumbLoaderData {
breadcrumb?: string
}
const routeTitles: Record<string, string> = {
'/': 'Панель управления',
'/domains': 'Домены',
'/groups': 'Группы доменов',
'/services': 'Сервисы',
'/certificates': 'Сертификаты',
'/settings/appearance': 'Внешний вид',
'/settings/health': 'Health-check',
'/settings/integrations': 'Интеграции',
}
function getBreadcrumbs(
pathname: string,
dynamicLabels: Record<string, string>,
) {
if (pathname === '/') {
return [{ label: 'Панель управления', href: '/' }]
}
if (pathname.match(/^\/services\/\d+$/)) {
return [
{ label: 'Сервисы', href: '/services' },
{ label: dynamicLabels[pathname] ?? 'Сервис', href: pathname },
]
}
if (pathname.match(/^\/groups\/\d+$/)) {
return [
{ label: 'Группы доменов', href: '/groups' },
{ label: dynamicLabels[pathname] ?? 'Группа', href: pathname },
]
}
if (pathname.match(/^\/domains\/\d+\/dns$/)) {
const domainId = pathname.split('/')[2]
const domainPath = `/domains/${domainId}`
return [
{ label: 'Домены', href: '/domains' },
{ label: dynamicLabels[domainPath] ?? 'Домен', href: domainPath },
{ label: 'DNS', href: pathname },
]
}
if (pathname.match(/^\/domains\/\d+$/)) {
return [
{ label: 'Домены', href: '/domains' },
{ label: dynamicLabels[pathname] ?? 'Обзор домена', href: pathname },
]
}
if (pathname.startsWith('/settings')) {
return [
{ label: 'Настройки', href: '/settings/appearance' },
...(pathname === '/settings/integrations'
? [{ label: 'Интеграции', href: pathname }]
: pathname === '/settings/health'
? [{ label: 'Health-check', href: pathname }]
: pathname === '/settings/appearance'
? [{ label: 'Внешний вид', href: pathname }]
: []),
]
}
const title = routeTitles[pathname]
if (title) {
return [{ label: title, href: pathname }]
}
return [{ label: 'Панель управления', href: '/' }]
}
function useDynamicBreadcrumbLabels() {
const matches = useMatches()
return useMemo(() => {
@@ -106,7 +36,10 @@ function useDynamicBreadcrumbLabels() {
export function SiteHeader() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const dynamicLabels = useDynamicBreadcrumbLabels()
const crumbs = getBreadcrumbs(pathname, dynamicLabels)
const crumbs = useMemo(
() => getBreadcrumbs(pathname, dynamicLabels),
[pathname, dynamicLabels],
)
return (
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
@@ -117,10 +50,10 @@ export function SiteHeader() {
{crumbs.map((crumb, index) => {
const isLast = index === crumbs.length - 1
return (
<span key={crumb.href} className="contents">
{index > 0 && (
<Fragment key={`${index}-${crumb.href}`}>
{index > 0 ? (
<BreadcrumbSeparator className="hidden md:block" />
)}
) : null}
<BreadcrumbItem
className={index === 0 && !isLast ? 'hidden md:block' : undefined}
>
@@ -132,7 +65,7 @@ export function SiteHeader() {
</BreadcrumbLink>
)}
</BreadcrumbItem>
</span>
</Fragment>
)
})}
</BreadcrumbList>
@@ -14,6 +14,10 @@ import {
} from '@cfdm/ui/components/item'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { cn } from '@cfdm/ui/lib/utils'
import {
ToggleGroup,
ToggleGroupItem,
} from '@cfdm/ui/components/toggle-group'
import { parseHealthProviders, type HealthCheckAggregate, type HealthCheckProvider } from '@cfdm/shared'
import type { HealthLogStatus } from '@/lib/health-log'
@@ -261,6 +265,86 @@ export function HealthAggregateTiles({
)
}
function toggleProviders(
active: HealthProvider[],
id: HealthProvider,
): HealthProvider[] {
if (active.includes(id)) {
if (active.length === 1) return active
return active.filter((item) => item !== id)
}
return [...active, id]
}
/**
* Компактный мультивыбор типа пробы (toolbar в stacked Frame).
* Preview: https://reui.io/preview/base/list-9 · https://reui.io/preview/base/chart-17
* Docs: https://reui.io/docs/components/base/icon-tile · https://reui.io/docs/components/base/badge
*/
export function HealthSourceFilterBar({
enabled,
selected,
statuses,
onChange,
}: {
enabled: HealthProvider[]
selected: HealthProvider[]
statuses: Partial<Record<HealthProvider, HealthLogStatus>>
onChange: (next: HealthProvider[]) => void
}) {
const visible = HEALTH_PROVIDER_ITEMS.filter((item) => enabled.includes(item.id))
if (visible.length === 0) return null
const active = selected.length > 0 ? selected : enabled
return (
<div className="@container min-w-0 w-full">
<ToggleGroup
multiple
variant="outline"
size="sm"
className="flex w-full min-w-0 flex-wrap justify-start"
value={active}
aria-label="Тип пробы"
onValueChange={(next) => {
const values = next.filter((value): value is HealthProvider =>
visible.some((item) => item.id === value),
)
if (values.length === 0) return
onChange(values)
}}
>
{visible.map((item) => (
<ToggleGroupItem
key={item.id}
value={item.id}
aria-label={item.title}
title={item.title}
className="max-w-full min-w-0 flex-none justify-start gap-1.5 @[16rem]:min-w-[8.5rem]"
>
<IconTile
variant="elevated"
size="xs"
className={item.iconClassName}
aria-hidden="true"
>
{item.icon}
</IconTile>
<span className="hidden min-w-0 truncate @[16rem]:inline">
{item.title}
</span>
<HealthCheckBadge
status={statuses[item.id] ?? 'unknown'}
provider={item.id}
size="xs"
/>
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
)
}
/**
* Read-only status tiles for enabled probe sources; click filters the monitor.
* Preview: https://reui.io/preview/base/list-9 · https://reui.io/preview/base/stats-12
@@ -282,15 +366,6 @@ export function HealthProviderStatusTiles({
const active = selected.length > 0 ? selected : enabled
function toggle(id: HealthProvider) {
if (active.includes(id)) {
if (active.length === 1) return
onChange(active.filter((item) => item !== id))
return
}
onChange([...active, id])
}
return (
<ChoiceFrame>
{visible.map((item) => (
@@ -309,7 +384,7 @@ export function HealthProviderStatusTiles({
size="xs"
/>
}
onActivate={() => toggle(item.id)}
onActivate={() => onChange(toggleProviders(active, item.id))}
/>
))}
</ChoiceFrame>
@@ -24,6 +24,7 @@ export {
HealthSourceTiles,
HealthAggregateTiles,
HealthProviderStatusTiles,
HealthSourceFilterBar,
type HealthProvider,
type HealthAggregate,
} from './health-source-tiles'
@@ -67,7 +67,7 @@ function resolveFooter(item: KpiStatItem): ReactNode {
if (item.footer) return item.footer
if (typeof item.hint === 'string') {
return (
<Badge variant="outline" size="sm">
<Badge variant="outline" size="sm" className="max-w-[min(100%,11rem)] truncate">
{item.hint}
</Badge>
)
@@ -81,30 +81,39 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) {
const valueVariant = item.variant ?? 'default'
return (
<div className="relative z-10 flex h-full items-start gap-3">
<div className="@container relative z-10 flex h-full min-w-0 items-start gap-3">
{item.icon ? (
<IconTile
variant="elevated"
aria-hidden="true"
className={cn('size-10.5', item.iconClassName ?? DEFAULT_ICON_CLASS)}
className={cn('size-10.5 shrink-0', item.iconClassName ?? DEFAULT_ICON_CLASS)}
>
{item.icon}
</IconTile>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-start justify-between gap-2">
<div className="text-muted-foreground text-sm font-medium">{item.label}</div>
{footer ? <div className="shrink-0">{footer}</div> : null}
<div className="flex min-w-0 items-start justify-between gap-2">
<div className="text-muted-foreground min-w-0 truncate text-sm font-medium">
{item.label}
</div>
{footer ? (
<div className="hidden min-w-0 max-w-[min(100%,11rem)] shrink-0 @[20rem]:block">
{footer}
</div>
) : null}
</div>
<div
className={cn(
'text-2xl leading-none font-bold tabular-nums',
'min-w-0 break-all text-2xl leading-none font-bold tabular-nums',
VALUE_VARIANT_CLASS[valueVariant],
)}
>
{item.value}
</div>
{footer ? (
<div className="min-w-0 max-w-full @[20rem]:hidden">{footer}</div>
) : null}
</div>
</div>
)
@@ -116,7 +125,7 @@ function panelClassName(item: KpiStatItem, className?: string) {
const selected = isSelected(item)
return cn(
'relative isolate flex h-full flex-col',
'relative isolate flex h-full min-w-0 flex-col',
clickable &&
'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2',
selected && 'ring-primary/30 bg-muted/30 ring-1',
@@ -1,8 +1,6 @@
import { useMemo, useState, type ReactNode } from 'react'
import { LayersIcon } from 'lucide-react'
import { useMemo, useState } from 'react'
import { HealthTimeline } from '@/components/health/health-timeline'
import { HealthCheckBadge } from '@/components/health-check-badge'
import {
Frame,
FrameDescription,
@@ -10,16 +8,8 @@ import {
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { IconTile } from '@/components/reui/icon-tile'
import { Badge } from '@/components/reui/badge'
import {
lastProbeLatency,
probeUptimePercent,
UptimeChart,
UPTIME_PERIODS,
type UptimePeriodKey,
} from '@/components/reui-kit/uptime-chart'
import { HEALTH_PROVIDER_ITEMS } from '@/components/reui-kit/health-source-tiles'
import { UptimeChart, UPTIME_PERIODS, type UptimePeriodKey } from '@/components/reui-kit/uptime-chart'
import { HealthSourceFilterBar } from '@/components/reui-kit/health-source-tiles'
import {
collapseStatusChanges,
filterByPeriod,
@@ -27,34 +17,13 @@ import {
type HealthLogProbe,
type HealthLogStatus,
} from '@/lib/health-log'
import { cn } from '@cfdm/ui/lib/utils'
import type { HealthCheckProvider } from '@cfdm/shared'
const ALL_TAB = 'all' as const
type SourceTab = typeof ALL_TAB | HealthCheckProvider
function formatUptime(value: number | null): string {
if (value == null) return '—'
return `${value.toFixed(value >= 99.95 ? 2 : 1)}%`
}
function formatLatency(ms: number | null): string {
if (ms == null) return 'нет проб'
return `${ms} мс`
}
function statusTileClass(status: HealthLogStatus | undefined): string {
if (status === 'down') return 'text-destructive'
if (status === 'degraded') return 'text-warning'
if (status === 'up') return 'text-success'
return 'text-muted-foreground'
}
/**
* Единый блок мониторинга: переключатель источников (dashboard-4) + график
* (chart-17) + таймлайн смен статуса (solution-ai-ops-1 / timeline).
* Единый блок мониторинга: компактный мультивыбор типа пробы (list-9 / ToggleGroup)
* + график (chart-17) + таймлайн смен статуса.
*
* Preview: https://reui.io/preview/base/dashboard-4
* Preview: https://reui.io/preview/base/list-9
* Preview: https://reui.io/preview/base/chart-17
* Preview: https://reui.io/preview/base/solution-ai-ops-1
* Docs: https://reui.io/docs/components/base/frame
@@ -74,19 +43,20 @@ export function ServiceHealthMonitor({
isLoading?: boolean
}) {
const [period, setPeriod] = useState<UptimePeriodKey>('5D')
const [source, setSource] = useState<SourceTab>(ALL_TAB)
const [selected, setSelected] = useState<HealthCheckProvider[] | null>(null)
const days = UPTIME_PERIODS.find((entry) => entry.key === period)?.days ?? 5
const enabledItems = useMemo(
() => HEALTH_PROVIDER_ITEMS.filter((item) => enabledProviders.includes(item.id)),
const enabled = useMemo(
() => [...enabledProviders],
[enabledProviders],
)
const selectedProviders = useMemo((): HealthCheckProvider[] => {
if (source === ALL_TAB) return [...enabledProviders]
if (enabledProviders.includes(source)) return [source]
return [...enabledProviders]
}, [enabledProviders, source])
const activeProviders = useMemo((): HealthCheckProvider[] => {
const picked = (selected ?? enabled).filter((provider) =>
enabled.includes(provider),
)
return picked.length > 0 ? picked : enabled
}, [enabled, selected])
const periodItems = useMemo(
() => filterByPeriod(items, days),
@@ -94,59 +64,22 @@ export function ServiceHealthMonitor({
)
const filtered = useMemo(
() => filterByProviders(periodItems, selectedProviders),
[periodItems, selectedProviders],
() => filterByProviders(periodItems, activeProviders),
[periodItems, activeProviders],
)
const changes = useMemo(() => collapseStatusChanges(filtered), [filtered])
const showAllTab = enabledItems.length > 1
const tabCount = enabledItems.length + (showAllTab ? 1 : 0)
const activeSource: SourceTab =
source === ALL_TAB || enabledProviders.includes(source)
? source
: ALL_TAB
return (
<Frame stacked spacing="sm" className="min-w-0 w-full">
<FrameHeader className="p-0!">
<div
className={cn(
'grid',
tabCount <= 2 && 'grid-cols-2',
tabCount === 3 && 'grid-cols-1 sm:grid-cols-3',
tabCount >= 4 && 'grid-cols-2',
)}
>
{showAllTab ? (
<SourceMetricButton
selected={activeSource === ALL_TAB}
icon={<LayersIcon />}
iconClassName="text-muted-foreground"
label="Все источники"
value={formatUptime(probeUptimePercent(periodItems))}
hint={`${periodItems.length} проб`}
onSelect={() => setSource(ALL_TAB)}
/>
) : null}
{enabledItems.map((item) => {
const series = filterByProviders(periodItems, [item.id])
return (
<SourceMetricButton
key={item.id}
selected={activeSource === item.id}
icon={item.icon}
iconClassName={item.iconClassName}
label={item.title}
value={formatUptime(probeUptimePercent(series))}
hint={formatLatency(lastProbeLatency(series))}
status={statuses[item.id] ?? 'unknown'}
onSelect={() => setSource(item.id)}
/>
)
})}
</div>
</FrameHeader>
<FramePanel>
<HealthSourceFilterBar
enabled={enabled}
selected={activeProviders}
statuses={statuses}
onChange={setSelected}
/>
</FramePanel>
<UptimeChart
items={filtered}
@@ -185,65 +118,3 @@ export function ServiceHealthMonitor({
</Frame>
)
}
function SourceMetricButton({
selected,
icon,
iconClassName,
label,
value,
hint,
status,
onSelect,
}: {
selected: boolean
icon: ReactNode
iconClassName: string
label: string
value: string
hint: string
status?: HealthLogStatus
onSelect: () => void
}) {
return (
<button
type="button"
aria-pressed={selected}
aria-label={`${label}: ${value}`}
onClick={onSelect}
className={cn(
'focus-visible:ring-ring/50 hover:bg-muted/40 relative flex min-w-0 items-start gap-3 border-e border-b p-4 text-start transition-colors last:border-e-0 focus-visible:ring-2 focus-visible:outline-none sm:border-b-0',
selected && 'bg-muted/40',
)}
>
<IconTile
variant="elevated"
className={cn(
'size-10.5',
status ? statusTileClass(status) : iconClassName,
)}
aria-hidden="true"
>
{icon}
</IconTile>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="flex items-center justify-between gap-2">
<span className="text-muted-foreground text-sm font-medium">{label}</span>
{status ? (
<HealthCheckBadge status={status} size="xs" />
) : (
<Badge variant="outline" size="sm">
{hint}
</Badge>
)}
</span>
<span className="text-foreground text-2xl leading-none font-bold tabular-nums">
{value}
</span>
{status ? (
<span className="text-muted-foreground text-xs">{hint}</span>
) : null}
</span>
</button>
)
}
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest'
import { toAlignedSeries, type UptimeProbe } from './uptime-chart'
function probe(
overrides: Partial<UptimeProbe> & Pick<UptimeProbe, 'id'>,
): UptimeProbe {
return {
status: 'up',
ok: true,
latency_ms: 10,
checked_at: '2026-01-01T00:00:00.000Z',
provider: 'local',
...overrides,
}
}
describe('toAlignedSeries', () => {
it('puts mixed-source probes in one 60s bucket instead of a sawtooth series', () => {
const { points, keys } = toAlignedSeries([
probe({
id: 1,
provider: 'local',
latency_ms: 4,
checked_at: '2026-01-01T00:00:10.000Z',
}),
probe({
id: 2,
provider: 'cloudflare',
latency_ms: 284,
checked_at: '2026-01-01T00:00:12.000Z',
}),
probe({
id: 3,
provider: 'globalping',
latency_ms: 38,
checked_at: '2026-01-01T00:00:40.000Z',
}),
])
expect(points).toHaveLength(1)
expect(points[0]?.local).toBe(4)
expect(points[0]?.cloudflare).toBe(284)
expect(points[0]?.globalping).toBe(38)
expect(keys).toEqual(['local', 'cloudflare', 'globalping'])
})
it('does not plot down probes as latency 0', () => {
const { points } = toAlignedSeries([
probe({
id: 1,
status: 'down',
ok: false,
latency_ms: 12,
provider: 'local',
}),
])
expect(points).toHaveLength(1)
expect(points[0]?.local).toBeNull()
expect(points[0]?.localOk).toBe(false)
expect(points[0]?.ok).toBe(false)
})
it('splits probes that fall into adjacent minutes', () => {
const { points } = toAlignedSeries([
probe({ id: 1, latency_ms: 10, checked_at: '2026-01-01T00:00:50.000Z' }),
probe({ id: 2, latency_ms: 20, checked_at: '2026-01-01T00:01:10.000Z' }),
])
expect(points).toHaveLength(2)
expect(points[0]?.local).toBe(10)
expect(points[1]?.local).toBe(20)
})
})
+201 -79
View File
@@ -1,6 +1,6 @@
import { useEffect, useId, useMemo, useState } from 'react'
import { useId, useMemo, useState } from 'react'
import { ActivityIcon, InfoIcon, TrendingDownIcon, TrendingUpIcon } from 'lucide-react'
import { Area, AreaChart, XAxis } from 'recharts'
import { Area, ComposedChart, Line, XAxis, YAxis } from 'recharts'
import { EmptyState } from '@/components/empty-state'
import { Badge } from '@/components/reui/badge'
@@ -22,12 +22,13 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@cfdm/ui/components/tooltip'
import type { HealthCheckProvider } from '@cfdm/shared'
/**
* Uptime monitoring card — chart-17 DNA (Frame + value + AreaChart + period tabs).
* Preview: https://reui.io/preview/base/chart-17
* Frame: https://reui.io/docs/components/base/frame
* Chart: shadcn Chart + Recharts AreaChart
* Chart: shadcn Chart + Recharts ComposedChart
*/
export interface UptimeProbe {
@@ -36,6 +37,7 @@ export interface UptimeProbe {
ok: boolean
latency_ms: number | null
checked_at: string
provider?: HealthCheckProvider
}
export type UptimePeriodKey = '5D' | '2W' | '1M'
@@ -46,40 +48,107 @@ export const UPTIME_PERIODS: { key: UptimePeriodKey; label: string; days: number
{ key: '1M', label: '1M', days: 30 },
]
export const UPTIME_BUCKET_MS = 60_000
export const UPTIME_PROVIDER_KEYS = ['local', 'cloudflare', 'globalping'] as const
export type UptimeProviderKey = (typeof UPTIME_PROVIDER_KEYS)[number]
const chartConfig = {
latency: {
label: 'Задержка',
color: 'var(--chart-1)',
local: {
label: 'Local',
color: 'var(--info)',
},
cloudflare: {
label: 'Cloudflare',
color: 'var(--warning)',
},
globalping: {
label: 'Globalping',
color: 'var(--success)',
},
} satisfies ChartConfig
interface ChartPoint {
export interface AlignedChartPoint {
period: string
latency: number
ok: boolean
at: string
status: UptimeProbe['status']
ok: boolean
local?: number | null
cloudflare?: number | null
globalping?: number | null
localOk?: boolean
cloudflareOk?: boolean
globalpingOk?: boolean
}
function toSeries(items: UptimeProbe[]): ChartPoint[] {
return [...items]
.sort((a, b) => probeTime(a.checked_at) - probeTime(b.checked_at))
.map((item) => ({
period: formatDate(item.checked_at),
latency: item.latency_ms ?? 0,
ok: item.ok && item.status !== 'down',
at: item.checked_at,
status: item.status,
}))
function isProviderKey(value: string | undefined): value is UptimeProviderKey {
return value === 'local' || value === 'cloudflare' || value === 'globalping'
}
function uptimePercent(points: ChartPoint[]): number | null {
function bucketStart(time: number): number {
return Math.floor(time / UPTIME_BUCKET_MS) * UPTIME_BUCKET_MS
}
function providerOf(item: UptimeProbe): UptimeProviderKey {
return isProviderKey(item.provider) ? item.provider : 'local'
}
function probeOk(item: UptimeProbe): boolean {
return item.ok && item.status !== 'down'
}
/** Align mixed-source probes onto a 60s time axis so Local/CF/GP do not zigzag. */
export function toAlignedSeries(items: UptimeProbe[]): {
points: AlignedChartPoint[]
keys: UptimeProviderKey[]
} {
const buckets = new Map<number, AlignedChartPoint>()
const used = new Set<UptimeProviderKey>()
const sorted = [...items].sort(
(a, b) => probeTime(a.checked_at) - probeTime(b.checked_at) || a.id - b.id,
)
for (const item of sorted) {
const key = providerOf(item)
used.add(key)
const start = bucketStart(probeTime(item.checked_at))
let row = buckets.get(start)
if (!row) {
row = {
period: formatDate(item.checked_at),
at: item.checked_at,
ok: true,
}
buckets.set(start, row)
}
const ok = probeOk(item)
row[`${key}Ok`] = ok
row[key] = ok ? item.latency_ms : null
}
const points = [...buckets.entries()]
.sort((a, b) => a[0] - b[0])
.map(([, row]) => {
const present = UPTIME_PROVIDER_KEYS.filter((key) => row[`${key}Ok`] !== undefined)
return {
...row,
ok: present.length === 0 ? row.ok : present.every((key) => row[`${key}Ok`] !== false),
}
})
const keys = UPTIME_PROVIDER_KEYS.filter((key) => used.has(key))
return { points, keys }
}
function uptimePercent(points: AlignedChartPoint[]): number | null {
if (points.length === 0) return null
const okCount = points.filter((point) => point.ok).length
return (okCount / points.length) * 100
}
function deltaPercent(points: ChartPoint[]): number | null {
function deltaPercent(points: AlignedChartPoint[]): number | null {
if (points.length < 4) return null
const mid = Math.floor(points.length / 2)
const prev = uptimePercent(points.slice(0, mid))
@@ -113,7 +182,7 @@ function UptimeDelta({ delta }: { delta: number }) {
}
export function probeUptimePercent(items: UptimeProbe[]): number | null {
return uptimePercent(toSeries(items))
return uptimePercent(toAlignedSeries(items).points)
}
export function lastProbeLatency(items: UptimeProbe[]): number | null {
@@ -129,6 +198,12 @@ function formatUptime(value: number | null): string {
return `${value.toFixed(value >= 99.95 ? 2 : 1)}%`
}
function formatPing(value: unknown, ok: boolean | undefined): string {
if (ok === false) return '—'
const ping = typeof value === 'number' ? value : Number(value)
return Number.isFinite(ping) ? `${ping} мс` : '—'
}
interface UptimeChartProps {
items: UptimeProbe[]
isLoading?: boolean
@@ -151,30 +226,45 @@ export function UptimeChart({
}: UptimeChartProps) {
const gradientId = useId().replace(/:/g, '')
const [internalPeriod, setInternalPeriod] = useState<UptimePeriodKey>('5D')
const [tooltipPortal, setTooltipPortal] = useState<HTMLElement | null>(null)
const [hovered, setHovered] = useState<AlignedChartPoint | null>(null)
const period = periodProp ?? internalPeriod
const days = UPTIME_PERIODS.find((entry) => entry.key === period)?.days ?? 5
useEffect(() => {
setTooltipPortal(document.body)
}, [])
function handlePeriodChange(next: UptimePeriodKey) {
onPeriodChange?.(next)
if (periodProp == null) setInternalPeriod(next)
setHovered(null)
}
const points = useMemo(
() => toSeries(skipPeriodFilter ? items : filterByPeriod(items, days)),
const { points, keys } = useMemo(
() => toAlignedSeries(skipPeriodFilter ? items : filterByPeriod(items, days)),
[items, days, skipPeriodFilter],
)
const uptime = uptimePercent(points)
const delta = deltaPercent(points)
const lastOk = points.at(-1)?.ok ?? true
const tileClass = lastOk ? 'text-success' : 'text-destructive'
const single = keys.length <= 1
const areaKey = keys[0] ?? 'local'
const hoverPings = hovered
? keys.map((key) => {
const ok = hovered[`${key}Ok`]
const label = chartConfig[key].label
return `${label} ${formatPing(hovered[key], ok)}`
})
: []
function syncHover(state: {
activeTooltipIndex?: unknown
activeIndex?: unknown
}) {
const index = Number(state.activeTooltipIndex ?? state.activeIndex)
if (!Number.isFinite(index)) return
setHovered(points[index] ?? null)
}
const panel = (
<FramePanel className="flex flex-col gap-6">
<FramePanel className="flex flex-col gap-6 overflow-visible">
{hideHeader ? null : (
<div className="border-border flex items-center justify-between gap-2 border-b border-dashed pb-4">
<div className="flex items-center gap-2.5">
@@ -242,48 +332,78 @@ export function UptimeChart({
</div>
</div>
{hoverPings.length > 0 ? (
<p className="text-muted-foreground min-h-4 min-w-0 text-xs tabular-nums">
<span className="text-foreground font-medium">Пинг</span>
{' · '}
{formatDate(hovered?.at)}
{' · '}
{hoverPings.join(' · ')}
</p>
) : (
<p className="text-muted-foreground min-h-4 text-xs">
Наведите на точку графика
</p>
)}
<div className="h-40 w-full overflow-visible">
<ChartContainer
config={chartConfig}
className="h-full w-full overflow-visible rounded-b-xl"
className="[&_.recharts-tooltip-wrapper]:z-50 [&_.recharts-wrapper]:overflow-visible h-full w-full overflow-visible rounded-b-xl"
initialDimension={{ width: 320, height: 160 }}
>
<AreaChart
<ComposedChart
data={points}
margin={{ top: 16, left: 8, right: 8, bottom: 4 }}
margin={{ top: 24, left: 8, right: 8, bottom: 8 }}
accessibilityLayer
onMouseMove={syncHover}
onMouseLeave={() => setHovered(null)}
onClick={syncHover}
>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-latency)"
stopColor={`var(--color-${areaKey})`}
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-latency)"
stopColor={`var(--color-${areaKey})`}
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<XAxis dataKey="period" hide />
<XAxis dataKey="at" hide />
<YAxis hide domain={['auto', 'auto']} />
<ChartTooltip
cursor={{ stroke: 'var(--border)', strokeDasharray: '4 4' }}
filterNull={false}
shared
isAnimationActive={false}
allowEscapeViewBox={{ x: true, y: true }}
portal={tooltipPortal ?? undefined}
wrapperStyle={{ zIndex: 50, pointerEvents: 'none' }}
content={
<ChartTooltipContent
formatter={(value, _name, item) => {
const point = item.payload as ChartPoint | undefined
const ping = Number(value)
labelFormatter={(_label, payload) => {
const at = (payload?.[0]?.payload as AlignedChartPoint | undefined)?.at
return at ? formatDate(at) : String(_label ?? '')
}}
formatter={(value, name, item) => {
const key = String(name)
const row = item.payload as AlignedChartPoint | undefined
const ok =
key === 'local' || key === 'cloudflare' || key === 'globalping'
? row?.[`${key}Ok`]
: row?.ok
const label = chartConfig[key as UptimeProviderKey]?.label ?? key
return (
<div className="flex flex-1 items-center justify-between gap-4">
<span className="text-muted-foreground">
{point?.ok === false ? 'Down' : 'Пинг'}
{ok === false ? `${label} · Down` : `Пинг · ${label}`}
</span>
<span className="text-foreground font-mono font-medium tabular-nums">
{Number.isFinite(ping) ? `${ping} мс` : '—'}
{formatPing(value, ok)}
</span>
</div>
)
@@ -291,42 +411,44 @@ export function UptimeChart({
/>
}
/>
<Area
dataKey="latency"
name="latency"
type="natural"
fill={`url(#${gradientId})`}
stroke="var(--color-latency)"
strokeWidth={2}
isAnimationActive={false}
dot={(dotProps) => {
const { cx, cy, payload, index } = dotProps
if (cx == null || cy == null) return <g key={index} />
const point = payload as ChartPoint | undefined
return (
<circle
key={index}
cx={cx}
cy={cy}
r={4}
fill={
point?.ok
? 'var(--color-latency)'
: 'var(--destructive)'
}
stroke="var(--background)"
strokeWidth={2}
pointerEvents="none"
/>
)
}}
activeDot={{
r: 6,
stroke: 'var(--background)',
strokeWidth: 2,
}}
/>
</AreaChart>
{single ? (
<Area
dataKey={areaKey}
name={areaKey}
type="monotone"
fill={`url(#${gradientId})`}
stroke={`var(--color-${areaKey})`}
strokeWidth={2}
connectNulls={false}
isAnimationActive={false}
dot={{ r: 3, strokeWidth: 1, stroke: 'var(--background)' }}
activeDot={{
r: 6,
stroke: 'var(--background)',
strokeWidth: 2,
}}
/>
) : (
keys.map((key) => (
<Line
key={key}
dataKey={key}
name={key}
type="monotone"
stroke={`var(--color-${key})`}
strokeWidth={2}
connectNulls={false}
isAnimationActive={false}
dot={{ r: 3, strokeWidth: 1, stroke: 'var(--background)' }}
activeDot={{
r: 6,
stroke: 'var(--background)',
strokeWidth: 2,
}}
/>
))
)}
</ComposedChart>
</ChartContainer>
</div>
</div>
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { dedupeBreadcrumbs, getBreadcrumbs } from './breadcrumbs'
describe('getBreadcrumbs', () => {
it('keeps a single Настройки parent plus the active section', () => {
expect(getBreadcrumbs('/settings/appearance')).toEqual([
{ label: 'Настройки', href: '/settings' },
{ label: 'Внешний вид', href: '/settings/appearance' },
])
expect(getBreadcrumbs('/settings/health')).toEqual([
{ label: 'Настройки', href: '/settings' },
{ label: 'Health-check', href: '/settings/health' },
])
expect(getBreadcrumbs('/settings/integrations')).toEqual([
{ label: 'Настройки', href: '/settings' },
{ label: 'Интеграции', href: '/settings/integrations' },
])
})
it('does not reuse the section href for the parent crumb', () => {
const crumbs = getBreadcrumbs('/settings/appearance')
const hrefs = crumbs.map((crumb) => crumb.href)
expect(new Set(hrefs).size).toBe(hrefs.length)
})
})
describe('dedupeBreadcrumbs', () => {
it('collapses stacked identical labels from repeated navigations', () => {
expect(
dedupeBreadcrumbs([
{ label: 'Настройки', href: '/settings' },
{ label: 'Настройки', href: '/settings' },
{ label: 'Настройки', href: '/settings/appearance' },
{ label: 'Внешний вид', href: '/settings/appearance' },
]),
).toEqual([
{ label: 'Настройки', href: '/settings' },
{ label: 'Внешний вид', href: '/settings/appearance' },
])
})
})
+86
View File
@@ -0,0 +1,86 @@
export interface BreadcrumbCrumb {
label: string
href: string
}
const routeTitles: Record<string, string> = {
'/': 'Панель управления',
'/domains': 'Домены',
'/groups': 'Группы доменов',
'/services': 'Сервисы',
'/certificates': 'Сертификаты',
}
const SETTINGS_SECTIONS: Record<string, string> = {
'/settings/appearance': 'Внешний вид',
'/settings/health': 'Health-check',
'/settings/integrations': 'Интеграции',
}
/** Drop consecutive repeats so «Настройки» does not stack after tab switches. */
export function dedupeBreadcrumbs(crumbs: BreadcrumbCrumb[]): BreadcrumbCrumb[] {
const out: BreadcrumbCrumb[] = []
for (const crumb of crumbs) {
const prev = out.at(-1)
if (prev && prev.label === crumb.label) continue
out.push(crumb)
}
return out
}
export function getBreadcrumbs(
pathname: string,
dynamicLabels: Record<string, string> = {},
): BreadcrumbCrumb[] {
const path = pathname.replace(/\/+$/, '') || '/'
if (path === '/') {
return [{ label: 'Панель управления', href: '/' }]
}
if (path.match(/^\/services\/\d+$/)) {
return [
{ label: 'Сервисы', href: '/services' },
{ label: dynamicLabels[path] ?? 'Сервис', href: path },
]
}
if (path.match(/^\/groups\/\d+$/)) {
return [
{ label: 'Группы доменов', href: '/groups' },
{ label: dynamicLabels[path] ?? 'Группа', href: path },
]
}
if (path.match(/^\/domains\/\d+\/dns$/)) {
const domainId = path.split('/')[2]
const domainPath = `/domains/${domainId}`
return [
{ label: 'Домены', href: '/domains' },
{ label: dynamicLabels[domainPath] ?? 'Домен', href: domainPath },
{ label: 'DNS', href: path },
]
}
if (path.match(/^\/domains\/\d+$/)) {
return [
{ label: 'Домены', href: '/domains' },
{ label: dynamicLabels[path] ?? 'Обзор домена', href: path },
]
}
if (path === '/settings' || path.startsWith('/settings/')) {
const section = SETTINGS_SECTIONS[path]
return dedupeBreadcrumbs([
{ label: 'Настройки', href: '/settings' },
...(section ? [{ label: section, href: path }] : []),
])
}
const title = routeTitles[path]
if (title) {
return [{ label: title, href: path }]
}
return [{ label: 'Панель управления', href: '/' }]
}
+53 -9
View File
@@ -2217,6 +2217,40 @@ function pruneStaleIpHealthStatus(db, activeTargets) {
}
return deleted;
}
function normalizeCnameHost(target, zoneName) {
const trimmed = target.trim().toLowerCase().replace(/\.+$/, "");
if (!trimmed) return "";
if (trimmed.includes(".")) return trimmed;
const zone = zoneName.trim().toLowerCase().replace(/\.+$/, "");
return zone ? `${trimmed}.${zone}` : trimmed;
}
function resolveCnameProbeIps(db, cnameTarget, zoneName, serviceId) {
const fqdn = normalizeCnameHost(cnameTarget, zoneName);
if (fqdn) {
const fromDns = listOriginIpsForFqdn(db, fqdn).filter(isIpLiteral);
if (fromDns.length > 0) return [...new Set(fromDns)];
}
if (serviceId > 0) {
return [...new Set(listServiceIps(db, serviceId).filter(isIpLiteral))];
}
return [];
}
function expandCnameHealthTargets(db, rows) {
const expanded = [];
for (const row of rows) {
const ips = resolveCnameProbeIps(
db,
row.ip,
row.zone_name ?? "",
row.service_id ?? 0
);
const resolved = ips.length > 0 ? ips : [row.ip];
for (const ip of resolved) {
expanded.push({ ...row, ip });
}
}
return expanded;
}
function listHealthCheckTargets(db) {
const fqdnExpr = sql2`CASE WHEN sb.hostname = '@' OR sb.hostname IS NULL THEN d.zone_name ELSE sb.hostname || '.' || d.zone_name END`;
const bindingTargets = db.all(sql2`
@@ -2235,7 +2269,7 @@ function listHealthCheckTargets(db) {
JOIN service_bindings sb ON sb.id = sbi.binding_id
JOIN domains d ON d.id = sb.domain_id
WHERE sb.health_check_enabled = 1
`);
`).filter((t) => isIpLiteral(t.ip));
const groupTargets = db.all(sql2`
SELECT DISTINCT 'group' AS scope, sg.id AS ref_id, sbi.ip,
sg.domain AS hostname,
@@ -2258,7 +2292,7 @@ function listHealthCheckTargets(db) {
AND s.enabled = 1
AND sg.enabled = 1
AND (sb.cname_target IS NULL OR sb.cname_target = '')
`);
`).filter((t) => isIpLiteral(t.ip));
const groupInheritedBindingTargets = db.all(sql2`
SELECT 'binding' AS scope, sb.id AS ref_id, sbi.ip,
${fqdnExpr} AS hostname,
@@ -2281,8 +2315,10 @@ function listHealthCheckTargets(db) {
AND s.enabled = 1
AND sg.enabled = 1
AND sb.health_check_enabled = 0
`);
const cnameBindingTargets = db.all(sql2`
`).filter((t) => isIpLiteral(t.ip));
const cnameBindingTargets = expandCnameHealthTargets(
db,
db.all(sql2`
SELECT 'binding' AS scope, sb.id AS ref_id, sb.cname_target AS ip,
${fqdnExpr} AS hostname,
sb.health_check_type AS type,
@@ -2293,7 +2329,9 @@ function listHealthCheckTargets(db) {
sb.health_check_verify_tls AS verify_tls,
sb.health_check_providers AS providers_json,
COALESCE(sb.health_check_aggregate, 'majority') AS aggregate,
COALESCE(sb.health_check_provider, 'local') AS provider
COALESCE(sb.health_check_provider, 'local') AS provider,
d.zone_name AS zone_name,
sb.service_id AS service_id
FROM service_bindings sb
JOIN domains d ON d.id = sb.domain_id
JOIN services s ON s.id = sb.service_id
@@ -2301,8 +2339,11 @@ function listHealthCheckTargets(db) {
AND sb.cname_target IS NOT NULL
AND sb.cname_target <> ''
AND s.enabled = 1
`);
const groupInheritedCnameBindingTargets = db.all(sql2`
`)
);
const groupInheritedCnameBindingTargets = expandCnameHealthTargets(
db,
db.all(sql2`
SELECT 'binding' AS scope, sb.id AS ref_id, sb.cname_target AS ip,
${fqdnExpr} AS hostname,
sg.health_check_type AS type,
@@ -2313,7 +2354,9 @@ function listHealthCheckTargets(db) {
sg.health_check_verify_tls AS verify_tls,
sg.health_check_providers AS providers_json,
COALESCE(sg.health_check_aggregate, 'majority') AS aggregate,
COALESCE(sg.health_check_provider, 'local') AS provider
COALESCE(sg.health_check_provider, 'local') AS provider,
d.zone_name AS zone_name,
sb.service_id AS service_id
FROM service_bindings sb
JOIN domains d ON d.id = sb.domain_id
JOIN services s ON s.id = sb.service_id
@@ -2325,7 +2368,8 @@ function listHealthCheckTargets(db) {
AND sb.health_check_enabled = 0
AND sb.cname_target IS NOT NULL
AND sb.cname_target <> ''
`);
`)
);
return [
...bindingTargets,
...groupTargets,
+87 -13
View File
@@ -2493,13 +2493,73 @@ export function pruneStaleIpHealthStatus(
// --- Health Check Targets ---
function normalizeCnameHost(target: string, zoneName: string): string {
const trimmed = target.trim().toLowerCase().replace(/\.+$/, "");
if (!trimmed) return "";
if (trimmed.includes(".")) return trimmed;
const zone = zoneName.trim().toLowerCase().replace(/\.+$/, "");
return zone ? `${trimmed}.${zone}` : trimmed;
}
/**
* Unwrap a CNAME health target to origin IPs:
* 1) A/AAAA from local dns_records (follows CNAME chain)
* 2) this service's IP pool
*
* Hostname is kept as fallback so probes still run when nothing resolves.
*/
function resolveCnameProbeIps(
db: Db,
cnameTarget: string,
zoneName: string,
serviceId: number,
): string[] {
const fqdn = normalizeCnameHost(cnameTarget, zoneName);
if (fqdn) {
const fromDns = listOriginIpsForFqdn(db, fqdn).filter(isIpLiteral);
if (fromDns.length > 0) return [...new Set(fromDns)];
}
if (serviceId > 0) {
return [...new Set(listServiceIps(db, serviceId).filter(isIpLiteral))];
}
return [];
}
type RawHealthTarget = HealthCheckTarget & {
providers_json?: string | null;
aggregate?: string | null;
zone_name?: string | null;
service_id?: number | null;
};
function expandCnameHealthTargets(
db: Db,
rows: RawHealthTarget[],
): RawHealthTarget[] {
const expanded: RawHealthTarget[] = [];
for (const row of rows) {
const ips = resolveCnameProbeIps(
db,
row.ip,
row.zone_name ?? "",
row.service_id ?? 0,
);
const resolved = ips.length > 0 ? ips : [row.ip];
for (const ip of resolved) {
expanded.push({ ...row, ip });
}
}
return expanded;
}
export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
// FQDN for a binding: "@" => zone_name, else "<hostname>.<zone_name>".
// Used as SNI / Host header for HTTP(S) probes — the raw `sb.hostname` is just the record name
// (e.g. "de" or "@"), which would break TLS SNI (ssl alert 112 "unrecognized name").
const fqdnExpr = sql`CASE WHEN sb.hostname = '@' OR sb.hostname IS NULL THEN d.zone_name ELSE sb.hostname || '.' || d.zone_name END`;
const bindingTargets = db.all<HealthCheckTarget>(sql`
const bindingTargets = db
.all<RawHealthTarget>(sql`
SELECT 'binding' AS scope, sb.id AS ref_id, sbi.ip,
${fqdnExpr} AS hostname,
sb.health_check_type AS type,
@@ -2515,12 +2575,14 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
JOIN service_bindings sb ON sb.id = sbi.binding_id
JOIN domains d ON d.id = sb.domain_id
WHERE sb.health_check_enabled = 1
`);
`)
.filter((t) => isIpLiteral(t.ip));
// Group FQDN probes the same A-record IPs that DNS publishes (binding IPs of
// enabled services) — NOT the full service_ips pool. Pool-only dead IPs must
// not mark the group Down while the published domain stays healthy.
const groupTargets = db.all<HealthCheckTarget>(sql`
const groupTargets = db
.all<RawHealthTarget>(sql`
SELECT DISTINCT 'group' AS scope, sg.id AS ref_id, sbi.ip,
sg.domain AS hostname,
sg.health_check_type AS type,
@@ -2542,13 +2604,14 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
AND s.enabled = 1
AND sg.enabled = 1
AND (sb.cname_target IS NULL OR sb.cname_target = '')
`);
`)
.filter((t) => isIpLiteral(t.ip));
// IPs of A-bindings of enabled services in a group whose group has health-check enabled.
// These inherit the group's health-check config (scope='binding', ref_id=binding_id),
// so per-domain badges reflect group rules. Skipped for bindings that already have
// their own health_check_enabled=1 (covered by bindingTargets above).
const groupInheritedBindingTargets = db.all<HealthCheckTarget>(sql`
const groupInheritedBindingTargets = db.all<RawHealthTarget>(sql`
SELECT 'binding' AS scope, sb.id AS ref_id, sbi.ip,
${fqdnExpr} AS hostname,
sg.health_check_type AS type,
@@ -2570,10 +2633,13 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
AND s.enabled = 1
AND sg.enabled = 1
AND sb.health_check_enabled = 0
`);
`)
.filter((t) => isIpLiteral(t.ip));
// CNAME-bindings with their own health_check_enabled: probe the CNAME target host.
const cnameBindingTargets = db.all<HealthCheckTarget>(sql`
// CNAME-bindings: unwrap to origin/service IPs so ip_health keys match the IP table.
const cnameBindingTargets = expandCnameHealthTargets(
db,
db.all<RawHealthTarget>(sql`
SELECT 'binding' AS scope, sb.id AS ref_id, sb.cname_target AS ip,
${fqdnExpr} AS hostname,
sb.health_check_type AS type,
@@ -2584,7 +2650,9 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
sb.health_check_verify_tls AS verify_tls,
sb.health_check_providers AS providers_json,
COALESCE(sb.health_check_aggregate, 'majority') AS aggregate,
COALESCE(sb.health_check_provider, 'local') AS provider
COALESCE(sb.health_check_provider, 'local') AS provider,
d.zone_name AS zone_name,
sb.service_id AS service_id
FROM service_bindings sb
JOIN domains d ON d.id = sb.domain_id
JOIN services s ON s.id = sb.service_id
@@ -2592,11 +2660,14 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
AND sb.cname_target IS NOT NULL
AND sb.cname_target <> ''
AND s.enabled = 1
`);
`),
);
// CNAME-bindings of services in a group with group health-check enabled
// (inherit group config). Only for bindings without their own health_check_enabled.
const groupInheritedCnameBindingTargets = db.all<HealthCheckTarget>(sql`
const groupInheritedCnameBindingTargets = expandCnameHealthTargets(
db,
db.all<RawHealthTarget>(sql`
SELECT 'binding' AS scope, sb.id AS ref_id, sb.cname_target AS ip,
${fqdnExpr} AS hostname,
sg.health_check_type AS type,
@@ -2607,7 +2678,9 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
sg.health_check_verify_tls AS verify_tls,
sg.health_check_providers AS providers_json,
COALESCE(sg.health_check_aggregate, 'majority') AS aggregate,
COALESCE(sg.health_check_provider, 'local') AS provider
COALESCE(sg.health_check_provider, 'local') AS provider,
d.zone_name AS zone_name,
sb.service_id AS service_id
FROM service_bindings sb
JOIN domains d ON d.id = sb.domain_id
JOIN services s ON s.id = sb.service_id
@@ -2619,7 +2692,8 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
AND sb.health_check_enabled = 0
AND sb.cname_target IS NOT NULL
AND sb.cname_target <> ''
`);
`),
);
return [
...bindingTargets,