Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39fac7834f | ||
|
|
d267e40157 | ||
|
|
634a9dc362 | ||
|
|
1bf6cfa0d0 | ||
|
|
3da6de9311 | ||
|
|
7a8bacade9 |
@@ -18,6 +18,7 @@ import {
|
|||||||
SYNC_PENDING_PUSH,
|
SYNC_PENDING_PUSH,
|
||||||
SYNC_SYNCED,
|
SYNC_SYNCED,
|
||||||
dnsRecordNamesMatch,
|
dnsRecordNamesMatch,
|
||||||
|
isIpLiteral,
|
||||||
normalizeDnsRecordName,
|
normalizeDnsRecordName,
|
||||||
} from "@cfdm/shared";
|
} from "@cfdm/shared";
|
||||||
import type { CloudflareClient } from "../lib/cf-client.js";
|
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(
|
function attachServiceHealth(
|
||||||
db: Db,
|
db: Db,
|
||||||
views: ServiceView[],
|
views: ServiceView[],
|
||||||
@@ -353,11 +412,16 @@ function attachServiceHealth(
|
|||||||
const ipHealthByService = repos.listIpHealthByServiceIds(db, ids);
|
const ipHealthByService = repos.listIpHealthByServiceIds(db, ids);
|
||||||
return views.map((view) => {
|
return views.map((view) => {
|
||||||
const health = healthByService.get(view.id);
|
const health = healthByService.get(view.id);
|
||||||
const byIp = new Map(
|
const rows = ipHealthByService.get(view.id) ?? [];
|
||||||
(ipHealthByService.get(view.id) ?? []).map((row) => [row.ip, row]),
|
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 ip_health = (view.ips ?? []).map((ip) => {
|
||||||
const row = byIp.get(ip);
|
const row = byIp.get(ip) ?? (aRecordIps.has(ip) ? undefined : cnameFallback);
|
||||||
return {
|
return {
|
||||||
ip,
|
ip,
|
||||||
status: row?.status ?? ("unknown" as const),
|
status: row?.status ?? ("unknown" as const),
|
||||||
|
|||||||
@@ -235,4 +235,95 @@ describe("health-check state derivation via runAllChecks", () => {
|
|||||||
expect(targets).toHaveLength(1);
|
expect(targets).toHaveLength(1);
|
||||||
expect(targets[0]?.verify_tls).toBe(true);
|
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,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import { Fragment, useMemo } from 'react'
|
||||||
import { Link, useMatches, useRouterState } from '@tanstack/react-router'
|
import { Link, useMatches, useRouterState } from '@tanstack/react-router'
|
||||||
import { useMemo } from 'react'
|
|
||||||
import {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
BreadcrumbItem,
|
BreadcrumbItem,
|
||||||
@@ -12,82 +12,12 @@ import { Separator } from '@cfdm/ui/components/separator'
|
|||||||
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
||||||
import { AppsMenu } from '@/components/layout/apps-menu'
|
import { AppsMenu } from '@/components/layout/apps-menu'
|
||||||
import { SidebarTrigger } from '@cfdm/ui/components/sidebar'
|
import { SidebarTrigger } from '@cfdm/ui/components/sidebar'
|
||||||
|
import { getBreadcrumbs } from '@/lib/breadcrumbs'
|
||||||
|
|
||||||
export interface RouteBreadcrumbLoaderData {
|
export interface RouteBreadcrumbLoaderData {
|
||||||
breadcrumb?: string
|
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() {
|
function useDynamicBreadcrumbLabels() {
|
||||||
const matches = useMatches()
|
const matches = useMatches()
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
@@ -106,7 +36,10 @@ function useDynamicBreadcrumbLabels() {
|
|||||||
export function SiteHeader() {
|
export function SiteHeader() {
|
||||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||||
const dynamicLabels = useDynamicBreadcrumbLabels()
|
const dynamicLabels = useDynamicBreadcrumbLabels()
|
||||||
const crumbs = getBreadcrumbs(pathname, dynamicLabels)
|
const crumbs = useMemo(
|
||||||
|
() => getBreadcrumbs(pathname, dynamicLabels),
|
||||||
|
[pathname, dynamicLabels],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
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">
|
<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) => {
|
{crumbs.map((crumb, index) => {
|
||||||
const isLast = index === crumbs.length - 1
|
const isLast = index === crumbs.length - 1
|
||||||
return (
|
return (
|
||||||
<span key={crumb.href} className="contents">
|
<Fragment key={`${index}-${crumb.href}`}>
|
||||||
{index > 0 && (
|
{index > 0 ? (
|
||||||
<BreadcrumbSeparator className="hidden md:block" />
|
<BreadcrumbSeparator className="hidden md:block" />
|
||||||
)}
|
) : null}
|
||||||
<BreadcrumbItem
|
<BreadcrumbItem
|
||||||
className={index === 0 && !isLast ? 'hidden md:block' : undefined}
|
className={index === 0 && !isLast ? 'hidden md:block' : undefined}
|
||||||
>
|
>
|
||||||
@@ -132,7 +65,7 @@ export function SiteHeader() {
|
|||||||
</BreadcrumbLink>
|
</BreadcrumbLink>
|
||||||
)}
|
)}
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
</span>
|
</Fragment>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</BreadcrumbList>
|
</BreadcrumbList>
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ import {
|
|||||||
} from '@cfdm/ui/components/item'
|
} from '@cfdm/ui/components/item'
|
||||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
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 { parseHealthProviders, type HealthCheckAggregate, type HealthCheckProvider } from '@cfdm/shared'
|
||||||
import type { HealthLogStatus } from '@/lib/health-log'
|
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.
|
* 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
|
* 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
|
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 (
|
return (
|
||||||
<ChoiceFrame>
|
<ChoiceFrame>
|
||||||
{visible.map((item) => (
|
{visible.map((item) => (
|
||||||
@@ -309,7 +384,7 @@ export function HealthProviderStatusTiles({
|
|||||||
size="xs"
|
size="xs"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
onActivate={() => toggle(item.id)}
|
onActivate={() => onChange(toggleProviders(active, item.id))}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ChoiceFrame>
|
</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 { ServiceHealthMonitor } from './service-health-monitor'
|
||||||
export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from './filter-utils'
|
export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from './filter-utils'
|
||||||
export { CertStatusChart, GroupDomainsChart } from './dashboard-analytics'
|
export { CertStatusChart, GroupDomainsChart } from './dashboard-analytics'
|
||||||
@@ -24,6 +24,7 @@ export {
|
|||||||
HealthSourceTiles,
|
HealthSourceTiles,
|
||||||
HealthAggregateTiles,
|
HealthAggregateTiles,
|
||||||
HealthProviderStatusTiles,
|
HealthProviderStatusTiles,
|
||||||
|
HealthSourceFilterBar,
|
||||||
type HealthProvider,
|
type HealthProvider,
|
||||||
type HealthAggregate,
|
type HealthAggregate,
|
||||||
} from './health-source-tiles'
|
} from './health-source-tiles'
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ function resolveFooter(item: KpiStatItem): ReactNode {
|
|||||||
if (item.footer) return item.footer
|
if (item.footer) return item.footer
|
||||||
if (typeof item.hint === 'string') {
|
if (typeof item.hint === 'string') {
|
||||||
return (
|
return (
|
||||||
<Badge variant="outline" size="sm">
|
<Badge variant="outline" size="sm" className="max-w-[min(100%,11rem)] truncate">
|
||||||
{item.hint}
|
{item.hint}
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
@@ -81,30 +81,39 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) {
|
|||||||
const valueVariant = item.variant ?? 'default'
|
const valueVariant = item.variant ?? 'default'
|
||||||
|
|
||||||
return (
|
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 ? (
|
{item.icon ? (
|
||||||
<IconTile
|
<IconTile
|
||||||
variant="elevated"
|
variant="elevated"
|
||||||
aria-hidden="true"
|
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}
|
{item.icon}
|
||||||
</IconTile>
|
</IconTile>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex min-w-0 items-start justify-between gap-2">
|
||||||
<div className="text-muted-foreground text-sm font-medium">{item.label}</div>
|
<div className="text-muted-foreground min-w-0 truncate text-sm font-medium">
|
||||||
{footer ? <div className="shrink-0">{footer}</div> : null}
|
{item.label}
|
||||||
|
</div>
|
||||||
|
{footer ? (
|
||||||
|
<div className="hidden min-w-0 max-w-[min(100%,11rem)] shrink-0 @[20rem]:block">
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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],
|
VALUE_VARIANT_CLASS[valueVariant],
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.value}
|
{item.value}
|
||||||
</div>
|
</div>
|
||||||
|
{footer ? (
|
||||||
|
<div className="min-w-0 max-w-full @[20rem]:hidden">{footer}</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -116,7 +125,7 @@ function panelClassName(item: KpiStatItem, className?: string) {
|
|||||||
const selected = isSelected(item)
|
const selected = isSelected(item)
|
||||||
|
|
||||||
return cn(
|
return cn(
|
||||||
'relative isolate flex h-full flex-col',
|
'relative isolate flex h-full min-w-0 flex-col',
|
||||||
clickable &&
|
clickable &&
|
||||||
'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2',
|
'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',
|
selected && 'ring-primary/30 bg-muted/30 ring-1',
|
||||||
|
|||||||
@@ -9,40 +9,78 @@ import {
|
|||||||
FrameTitle,
|
FrameTitle,
|
||||||
} from '@/components/reui/frame'
|
} from '@/components/reui/frame'
|
||||||
import { UptimeChart, UPTIME_PERIODS, type UptimePeriodKey } from '@/components/reui-kit/uptime-chart'
|
import { UptimeChart, UPTIME_PERIODS, type UptimePeriodKey } from '@/components/reui-kit/uptime-chart'
|
||||||
|
import { HealthSourceFilterBar } from '@/components/reui-kit/health-source-tiles'
|
||||||
import {
|
import {
|
||||||
collapseStatusChanges,
|
collapseStatusChanges,
|
||||||
filterByPeriod,
|
filterByPeriod,
|
||||||
filterByProviders,
|
filterByProviders,
|
||||||
type HealthLogProbe,
|
type HealthLogProbe,
|
||||||
|
type HealthLogStatus,
|
||||||
} from '@/lib/health-log'
|
} from '@/lib/health-log'
|
||||||
import type { HealthCheckProvider } from '@cfdm/shared'
|
import type { HealthCheckProvider } from '@cfdm/shared'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Combined uptime chart + status-change timeline (solution-analytics-8 DNA).
|
* Единый блок мониторинга: компактный мультивыбор типа пробы (list-9 / ToggleGroup)
|
||||||
* Preview: https://reui.io/preview/base/solution-analytics-8 · https://reui.io/preview/base/chart-17
|
* + график (chart-17) + таймлайн смен статуса.
|
||||||
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/timeline
|
*
|
||||||
|
* 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({
|
export function ServiceHealthMonitor({
|
||||||
items,
|
items,
|
||||||
selectedProviders,
|
enabledProviders,
|
||||||
|
statuses,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
}: {
|
}: {
|
||||||
items: HealthLogProbe[]
|
items: HealthLogProbe[]
|
||||||
selectedProviders: readonly HealthCheckProvider[]
|
enabledProviders: readonly HealthCheckProvider[]
|
||||||
|
statuses: Partial<Record<HealthCheckProvider, HealthLogStatus>>
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
}) {
|
}) {
|
||||||
const [period, setPeriod] = useState<UptimePeriodKey>('5D')
|
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 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(
|
const filtered = useMemo(
|
||||||
() => filterByProviders(filterByPeriod(items, days), selectedProviders),
|
() => filterByProviders(periodItems, activeProviders),
|
||||||
[items, days, selectedProviders],
|
[periodItems, activeProviders],
|
||||||
)
|
)
|
||||||
|
|
||||||
const changes = useMemo(() => collapseStatusChanges(filtered), [filtered])
|
const changes = useMemo(() => collapseStatusChanges(filtered), [filtered])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Frame stacked spacing="sm" className="min-w-0 w-full">
|
<Frame stacked spacing="sm" className="min-w-0 w-full">
|
||||||
|
<FramePanel>
|
||||||
|
<HealthSourceFilterBar
|
||||||
|
enabled={enabled}
|
||||||
|
selected={activeProviders}
|
||||||
|
statuses={statuses}
|
||||||
|
onChange={setSelected}
|
||||||
|
/>
|
||||||
|
</FramePanel>
|
||||||
|
|
||||||
<UptimeChart
|
<UptimeChart
|
||||||
items={filtered}
|
items={filtered}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
@@ -50,12 +88,15 @@ export function ServiceHealthMonitor({
|
|||||||
onPeriodChange={setPeriod}
|
onPeriodChange={setPeriod}
|
||||||
skipPeriodFilter
|
skipPeriodFilter
|
||||||
embedded
|
embedded
|
||||||
|
hideHeader
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FramePanel className="flex flex-col gap-3">
|
<FramePanel className="flex flex-col gap-3">
|
||||||
<FrameHeader className="px-0 py-0">
|
<FrameHeader className="px-0 py-0">
|
||||||
<FrameTitle>Смены статуса</FrameTitle>
|
<FrameTitle>Смены статуса</FrameTitle>
|
||||||
<FrameDescription>
|
<FrameDescription>
|
||||||
Только переходы up / degraded / down · Cloudflare = Worker, не Health Checks API
|
Только переходы up / degraded / down · Cloudflare = Worker, не Health
|
||||||
|
Checks API
|
||||||
</FrameDescription>
|
</FrameDescription>
|
||||||
</FrameHeader>
|
</FrameHeader>
|
||||||
<HealthTimeline
|
<HealthTimeline
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 { ActivityIcon, InfoIcon, TrendingDownIcon, TrendingUpIcon } from 'lucide-react'
|
||||||
import { Area, AreaChart, XAxis } from 'recharts'
|
import { Area, ComposedChart, Line, XAxis } from 'recharts'
|
||||||
|
|
||||||
import { EmptyState } from '@/components/empty-state'
|
import { EmptyState } from '@/components/empty-state'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
@@ -22,12 +22,13 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@cfdm/ui/components/tooltip'
|
} from '@cfdm/ui/components/tooltip'
|
||||||
|
import type { HealthCheckProvider } from '@cfdm/shared'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Uptime monitoring card — chart-17 DNA (Frame + value + AreaChart + period tabs).
|
* Uptime monitoring card — chart-17 DNA (Frame + value + AreaChart + period tabs).
|
||||||
* Preview: https://reui.io/preview/base/chart-17
|
* Preview: https://reui.io/preview/base/chart-17
|
||||||
* Frame: https://reui.io/docs/components/base/frame
|
* Frame: https://reui.io/docs/components/base/frame
|
||||||
* Chart: shadcn Chart + Recharts AreaChart
|
* Chart: shadcn Chart + Recharts ComposedChart
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface UptimeProbe {
|
export interface UptimeProbe {
|
||||||
@@ -36,6 +37,7 @@ export interface UptimeProbe {
|
|||||||
ok: boolean
|
ok: boolean
|
||||||
latency_ms: number | null
|
latency_ms: number | null
|
||||||
checked_at: string
|
checked_at: string
|
||||||
|
provider?: HealthCheckProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UptimePeriodKey = '5D' | '2W' | '1M'
|
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 },
|
{ 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 = {
|
const chartConfig = {
|
||||||
latency: {
|
local: {
|
||||||
label: 'Задержка',
|
label: 'Local',
|
||||||
color: 'var(--chart-1)',
|
color: 'var(--info)',
|
||||||
|
},
|
||||||
|
cloudflare: {
|
||||||
|
label: 'Cloudflare',
|
||||||
|
color: 'var(--warning)',
|
||||||
|
},
|
||||||
|
globalping: {
|
||||||
|
label: 'Globalping',
|
||||||
|
color: 'var(--success)',
|
||||||
},
|
},
|
||||||
} satisfies ChartConfig
|
} satisfies ChartConfig
|
||||||
|
|
||||||
interface ChartPoint {
|
export interface AlignedChartPoint {
|
||||||
period: string
|
period: string
|
||||||
latency: number
|
|
||||||
ok: boolean
|
|
||||||
at: string
|
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[] {
|
function isProviderKey(value: string | undefined): value is UptimeProviderKey {
|
||||||
return [...items]
|
return value === 'local' || value === 'cloudflare' || value === 'globalping'
|
||||||
.sort((a, b) => probeTime(a.checked_at) - probeTime(b.checked_at))
|
}
|
||||||
.map((item) => ({
|
|
||||||
|
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),
|
period: formatDate(item.checked_at),
|
||||||
latency: item.latency_ms ?? 0,
|
|
||||||
ok: item.ok && item.status !== 'down',
|
|
||||||
at: item.checked_at,
|
at: item.checked_at,
|
||||||
status: item.status,
|
ok: true,
|
||||||
}))
|
}
|
||||||
|
buckets.set(start, row)
|
||||||
}
|
}
|
||||||
|
|
||||||
function uptimePercent(points: ChartPoint[]): number | null {
|
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
|
if (points.length === 0) return null
|
||||||
const okCount = points.filter((point) => point.ok).length
|
const okCount = points.filter((point) => point.ok).length
|
||||||
return (okCount / points.length) * 100
|
return (okCount / points.length) * 100
|
||||||
}
|
}
|
||||||
|
|
||||||
function deltaPercent(points: ChartPoint[]): number | null {
|
function deltaPercent(points: AlignedChartPoint[]): number | null {
|
||||||
if (points.length < 4) return null
|
if (points.length < 4) return null
|
||||||
const mid = Math.floor(points.length / 2)
|
const mid = Math.floor(points.length / 2)
|
||||||
const prev = uptimePercent(points.slice(0, mid))
|
const prev = uptimePercent(points.slice(0, mid))
|
||||||
@@ -112,6 +181,29 @@ function UptimeDelta({ delta }: { delta: number }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function probeUptimePercent(items: UptimeProbe[]): number | null {
|
||||||
|
return uptimePercent(toAlignedSeries(items).points)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
interface UptimeChartProps {
|
||||||
items: UptimeProbe[]
|
items: UptimeProbe[]
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
@@ -119,6 +211,8 @@ interface UptimeChartProps {
|
|||||||
onPeriodChange?: (period: UptimePeriodKey) => void
|
onPeriodChange?: (period: UptimePeriodKey) => void
|
||||||
skipPeriodFilter?: boolean
|
skipPeriodFilter?: boolean
|
||||||
embedded?: boolean
|
embedded?: boolean
|
||||||
|
/** dashboard-4: chrome живёт в родительском FrameHeader (переключатель серий). */
|
||||||
|
hideHeader?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UptimeChart({
|
export function UptimeChart({
|
||||||
@@ -128,33 +222,34 @@ export function UptimeChart({
|
|||||||
onPeriodChange,
|
onPeriodChange,
|
||||||
skipPeriodFilter = false,
|
skipPeriodFilter = false,
|
||||||
embedded = false,
|
embedded = false,
|
||||||
|
hideHeader = false,
|
||||||
}: UptimeChartProps) {
|
}: UptimeChartProps) {
|
||||||
const gradientId = useId().replace(/:/g, '')
|
const gradientId = useId().replace(/:/g, '')
|
||||||
const [internalPeriod, setInternalPeriod] = useState<UptimePeriodKey>('5D')
|
const [internalPeriod, setInternalPeriod] = useState<UptimePeriodKey>('5D')
|
||||||
const [tooltipPortal, setTooltipPortal] = useState<HTMLElement | null>(null)
|
const [pinnedIndex, setPinnedIndex] = useState<number | undefined>()
|
||||||
const period = periodProp ?? internalPeriod
|
const period = periodProp ?? internalPeriod
|
||||||
const days = UPTIME_PERIODS.find((entry) => entry.key === period)?.days ?? 5
|
const days = UPTIME_PERIODS.find((entry) => entry.key === period)?.days ?? 5
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setTooltipPortal(document.body)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
function handlePeriodChange(next: UptimePeriodKey) {
|
function handlePeriodChange(next: UptimePeriodKey) {
|
||||||
onPeriodChange?.(next)
|
onPeriodChange?.(next)
|
||||||
if (periodProp == null) setInternalPeriod(next)
|
if (periodProp == null) setInternalPeriod(next)
|
||||||
|
setPinnedIndex(undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
const points = useMemo(
|
const { points, keys } = useMemo(
|
||||||
() => toSeries(skipPeriodFilter ? items : filterByPeriod(items, days)),
|
() => toAlignedSeries(skipPeriodFilter ? items : filterByPeriod(items, days)),
|
||||||
[items, days, skipPeriodFilter],
|
[items, days, skipPeriodFilter],
|
||||||
)
|
)
|
||||||
const uptime = uptimePercent(points)
|
const uptime = uptimePercent(points)
|
||||||
const delta = deltaPercent(points)
|
const delta = deltaPercent(points)
|
||||||
const lastOk = points.at(-1)?.ok ?? true
|
const lastOk = points.at(-1)?.ok ?? true
|
||||||
const tileClass = lastOk ? 'text-success' : 'text-destructive'
|
const tileClass = lastOk ? 'text-success' : 'text-destructive'
|
||||||
|
const single = keys.length <= 1
|
||||||
|
const areaKey = keys[0] ?? 'local'
|
||||||
|
|
||||||
const panel = (
|
const panel = (
|
||||||
<FramePanel className="flex flex-col gap-6">
|
<FramePanel className="flex flex-col gap-6">
|
||||||
|
{hideHeader ? null : (
|
||||||
<div className="border-border flex items-center justify-between gap-2 border-b border-dashed pb-4">
|
<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">
|
<div className="flex items-center gap-2.5">
|
||||||
<IconTile
|
<IconTile
|
||||||
@@ -192,6 +287,7 @@ export function UptimeChart({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="bg-muted h-40 w-full animate-pulse rounded-xl" />
|
<div className="bg-muted h-40 w-full animate-pulse rounded-xl" />
|
||||||
@@ -207,7 +303,7 @@ export function UptimeChart({
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="text-foreground text-3xl font-semibold tabular-nums">
|
<div className="text-foreground text-3xl font-semibold tabular-nums">
|
||||||
{uptime == null ? '—' : `${uptime.toFixed(uptime >= 99.95 ? 2 : 1)}%`}
|
{formatUptime(uptime)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
{delta == null ? (
|
{delta == null ? (
|
||||||
@@ -226,42 +322,60 @@ export function UptimeChart({
|
|||||||
className="h-full w-full overflow-visible rounded-b-xl"
|
className="h-full w-full overflow-visible rounded-b-xl"
|
||||||
initialDimension={{ width: 320, height: 160 }}
|
initialDimension={{ width: 320, height: 160 }}
|
||||||
>
|
>
|
||||||
<AreaChart
|
<ComposedChart
|
||||||
data={points}
|
data={points}
|
||||||
margin={{ top: 16, left: 8, right: 8, bottom: 4 }}
|
margin={{ top: 16, left: 8, right: 8, bottom: 4 }}
|
||||||
|
accessibilityLayer
|
||||||
|
onClick={(state) => {
|
||||||
|
const index = state.activeTooltipIndex ?? state.activeIndex
|
||||||
|
if (index == null) return
|
||||||
|
const next = Number(index)
|
||||||
|
if (Number.isFinite(next)) setPinnedIndex(next)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop
|
<stop
|
||||||
offset="5%"
|
offset="5%"
|
||||||
stopColor="var(--color-latency)"
|
stopColor={`var(--color-${areaKey})`}
|
||||||
stopOpacity={0.8}
|
stopOpacity={0.8}
|
||||||
/>
|
/>
|
||||||
<stop
|
<stop
|
||||||
offset="95%"
|
offset="95%"
|
||||||
stopColor="var(--color-latency)"
|
stopColor={`var(--color-${areaKey})`}
|
||||||
stopOpacity={0.1}
|
stopOpacity={0.1}
|
||||||
/>
|
/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<XAxis dataKey="period" hide />
|
<XAxis dataKey="period" hide />
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
|
key={pinnedIndex ?? 'hover'}
|
||||||
cursor={{ stroke: 'var(--border)', strokeDasharray: '4 4' }}
|
cursor={{ stroke: 'var(--border)', strokeDasharray: '4 4' }}
|
||||||
allowEscapeViewBox={{ x: true, y: true }}
|
filterNull={false}
|
||||||
portal={tooltipPortal ?? undefined}
|
defaultIndex={pinnedIndex}
|
||||||
|
active={pinnedIndex != null ? true : undefined}
|
||||||
wrapperStyle={{ zIndex: 50, pointerEvents: 'none' }}
|
wrapperStyle={{ zIndex: 50, pointerEvents: 'none' }}
|
||||||
content={
|
content={
|
||||||
<ChartTooltipContent
|
<ChartTooltipContent
|
||||||
formatter={(value, _name, item) => {
|
labelFormatter={(_label, payload) => {
|
||||||
const point = item.payload as ChartPoint | undefined
|
const at = (payload?.[0]?.payload as AlignedChartPoint | undefined)?.at
|
||||||
const ping = Number(value)
|
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 (
|
return (
|
||||||
<div className="flex flex-1 items-center justify-between gap-4">
|
<div className="flex flex-1 items-center justify-between gap-4">
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{point?.ok === false ? 'Down' : 'Пинг'}
|
{ok === false ? `${label} · Down` : `Пинг · ${label}`}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||||
{Number.isFinite(ping) ? `${ping} мс` : '—'}
|
{formatPing(value, ok)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -269,42 +383,43 @@ export function UptimeChart({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{single ? (
|
||||||
<Area
|
<Area
|
||||||
dataKey="latency"
|
dataKey={areaKey}
|
||||||
name="latency"
|
name={areaKey}
|
||||||
type="natural"
|
type="monotone"
|
||||||
fill={`url(#${gradientId})`}
|
fill={`url(#${gradientId})`}
|
||||||
stroke="var(--color-latency)"
|
stroke={`var(--color-${areaKey})`}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
|
connectNulls={false}
|
||||||
isAnimationActive={false}
|
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={{
|
activeDot={{
|
||||||
r: 6,
|
r: 6,
|
||||||
stroke: 'var(--background)',
|
stroke: 'var(--background)',
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
) : (
|
||||||
|
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>
|
</ChartContainer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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: '/' }]
|
||||||
|
}
|
||||||
@@ -28,7 +28,6 @@ import {
|
|||||||
} from '@/components/services/service-detail-grid'
|
} from '@/components/services/service-detail-grid'
|
||||||
import { LbModeTile } from '@/components/services/service-unit-card'
|
import { LbModeTile } from '@/components/services/service-unit-card'
|
||||||
import {
|
import {
|
||||||
HealthProviderStatusTiles,
|
|
||||||
KpiStatGrid,
|
KpiStatGrid,
|
||||||
ServiceHealthMonitor,
|
ServiceHealthMonitor,
|
||||||
} from '@/components/reui-kit'
|
} from '@/components/reui-kit'
|
||||||
@@ -45,7 +44,6 @@ import {
|
|||||||
providerHealthStatuses,
|
providerHealthStatuses,
|
||||||
} from '@/lib/health-log'
|
} from '@/lib/health-log'
|
||||||
import type { ServiceView, UpdateServiceConfigInput } from '@/lib/schemas'
|
import type { ServiceView, UpdateServiceConfigInput } from '@/lib/schemas'
|
||||||
import type { HealthCheckProvider } from '@cfdm/shared'
|
|
||||||
import {
|
import {
|
||||||
createServiceNode,
|
createServiceNode,
|
||||||
deleteServiceNode,
|
deleteServiceNode,
|
||||||
@@ -112,9 +110,6 @@ function ServiceDetailPage() {
|
|||||||
|
|
||||||
const [editOpen, setEditOpen] = useState(false)
|
const [editOpen, setEditOpen] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [selectedProviders, setSelectedProviders] = useState<
|
|
||||||
HealthCheckProvider[] | null
|
|
||||||
>(null)
|
|
||||||
const [togglingIp, setTogglingIp] = useState<string | null>(null)
|
const [togglingIp, setTogglingIp] = useState<string | null>(null)
|
||||||
const [changeIp, setChangeIp] = useState<{
|
const [changeIp, setChangeIp] = useState<{
|
||||||
bindingId: number
|
bindingId: number
|
||||||
@@ -234,11 +229,6 @@ function ServiceDetailPage() {
|
|||||||
() => enabledHealthProviders(service?.domains ?? []),
|
() => enabledHealthProviders(service?.domains ?? []),
|
||||||
[service],
|
[service],
|
||||||
)
|
)
|
||||||
const activeProviders =
|
|
||||||
selectedProviders?.filter((provider) => enabledProviders.includes(provider)) ??
|
|
||||||
enabledProviders
|
|
||||||
const effectiveProviders =
|
|
||||||
activeProviders.length > 0 ? activeProviders : enabledProviders
|
|
||||||
const providerStatuses = useMemo(
|
const providerStatuses = useMemo(
|
||||||
() => providerHealthStatuses(logItems, enabledProviders),
|
() => providerHealthStatuses(logItems, enabledProviders),
|
||||||
[logItems, enabledProviders],
|
[logItems, enabledProviders],
|
||||||
@@ -333,20 +323,14 @@ function ServiceDetailPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<HealthProviderStatusTiles
|
|
||||||
enabled={enabledProviders}
|
|
||||||
selected={effectiveProviders}
|
|
||||||
statuses={providerStatuses}
|
|
||||||
onChange={setSelectedProviders}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section
|
<section
|
||||||
aria-label="Мониторинг"
|
aria-label="Мониторинг"
|
||||||
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
|
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
|
||||||
>
|
>
|
||||||
<ServiceHealthMonitor
|
<ServiceHealthMonitor
|
||||||
items={logItems}
|
items={logItems}
|
||||||
selectedProviders={effectiveProviders}
|
enabledProviders={enabledProviders}
|
||||||
|
statuses={providerStatuses}
|
||||||
isLoading={logQuery.isLoading}
|
isLoading={logQuery.isLoading}
|
||||||
/>
|
/>
|
||||||
<Frame dense spacing="sm" className="min-w-0 w-full">
|
<Frame dense spacing="sm" className="min-w-0 w-full">
|
||||||
|
|||||||
Vendored
+53
-9
@@ -2217,6 +2217,40 @@ function pruneStaleIpHealthStatus(db, activeTargets) {
|
|||||||
}
|
}
|
||||||
return deleted;
|
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) {
|
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 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`
|
const bindingTargets = db.all(sql2`
|
||||||
@@ -2235,7 +2269,7 @@ function listHealthCheckTargets(db) {
|
|||||||
JOIN service_bindings sb ON sb.id = sbi.binding_id
|
JOIN service_bindings sb ON sb.id = sbi.binding_id
|
||||||
JOIN domains d ON d.id = sb.domain_id
|
JOIN domains d ON d.id = sb.domain_id
|
||||||
WHERE sb.health_check_enabled = 1
|
WHERE sb.health_check_enabled = 1
|
||||||
`);
|
`).filter((t) => isIpLiteral(t.ip));
|
||||||
const groupTargets = db.all(sql2`
|
const groupTargets = db.all(sql2`
|
||||||
SELECT DISTINCT 'group' AS scope, sg.id AS ref_id, sbi.ip,
|
SELECT DISTINCT 'group' AS scope, sg.id AS ref_id, sbi.ip,
|
||||||
sg.domain AS hostname,
|
sg.domain AS hostname,
|
||||||
@@ -2258,7 +2292,7 @@ function listHealthCheckTargets(db) {
|
|||||||
AND s.enabled = 1
|
AND s.enabled = 1
|
||||||
AND sg.enabled = 1
|
AND sg.enabled = 1
|
||||||
AND (sb.cname_target IS NULL OR sb.cname_target = '')
|
AND (sb.cname_target IS NULL OR sb.cname_target = '')
|
||||||
`);
|
`).filter((t) => isIpLiteral(t.ip));
|
||||||
const groupInheritedBindingTargets = db.all(sql2`
|
const groupInheritedBindingTargets = db.all(sql2`
|
||||||
SELECT 'binding' AS scope, sb.id AS ref_id, sbi.ip,
|
SELECT 'binding' AS scope, sb.id AS ref_id, sbi.ip,
|
||||||
${fqdnExpr} AS hostname,
|
${fqdnExpr} AS hostname,
|
||||||
@@ -2281,8 +2315,10 @@ function listHealthCheckTargets(db) {
|
|||||||
AND s.enabled = 1
|
AND s.enabled = 1
|
||||||
AND sg.enabled = 1
|
AND sg.enabled = 1
|
||||||
AND sb.health_check_enabled = 0
|
AND sb.health_check_enabled = 0
|
||||||
`);
|
`).filter((t) => isIpLiteral(t.ip));
|
||||||
const cnameBindingTargets = db.all(sql2`
|
const cnameBindingTargets = expandCnameHealthTargets(
|
||||||
|
db,
|
||||||
|
db.all(sql2`
|
||||||
SELECT 'binding' AS scope, sb.id AS ref_id, sb.cname_target AS ip,
|
SELECT 'binding' AS scope, sb.id AS ref_id, sb.cname_target AS ip,
|
||||||
${fqdnExpr} AS hostname,
|
${fqdnExpr} AS hostname,
|
||||||
sb.health_check_type AS type,
|
sb.health_check_type AS type,
|
||||||
@@ -2293,7 +2329,9 @@ function listHealthCheckTargets(db) {
|
|||||||
sb.health_check_verify_tls AS verify_tls,
|
sb.health_check_verify_tls AS verify_tls,
|
||||||
sb.health_check_providers AS providers_json,
|
sb.health_check_providers AS providers_json,
|
||||||
COALESCE(sb.health_check_aggregate, 'majority') AS aggregate,
|
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
|
FROM service_bindings sb
|
||||||
JOIN domains d ON d.id = sb.domain_id
|
JOIN domains d ON d.id = sb.domain_id
|
||||||
JOIN services s ON s.id = sb.service_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 IS NOT NULL
|
||||||
AND sb.cname_target <> ''
|
AND sb.cname_target <> ''
|
||||||
AND s.enabled = 1
|
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,
|
SELECT 'binding' AS scope, sb.id AS ref_id, sb.cname_target AS ip,
|
||||||
${fqdnExpr} AS hostname,
|
${fqdnExpr} AS hostname,
|
||||||
sg.health_check_type AS type,
|
sg.health_check_type AS type,
|
||||||
@@ -2313,7 +2354,9 @@ function listHealthCheckTargets(db) {
|
|||||||
sg.health_check_verify_tls AS verify_tls,
|
sg.health_check_verify_tls AS verify_tls,
|
||||||
sg.health_check_providers AS providers_json,
|
sg.health_check_providers AS providers_json,
|
||||||
COALESCE(sg.health_check_aggregate, 'majority') AS aggregate,
|
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
|
FROM service_bindings sb
|
||||||
JOIN domains d ON d.id = sb.domain_id
|
JOIN domains d ON d.id = sb.domain_id
|
||||||
JOIN services s ON s.id = sb.service_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.health_check_enabled = 0
|
||||||
AND sb.cname_target IS NOT NULL
|
AND sb.cname_target IS NOT NULL
|
||||||
AND sb.cname_target <> ''
|
AND sb.cname_target <> ''
|
||||||
`);
|
`)
|
||||||
|
);
|
||||||
return [
|
return [
|
||||||
...bindingTargets,
|
...bindingTargets,
|
||||||
...groupTargets,
|
...groupTargets,
|
||||||
|
|||||||
+87
-13
@@ -2493,13 +2493,73 @@ export function pruneStaleIpHealthStatus(
|
|||||||
|
|
||||||
// --- Health Check Targets ---
|
// --- 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[] {
|
export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
|
||||||
// FQDN for a binding: "@" => zone_name, else "<hostname>.<zone_name>".
|
// 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
|
// 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").
|
// (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 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,
|
SELECT 'binding' AS scope, sb.id AS ref_id, sbi.ip,
|
||||||
${fqdnExpr} AS hostname,
|
${fqdnExpr} AS hostname,
|
||||||
sb.health_check_type AS type,
|
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 service_bindings sb ON sb.id = sbi.binding_id
|
||||||
JOIN domains d ON d.id = sb.domain_id
|
JOIN domains d ON d.id = sb.domain_id
|
||||||
WHERE sb.health_check_enabled = 1
|
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
|
// 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
|
// enabled services) — NOT the full service_ips pool. Pool-only dead IPs must
|
||||||
// not mark the group Down while the published domain stays healthy.
|
// 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,
|
SELECT DISTINCT 'group' AS scope, sg.id AS ref_id, sbi.ip,
|
||||||
sg.domain AS hostname,
|
sg.domain AS hostname,
|
||||||
sg.health_check_type AS type,
|
sg.health_check_type AS type,
|
||||||
@@ -2542,13 +2604,14 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
|
|||||||
AND s.enabled = 1
|
AND s.enabled = 1
|
||||||
AND sg.enabled = 1
|
AND sg.enabled = 1
|
||||||
AND (sb.cname_target IS NULL OR sb.cname_target = '')
|
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.
|
// 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),
|
// 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
|
// so per-domain badges reflect group rules. Skipped for bindings that already have
|
||||||
// their own health_check_enabled=1 (covered by bindingTargets above).
|
// 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,
|
SELECT 'binding' AS scope, sb.id AS ref_id, sbi.ip,
|
||||||
${fqdnExpr} AS hostname,
|
${fqdnExpr} AS hostname,
|
||||||
sg.health_check_type AS type,
|
sg.health_check_type AS type,
|
||||||
@@ -2570,10 +2633,13 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
|
|||||||
AND s.enabled = 1
|
AND s.enabled = 1
|
||||||
AND sg.enabled = 1
|
AND sg.enabled = 1
|
||||||
AND sb.health_check_enabled = 0
|
AND sb.health_check_enabled = 0
|
||||||
`);
|
`)
|
||||||
|
.filter((t) => isIpLiteral(t.ip));
|
||||||
|
|
||||||
// CNAME-bindings with their own health_check_enabled: probe the CNAME target host.
|
// CNAME-bindings: unwrap to origin/service IPs so ip_health keys match the IP table.
|
||||||
const cnameBindingTargets = db.all<HealthCheckTarget>(sql`
|
const cnameBindingTargets = expandCnameHealthTargets(
|
||||||
|
db,
|
||||||
|
db.all<RawHealthTarget>(sql`
|
||||||
SELECT 'binding' AS scope, sb.id AS ref_id, sb.cname_target AS ip,
|
SELECT 'binding' AS scope, sb.id AS ref_id, sb.cname_target AS ip,
|
||||||
${fqdnExpr} AS hostname,
|
${fqdnExpr} AS hostname,
|
||||||
sb.health_check_type AS type,
|
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_verify_tls AS verify_tls,
|
||||||
sb.health_check_providers AS providers_json,
|
sb.health_check_providers AS providers_json,
|
||||||
COALESCE(sb.health_check_aggregate, 'majority') AS aggregate,
|
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
|
FROM service_bindings sb
|
||||||
JOIN domains d ON d.id = sb.domain_id
|
JOIN domains d ON d.id = sb.domain_id
|
||||||
JOIN services s ON s.id = sb.service_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 IS NOT NULL
|
||||||
AND sb.cname_target <> ''
|
AND sb.cname_target <> ''
|
||||||
AND s.enabled = 1
|
AND s.enabled = 1
|
||||||
`);
|
`),
|
||||||
|
);
|
||||||
|
|
||||||
// CNAME-bindings of services in a group with group health-check enabled
|
// CNAME-bindings of services in a group with group health-check enabled
|
||||||
// (inherit group config). Only for bindings without their own 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,
|
SELECT 'binding' AS scope, sb.id AS ref_id, sb.cname_target AS ip,
|
||||||
${fqdnExpr} AS hostname,
|
${fqdnExpr} AS hostname,
|
||||||
sg.health_check_type AS type,
|
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_verify_tls AS verify_tls,
|
||||||
sg.health_check_providers AS providers_json,
|
sg.health_check_providers AS providers_json,
|
||||||
COALESCE(sg.health_check_aggregate, 'majority') AS aggregate,
|
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
|
FROM service_bindings sb
|
||||||
JOIN domains d ON d.id = sb.domain_id
|
JOIN domains d ON d.id = sb.domain_id
|
||||||
JOIN services s ON s.id = sb.service_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.health_check_enabled = 0
|
||||||
AND sb.cname_target IS NOT NULL
|
AND sb.cname_target IS NOT NULL
|
||||||
AND sb.cname_target <> ''
|
AND sb.cname_target <> ''
|
||||||
`);
|
`),
|
||||||
|
);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...bindingTargets,
|
...bindingTargets,
|
||||||
|
|||||||
Reference in New Issue
Block a user