Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
634a9dc362 | ||
|
|
1bf6cfa0d0 | ||
|
|
3da6de9311 | ||
|
|
7a8bacade9 |
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,81 @@ 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 (
|
||||
<ToggleGroup
|
||||
multiple
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full min-w-0"
|
||||
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}
|
||||
className="min-w-0 flex-1 gap-1.5"
|
||||
>
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
size="xs"
|
||||
className={item.iconClassName}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{item.icon}
|
||||
</IconTile>
|
||||
<span className="truncate">{item.title}</span>
|
||||
<HealthCheckBadge
|
||||
status={statuses[item.id] ?? 'unknown'}
|
||||
provider={item.id}
|
||||
size="xs"
|
||||
/>
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 +361,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 +379,7 @@ export function HealthProviderStatusTiles({
|
||||
size="xs"
|
||||
/>
|
||||
}
|
||||
onActivate={() => toggle(item.id)}
|
||||
onActivate={() => onChange(toggleProviders(active, item.id))}
|
||||
/>
|
||||
))}
|
||||
</ChoiceFrame>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { UptimeChart, type UptimeProbe, type UptimePeriodKey } from './uptime-chart'
|
||||
export { UptimeChart, type UptimeProbe, type UptimePeriodKey, probeUptimePercent, lastProbeLatency } from './uptime-chart'
|
||||
export { ServiceHealthMonitor } from './service-health-monitor'
|
||||
export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from './filter-utils'
|
||||
export { CertStatusChart, GroupDomainsChart } from './dashboard-analytics'
|
||||
@@ -24,6 +24,7 @@ export {
|
||||
HealthSourceTiles,
|
||||
HealthAggregateTiles,
|
||||
HealthProviderStatusTiles,
|
||||
HealthSourceFilterBar,
|
||||
type HealthProvider,
|
||||
type HealthAggregate,
|
||||
} from './health-source-tiles'
|
||||
|
||||
@@ -9,40 +9,78 @@ import {
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { UptimeChart, UPTIME_PERIODS, type UptimePeriodKey } from '@/components/reui-kit/uptime-chart'
|
||||
import { HealthSourceFilterBar } from '@/components/reui-kit/health-source-tiles'
|
||||
import {
|
||||
collapseStatusChanges,
|
||||
filterByPeriod,
|
||||
filterByProviders,
|
||||
type HealthLogProbe,
|
||||
type HealthLogStatus,
|
||||
} from '@/lib/health-log'
|
||||
import type { HealthCheckProvider } from '@cfdm/shared'
|
||||
|
||||
/**
|
||||
* Combined uptime chart + status-change timeline (solution-analytics-8 DNA).
|
||||
* Preview: https://reui.io/preview/base/solution-analytics-8 · https://reui.io/preview/base/chart-17
|
||||
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/timeline
|
||||
* Единый блок мониторинга: компактный мультивыбор типа пробы (list-9 / ToggleGroup)
|
||||
* + график (chart-17) + таймлайн смен статуса.
|
||||
*
|
||||
* 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
|
||||
* Docs: https://reui.io/docs/components/base/icon-tile
|
||||
* Docs: https://reui.io/docs/components/base/timeline
|
||||
* Docs: https://reui.io/docs/components/base/badge
|
||||
*/
|
||||
export function ServiceHealthMonitor({
|
||||
items,
|
||||
selectedProviders,
|
||||
enabledProviders,
|
||||
statuses,
|
||||
isLoading = false,
|
||||
}: {
|
||||
items: HealthLogProbe[]
|
||||
selectedProviders: readonly HealthCheckProvider[]
|
||||
enabledProviders: readonly HealthCheckProvider[]
|
||||
statuses: Partial<Record<HealthCheckProvider, HealthLogStatus>>
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const [period, setPeriod] = useState<UptimePeriodKey>('5D')
|
||||
const [selected, setSelected] = useState<HealthCheckProvider[] | null>(null)
|
||||
const days = UPTIME_PERIODS.find((entry) => entry.key === period)?.days ?? 5
|
||||
|
||||
const enabled = useMemo(
|
||||
() => [...enabledProviders],
|
||||
[enabledProviders],
|
||||
)
|
||||
|
||||
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),
|
||||
[items, days],
|
||||
)
|
||||
|
||||
const filtered = useMemo(
|
||||
() => filterByProviders(filterByPeriod(items, days), selectedProviders),
|
||||
[items, days, selectedProviders],
|
||||
() => filterByProviders(periodItems, activeProviders),
|
||||
[periodItems, activeProviders],
|
||||
)
|
||||
|
||||
const changes = useMemo(() => collapseStatusChanges(filtered), [filtered])
|
||||
|
||||
return (
|
||||
<Frame stacked spacing="sm" className="min-w-0 w-full">
|
||||
<FramePanel>
|
||||
<HealthSourceFilterBar
|
||||
enabled={enabled}
|
||||
selected={activeProviders}
|
||||
statuses={statuses}
|
||||
onChange={setSelected}
|
||||
/>
|
||||
</FramePanel>
|
||||
|
||||
<UptimeChart
|
||||
items={filtered}
|
||||
isLoading={isLoading}
|
||||
@@ -50,12 +88,15 @@ export function ServiceHealthMonitor({
|
||||
onPeriodChange={setPeriod}
|
||||
skipPeriodFilter
|
||||
embedded
|
||||
hideHeader
|
||||
/>
|
||||
|
||||
<FramePanel className="flex flex-col gap-3">
|
||||
<FrameHeader className="px-0 py-0">
|
||||
<FrameTitle>Смены статуса</FrameTitle>
|
||||
<FrameDescription>
|
||||
Только переходы up / degraded / down · Cloudflare = Worker, не Health Checks API
|
||||
Только переходы up / degraded / down · Cloudflare = Worker, не Health
|
||||
Checks API
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<HealthTimeline
|
||||
|
||||
@@ -112,6 +112,23 @@ function UptimeDelta({ delta }: { delta: number }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function probeUptimePercent(items: UptimeProbe[]): number | null {
|
||||
return uptimePercent(toSeries(items))
|
||||
}
|
||||
|
||||
export function lastProbeLatency(items: UptimeProbe[]): number | null {
|
||||
if (items.length === 0) return null
|
||||
const latest = [...items].sort(
|
||||
(a, b) => probeTime(b.checked_at) - probeTime(a.checked_at),
|
||||
)[0]
|
||||
return latest?.latency_ms ?? null
|
||||
}
|
||||
|
||||
function formatUptime(value: number | null): string {
|
||||
if (value == null) return '—'
|
||||
return `${value.toFixed(value >= 99.95 ? 2 : 1)}%`
|
||||
}
|
||||
|
||||
interface UptimeChartProps {
|
||||
items: UptimeProbe[]
|
||||
isLoading?: boolean
|
||||
@@ -119,6 +136,8 @@ interface UptimeChartProps {
|
||||
onPeriodChange?: (period: UptimePeriodKey) => void
|
||||
skipPeriodFilter?: boolean
|
||||
embedded?: boolean
|
||||
/** dashboard-4: chrome живёт в родительском FrameHeader (переключатель серий). */
|
||||
hideHeader?: boolean
|
||||
}
|
||||
|
||||
export function UptimeChart({
|
||||
@@ -128,6 +147,7 @@ export function UptimeChart({
|
||||
onPeriodChange,
|
||||
skipPeriodFilter = false,
|
||||
embedded = false,
|
||||
hideHeader = false,
|
||||
}: UptimeChartProps) {
|
||||
const gradientId = useId().replace(/:/g, '')
|
||||
const [internalPeriod, setInternalPeriod] = useState<UptimePeriodKey>('5D')
|
||||
@@ -155,43 +175,45 @@ export function UptimeChart({
|
||||
|
||||
const panel = (
|
||||
<FramePanel className="flex flex-col gap-6">
|
||||
<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">
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
className={`size-10.5 ${tileClass}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ActivityIcon />
|
||||
</IconTile>
|
||||
<div className="flex flex-col justify-center">
|
||||
<h3 className="text-base font-semibold">Uptime</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Пробы health-check за период
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<TooltipProvider delay={150}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label="О графике uptime"
|
||||
className="text-muted-foreground/70 -mr-1"
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
/>
|
||||
}
|
||||
{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">
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
className={`size-10.5 ${tileClass}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<InfoIcon data-icon="inline-start" aria-hidden="true" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={8}>
|
||||
<p>Доля успешных проб и задержка (мс) по журналу health-log.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<ActivityIcon />
|
||||
</IconTile>
|
||||
<div className="flex flex-col justify-center">
|
||||
<h3 className="text-base font-semibold">Uptime</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Пробы health-check за период
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<TooltipProvider delay={150}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label="О графике uptime"
|
||||
className="text-muted-foreground/70 -mr-1"
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<InfoIcon data-icon="inline-start" aria-hidden="true" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={8}>
|
||||
<p>Доля успешных проб и задержка (мс) по журналу health-log.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="bg-muted h-40 w-full animate-pulse rounded-xl" />
|
||||
@@ -207,7 +229,7 @@ export function UptimeChart({
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-foreground text-3xl font-semibold tabular-nums">
|
||||
{uptime == null ? '—' : `${uptime.toFixed(uptime >= 99.95 ? 2 : 1)}%`}
|
||||
{formatUptime(uptime)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{delta == null ? (
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
} from '@/components/services/service-detail-grid'
|
||||
import { LbModeTile } from '@/components/services/service-unit-card'
|
||||
import {
|
||||
HealthProviderStatusTiles,
|
||||
KpiStatGrid,
|
||||
ServiceHealthMonitor,
|
||||
} from '@/components/reui-kit'
|
||||
@@ -45,7 +44,6 @@ import {
|
||||
providerHealthStatuses,
|
||||
} from '@/lib/health-log'
|
||||
import type { ServiceView, UpdateServiceConfigInput } from '@/lib/schemas'
|
||||
import type { HealthCheckProvider } from '@cfdm/shared'
|
||||
import {
|
||||
createServiceNode,
|
||||
deleteServiceNode,
|
||||
@@ -112,9 +110,6 @@ function ServiceDetailPage() {
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [selectedProviders, setSelectedProviders] = useState<
|
||||
HealthCheckProvider[] | null
|
||||
>(null)
|
||||
const [togglingIp, setTogglingIp] = useState<string | null>(null)
|
||||
const [changeIp, setChangeIp] = useState<{
|
||||
bindingId: number
|
||||
@@ -234,11 +229,6 @@ function ServiceDetailPage() {
|
||||
() => enabledHealthProviders(service?.domains ?? []),
|
||||
[service],
|
||||
)
|
||||
const activeProviders =
|
||||
selectedProviders?.filter((provider) => enabledProviders.includes(provider)) ??
|
||||
enabledProviders
|
||||
const effectiveProviders =
|
||||
activeProviders.length > 0 ? activeProviders : enabledProviders
|
||||
const providerStatuses = useMemo(
|
||||
() => providerHealthStatuses(logItems, enabledProviders),
|
||||
[logItems, enabledProviders],
|
||||
@@ -333,20 +323,14 @@ function ServiceDetailPage() {
|
||||
]}
|
||||
/>
|
||||
|
||||
<HealthProviderStatusTiles
|
||||
enabled={enabledProviders}
|
||||
selected={effectiveProviders}
|
||||
statuses={providerStatuses}
|
||||
onChange={setSelectedProviders}
|
||||
/>
|
||||
|
||||
<section
|
||||
aria-label="Мониторинг"
|
||||
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
|
||||
>
|
||||
<ServiceHealthMonitor
|
||||
items={logItems}
|
||||
selectedProviders={effectiveProviders}
|
||||
enabledProviders={enabledProviders}
|
||||
statuses={providerStatuses}
|
||||
isLoading={logQuery.isLoading}
|
||||
/>
|
||||
<Frame dense spacing="sm" className="min-w-0 w-full">
|
||||
|
||||
Vendored
+53
-9
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user