diff --git a/apps/api/src/services/service-config-service.ts b/apps/api/src/services/service-config-service.ts index dee5480..8c94337 100644 --- a/apps/api/src/services/service-config-service.ts +++ b/apps/api/src/services/service-config-service.ts @@ -546,6 +546,34 @@ function bestAliveDisplayStatus(statuses: readonly string[]): IpHealthState { return "unknown"; } +/** Health badge applies only when the service, IP and HC (binding or group) are active. */ +function isServiceHealthCheckActive(db: Db, view: ServiceView): boolean { + if ((view.domains ?? []).some((domain) => domain.health_check_enabled)) { + return true; + } + if (!view.service_group_id) return false; + const group = repos.getServiceGroup(db, view.service_group_id); + return Boolean(group.health_check_enabled); +} + +function isIpHealthMonitored(db: Db, view: ServiceView, ip: string): boolean { + if (!view.enabled) return false; + if (view.ip_enabled[ip] === false) return false; + return isServiceHealthCheckActive(db, view); +} + +function inactiveIpHealthRow(ip: string): ServiceHealthRow { + return { + ip, + status: "unknown", + latency_ms: null, + last_checked_at: null, + last_error: null, + provider: "local", + colo: null, + }; +} + function attachServiceHealth( db: Db, views: ServiceView[], @@ -567,6 +595,9 @@ function attachServiceHealth( ), ); const ip_health = (view.ips ?? []).map((ip) => { + if (!isIpHealthMonitored(db, view, ip)) { + return inactiveIpHealthRow(ip); + } const row = byIp.get(ip) ?? (aRecordIps.has(ip) ? undefined : cnameFallback); const live = liveByIp.get(ip); const status = overlayLiveHealth(row?.status, live?.status); @@ -584,17 +615,26 @@ function attachServiceHealth( colo: extras?.colo ?? null, }; }); - const displayStatus = bestAliveDisplayStatus(ip_health.map((row) => row.status)); + const monitoredStatuses = ip_health + .filter((row) => isIpHealthMonitored(db, view, row.ip)) + .map((row) => row.status); + const displayStatus = + monitoredStatuses.length > 0 + ? bestAliveDisplayStatus(monitoredStatuses) + : ("unknown" as const); const latencyRow = ip_health.find((row) => row.status === displayStatus && row.latency_ms != null) ?? ip_health.find((row) => row.latency_ms != null); return { ...view, - health_status: overlayLiveHealth(health?.health_status, displayStatus), + health_status: + monitoredStatuses.length > 0 + ? overlayLiveHealth(health?.health_status, displayStatus) + : "unknown", health_latency_ms: - displayStatus !== "unknown" + monitoredStatuses.length > 0 && displayStatus !== "unknown" ? (latencyRow?.latency_ms ?? null) - : (health?.health_latency_ms ?? null), + : null, ip_health, }; }); diff --git a/apps/api/test/health-check.test.ts b/apps/api/test/health-check.test.ts index 9e4cf40..3662c9c 100644 --- a/apps/api/test/health-check.test.ts +++ b/apps/api/test/health-check.test.ts @@ -376,6 +376,7 @@ describe("CNAME health mapped onto service IPs", () => { 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 }); repos.upsertIpHealthStatus( db, "binding", @@ -412,6 +413,7 @@ describe("CNAME health mapped onto service IPs", () => { { ip: "10.0.0.1", weight: 1, priority: 1 }, { ip: "10.0.0.2", weight: 1, priority: 1 }, ]); + repos.updateBindingLbConfig(db, binding.id, { health_check_enabled: true }); repos.upsertIpHealthStatus( db, "binding", @@ -450,6 +452,7 @@ describe("CNAME health mapped onto service IPs", () => { repos.replaceBindingIpsWithMeta(db, binding.id, [ { ip: "2.59.161.102", weight: 1, priority: 1 }, ]); + repos.updateBindingLbConfig(db, binding.id, { health_check_enabled: true }); repos.upsertIpHealthStatus( db, "binding", @@ -478,4 +481,74 @@ describe("CNAME health mapped onto service IPs", () => { expect(view.ip_health[0]?.status).toBe("up"); expect(view.ip_health[0]?.latency_ms).toBe(63); }); + + it("getView masks stale down when health-check is disabled", 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, "Main TG", "main-tg"); + repos.setServiceEnabled(db, service.id, true); + repos.replaceServiceIps(db, service.id, ["130.49.213.176"]); + const binding = repos.insertBinding(db, domain.id, service.id, "gt", null); + repos.replaceBindingIpsWithMeta(db, binding.id, [ + { ip: "130.49.213.176", weight: 1, priority: 1 }, + ]); + repos.updateBindingLbConfig(db, binding.id, { health_check_enabled: false }); + repos.upsertIpHealthStatus( + db, + "binding", + binding.id, + "130.49.213.176", + "down", + null, + 5, + "timeout", + ); + + const view = await getView(db, service.id); + expect(view.health_status).toBe("unknown"); + expect(view.ip_health).toEqual([ + expect.objectContaining({ + ip: "130.49.213.176", + status: "unknown", + latency_ms: null, + last_error: null, + }), + ]); + }); + + it("getView masks stale down when IP is disabled in pool", 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, "Main TG", "main-tg"); + repos.setServiceEnabled(db, service.id, true); + repos.replaceServiceIps(db, service.id, ["130.49.213.176"]); + repos.setServiceIpEnabled(db, service.id, "130.49.213.176", false); + const binding = repos.insertBinding(db, domain.id, service.id, "gt", null); + repos.replaceBindingIpsWithMeta(db, binding.id, [ + { ip: "130.49.213.176", weight: 1, priority: 1 }, + ]); + repos.updateBindingLbConfig(db, binding.id, { health_check_enabled: true }); + repos.upsertIpHealthStatus( + db, + "binding", + binding.id, + "130.49.213.176", + "down", + null, + 5, + "timeout", + ); + + const view = await getView(db, service.id); + expect(view.health_status).toBe("unknown"); + expect(view.ip_health[0]?.status).toBe("unknown"); + }); }); diff --git a/apps/web/src/components/health-check-badge.tsx b/apps/web/src/components/health-check-badge.tsx index cd8a3ee..66ef21b 100644 --- a/apps/web/src/components/health-check-badge.tsx +++ b/apps/web/src/components/health-check-badge.tsx @@ -16,8 +16,8 @@ type HealthStatus = function normalizeHealth(status: HealthStatus): IpHealthStatus['status'] { if (status === 'healthy') return 'up' - if (status === 'unhealthy' || status === 'disabled') return 'down' - if (status === 'checking') return 'unknown' + if (status === 'unhealthy') return 'down' + if (status === 'disabled' || status === 'checking') return 'unknown' return status } diff --git a/apps/web/src/components/services/service-fqdn-list.tsx b/apps/web/src/components/services/service-fqdn-list.tsx index 70b2172..1dc4221 100644 --- a/apps/web/src/components/services/service-fqdn-list.tsx +++ b/apps/web/src/components/services/service-fqdn-list.tsx @@ -133,6 +133,7 @@ const VISIBLE_IP_LIMIT = 6 interface ServiceIpListProps { ips: string[] ipHealth?: ServiceView['ip_health'] + healthCheckEnabled?: boolean ipEnabled?: Record togglingIp?: string | null ipToggleDisabled?: boolean @@ -151,6 +152,7 @@ interface ServiceIpListProps { export function ServiceIpList({ ips, ipHealth = [], + healthCheckEnabled = true, ipEnabled = {}, togglingIp = null, ipToggleDisabled = false, @@ -184,6 +186,10 @@ export function ServiceIpList({ {visible.map((ip) => { const health = healthByIp.get(ip) const enabled = ipEnabled[ip] !== false + const monitored = healthCheckEnabled && enabled + const badgeStatus = monitored + ? (health?.status ?? 'unknown') + : 'disabled' return ( domain.health_check_enabled) + } ipEnabled={service.ip_enabled ?? {}} ipToggleDisabled={togglingId === service.id} togglingIp={togglingIp}