fix(health): не показывать Down при выключенном health-check
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 4s
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / web (push) Successful in 54s
quality / api (push) Successful in 49s
CD / quality (push) Successful in 1m58s
CD / publish (push) Successful in 1m25s

Маскируем устаревший down, если HC выкл (binding/группа), IP или сервис отключены.
Бейдж Disabled — нейтральный, не красный.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-09-03 20:46:37 +07:00
co-authored by Cursor
parent 75575a3243
commit b078fa0a03
5 changed files with 130 additions and 7 deletions
@@ -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,
};
});
+73
View File
@@ -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");
});
});
@@ -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
}
@@ -133,6 +133,7 @@ const VISIBLE_IP_LIMIT = 6
interface ServiceIpListProps {
ips: string[]
ipHealth?: ServiceView['ip_health']
healthCheckEnabled?: boolean
ipEnabled?: Record<string, boolean>
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 (
<Item
key={ip}
@@ -192,7 +198,7 @@ export function ServiceIpList({
>
<ItemMedia>
<HealthCheckBadge
status={health?.status ?? 'unknown'}
status={badgeStatus}
latencyMs={health?.latency_ms}
lastCheckedAt={health?.last_checked_at}
lastError={health?.last_error}
@@ -273,6 +273,10 @@ export function ServiceUnitCard({
alignWithMenu
ips={service.ips ?? []}
ipHealth={service.ip_health ?? []}
healthCheckEnabled={
service.enabled &&
(service.domains ?? []).some((domain) => domain.health_check_enabled)
}
ipEnabled={service.ip_enabled ?? {}}
ipToggleDisabled={togglingId === service.id}
togglingIp={togglingIp}