From f1443f1db59dd585c645336ad83640fd79d10837 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 20 Aug 2026 18:13:06 +0700 Subject: [PATCH] =?UTF-8?q?fix(services):=20=D0=BD=D0=B5=20=D0=B1=D0=B0?= =?UTF-8?q?=D0=BB=D0=B0=D0=BD=D1=81=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20=D1=81=D0=B5=D1=80=D0=B2=D0=B8=D1=81=20=D1=81=20=D0=BE?= =?UTF-8?q?=D0=B4=D0=BD=D0=B8=D0=BC=20IP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KPI и карточка показывают живой OK, а не гистерезис unknown; DNS по-прежнему ждёт повторные успехи. Co-authored-by: Cursor --- apps/api/src/services/node-service.ts | 25 +++- apps/api/src/services/routing/index.ts | 13 ++- apps/api/src/services/routing/pool.ts | 16 ++- .../src/services/service-config-service.ts | 93 ++++++++++++--- apps/api/test/health-check.test.ts | 42 +++++++ apps/api/test/lb-reconcile.test.ts | 40 +++++++ apps/web/src/lib/failover-events.test.ts | 59 +++++++--- apps/web/src/lib/failover-events.ts | 26 +++-- apps/web/src/lib/health-log.test.ts | 20 ++++ apps/web/src/lib/health-log.ts | 19 ++++ apps/web/src/queries/services.ts | 6 + .../_auth/services/$serviceId/index.tsx | 70 ++++++++---- packages/db/src/repos.ts | 107 ++++++++++++++++++ 13 files changed, 464 insertions(+), 72 deletions(-) diff --git a/apps/api/src/services/node-service.ts b/apps/api/src/services/node-service.ts index a57ba22..3174035 100644 --- a/apps/api/src/services/node-service.ts +++ b/apps/api/src/services/node-service.ts @@ -9,7 +9,10 @@ import type { import { AppError } from "../errors.js"; import { isValidIpv4 } from "../lib/validators.js"; import { getView } from "./service-config-service.js"; -import { selectActiveIpsByMode } from "./routing/index.js"; +import { + isSharedPool, + resolveDesiredAIps, +} from "./routing/index.js"; function assertAddress(address: string): void { if (!isValidIpv4(address)) { @@ -89,8 +92,12 @@ export async function getOverview( : null; const active = new Set(); + const serviceIps = service.ips.filter( + (ip) => service.ip_enabled[ip] !== false, + ); for (const binding of bindings) { const metas = repos.listBindingIpsWithMeta(db, binding.id); + const targetIps = metas.map((entry) => entry.ip); const rows = metas.map((entry) => { const status = repos.getIpHealthStatusRow(db, "binding", binding.id, entry.ip); return { @@ -100,12 +107,15 @@ export async function getOverview( health: status ? status.status : ("unknown" as const), }; }); - for (const ip of selectActiveIpsByMode( + for (const ip of resolveDesiredAIps( { lb_mode: binding.lb_mode, health_check_enabled: binding.health_check_enabled, }, rows, + targetIps, + Date.now(), + serviceIps, )) { active.add(ip); } @@ -134,10 +144,13 @@ export function opsSummary(db: Db) { if (binding.lb_mode !== "failover" || !binding.health_check_enabled) { return false; } - return binding.target_ips.some((ip) => { - const row = repos.getIpHealthStatusRow(db, "binding", binding.id, ip); - return row?.status === "down"; - }); + return ( + isSharedPool(binding.target_ips) && + binding.target_ips.some((ip) => { + const row = repos.getIpHealthStatusRow(db, "binding", binding.id, ip); + return row?.status === "down"; + }) + ); }).length; return { domains: domains.length, diff --git a/apps/api/src/services/routing/index.ts b/apps/api/src/services/routing/index.ts index 0167728..0028888 100644 --- a/apps/api/src/services/routing/index.ts +++ b/apps/api/src/services/routing/index.ts @@ -1,6 +1,6 @@ import type { LbMode } from "@cfdm/shared"; import { failoverDesired } from "./failover.js"; -import { isSharedPool } from "./pool.js"; +import { canApplyLb, isSharedPool } from "./pool.js"; import { roundRobinDesired } from "./round-robin.js"; import type { LbIpRow, LbTargetConfig } from "./types.js"; import { weightedDesired } from "./weighted.js"; @@ -8,7 +8,12 @@ import { weightedDesired } from "./weighted.js"; export type { LbIpRow, LbTargetConfig } from "./types.js"; export { isHealthy } from "./health.js"; export { withBindingLock } from "./binding-lock.js"; -export { isSharedPool, shouldRecordFailoverDnsDiff } from "./pool.js"; +export { + canApplyLb, + isSharedPool, + shouldRecordFailoverDnsDiff, + uniqueIpCount, +} from "./pool.js"; export { WEIGHTED_DNS_TTL, WEIGHTED_SLOT_MS, weightedDesired } from "./weighted.js"; export function selectActiveIpsByMode( @@ -31,9 +36,11 @@ export function resolveDesiredAIps( rows: LbIpRow[], fallbackIps: readonly string[], nowMs = Date.now(), + serviceIps: readonly string[] = fallbackIps, ): string[] { const fallback = [...fallbackIps]; - if (!isSharedPool(fallback)) return fallback; + if (!canApplyLb(serviceIps, fallback)) return fallback; + if (!isSharedPool(rows.map((row) => row.ip))) return fallback; if (config.lb_mode === "weighted" || config.health_check_enabled) { const activeIps = selectActiveIpsByMode(config, rows, nowMs); if (activeIps.length > 0) return activeIps; diff --git a/apps/api/src/services/routing/pool.ts b/apps/api/src/services/routing/pool.ts index c300577..95892b1 100644 --- a/apps/api/src/services/routing/pool.ts +++ b/apps/api/src/services/routing/pool.ts @@ -1,8 +1,20 @@ import type { LbMode } from "@cfdm/shared"; -/** Shared pool FQDN — two or more A targets. Dedicated extra-FQDN has one IP. */ +export function uniqueIpCount(ips: readonly string[]): number { + return new Set(ips.filter(Boolean)).size; +} + +/** Shared pool — two or more unique IPs. One IP (even duplicated) is not a pool. */ export function isSharedPool(ips: readonly string[]): boolean { - return ips.length >= 2; + return uniqueIpCount(ips) >= 2; +} + +/** LB / drain only when the service itself has a pool AND this FQDN is shared. */ +export function canApplyLb( + serviceIps: readonly string[], + bindingIps: readonly string[], +): boolean { + return isSharedPool(serviceIps) && isSharedPool(bindingIps); } export function shouldRecordFailoverDnsDiff(input: { diff --git a/apps/api/src/services/service-config-service.ts b/apps/api/src/services/service-config-service.ts index 16e1b53..6592283 100644 --- a/apps/api/src/services/service-config-service.ts +++ b/apps/api/src/services/service-config-service.ts @@ -29,6 +29,7 @@ import * as domainService from "./domain-service.js"; import { syncServiceToVpsTracker } from "./vps-tracker-sync.js"; import { fireEnsureHealthWorker, DEFAULT_HEALTH_FALLBACKS } from "./health/health-worker-deploy.js"; import { + canApplyLb, isHealthy, isSharedPool, resolveDesiredAIps, @@ -41,12 +42,24 @@ import { } from "./routing/index.js"; export type { LbIpRow, LbTargetConfig }; -export { resolveDesiredAIps, selectActiveIpsByMode, shouldRecordFailoverDnsDiff }; +export { + canApplyLb, + resolveDesiredAIps, + selectActiveIpsByMode, + shouldRecordFailoverDnsDiff, +}; const AUTO_DNS_TTL = 1; -function ttlForBinding(mode: LbMode, ipCount: number): number { - return mode === "weighted" && ipCount >= 2 ? WEIGHTED_DNS_TTL : AUTO_DNS_TTL; +function ttlForBinding(mode: LbMode, ips: readonly string[]): number { + return mode === "weighted" && isSharedPool(ips) ? WEIGHTED_DNS_TTL : AUTO_DNS_TTL; +} + +function enabledServiceIps(db: Db, serviceId: number): string[] { + return repos + .listServiceIpRows(db, serviceId) + .filter((row) => row.enabled) + .map((row) => row.ip); } export function failoverARecordDiff( @@ -300,7 +313,17 @@ function desiredAIps( scope === "binding" ? getBindingLbState(db, refId) : getGroupLbState(db, refId); - return resolveDesiredAIps(state.config, state.rows, fallbackIps); + const serviceIps = + scope === "binding" + ? enabledServiceIps(db, repos.getBinding(db, refId).service_id) + : fallbackIps; + return resolveDesiredAIps( + state.config, + state.rows, + fallbackIps, + Date.now(), + serviceIps, + ); } async function collectKnownZones( @@ -352,7 +375,7 @@ async function buildView(db: Db, serviceId: number): Promise { const { config, rows } = getBindingLbState(db, binding.id); const bindingActiveIps = targetCname ? [] - : resolveDesiredAIps(config, rows, targetIps); + : resolveDesiredAIps(config, rows, targetIps, Date.now(), ips); return { binding_id: binding.id, @@ -473,6 +496,21 @@ function fallbackCnameHealth( ); } +function overlayLiveHealth( + stored: IpHealthState | undefined, + live: IpHealthState | undefined, +): IpHealthState { + if (live && live !== "unknown") return live; + return stored ?? live ?? "unknown"; +} + +function bestAliveDisplayStatus(statuses: readonly string[]): IpHealthState { + if (statuses.some((status) => status === "up")) return "up"; + if (statuses.some((status) => status === "degraded")) return "degraded"; + if (statuses.some((status) => status === "down")) return "down"; + return "unknown"; +} + function attachServiceHealth( db: Db, views: ServiceView[], @@ -480,10 +518,13 @@ function attachServiceHealth( const ids = views.map((v) => v.id); const healthByService = repos.aggregateIpHealthByServiceIds(db, ids); const ipHealthByService = repos.listIpHealthByServiceIds(db, ids); + const liveByService = repos.listLatestLiveHealthByServiceIds(db, ids); return views.map((view) => { const health = healthByService.get(view.id); const rows = ipHealthByService.get(view.id) ?? []; + const liveRows = liveByService.get(view.id) ?? []; const byIp = new Map(rows.map((row) => [row.ip, row])); + const liveByIp = new Map(liveRows.map((row) => [row.ip, row])); const cnameFallback = fallbackCnameHealth(rows, view); const aRecordIps = new Set( (view.domains ?? []).flatMap((domain) => @@ -492,20 +533,33 @@ function attachServiceHealth( ); const ip_health = (view.ips ?? []).map((ip) => { const row = byIp.get(ip) ?? (aRecordIps.has(ip) ? undefined : cnameFallback); + const live = liveByIp.get(ip); + const status = overlayLiveHealth(row?.status, live?.status); + const extras = live && live.status !== "unknown" ? live : row; return { ip, - status: row?.status ?? ("unknown" as const), - latency_ms: row?.latency_ms ?? null, - last_checked_at: row?.last_checked_at ?? null, - last_error: row?.last_error ?? null, - provider: row?.provider ?? "local", - colo: row?.colo ?? null, + status, + latency_ms: extras?.latency_ms ?? null, + last_checked_at: extras?.last_checked_at ?? null, + last_error: + live && live.status !== "unknown" + ? live.last_error + : (row?.last_error ?? null), + provider: extras?.provider ?? "local", + colo: extras?.colo ?? null, }; }); + const displayStatus = bestAliveDisplayStatus(ip_health.map((row) => row.status)); + 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: health?.health_status ?? "unknown", - health_latency_ms: health?.health_latency_ms ?? null, + health_status: overlayLiveHealth(health?.health_status, displayStatus), + health_latency_ms: + displayStatus !== "unknown" + ? (latencyRow?.latency_ms ?? null) + : (health?.health_latency_ms ?? null), ip_health, }; }); @@ -649,7 +703,7 @@ async function syncBindingDns( domainId, hostname, desiredIps, - ttlForBinding(binding.lb_mode, configuredIps.length), + ttlForBinding(binding.lb_mode, configuredIps), ); } @@ -1212,7 +1266,7 @@ async function syncGroupDomainDns( domainId, hostname, desiredIps, - ttlForBinding(group.lb_mode, fallbackIps.length), + ttlForBinding(group.lb_mode, fallbackIps), ); } @@ -1705,10 +1759,12 @@ export async function reconcileDnsForTarget( const cnameTarget = binding.cname_target?.trim() || null; if (cnameTarget) return; const ips = repos.listServiceIps(db, service.id); + const poolIps = enabledServiceIps(db, service.id); const targetIps = repos.listBindingIps(db, binding.id); - if (!isSharedPool(targetIps)) return; validateTargetIpsInPool(targetIps, ips); - const desiredIps = desiredAIps(db, "binding", refId, targetIps); + const desiredIps = canApplyLb(poolIps, targetIps) + ? desiredAIps(db, "binding", refId, targetIps) + : targetIps; await syncBindingDns( db, cf, @@ -1749,7 +1805,8 @@ export async function reconcileWeightedDns( const service = repos.getService(db, latest.service_id); if (!shouldPushDns(db, service)) return; const targetIps = repos.listBindingIps(db, latest.id); - if (!isSharedPool(targetIps)) return; + const poolIps = enabledServiceIps(db, service.id); + if (!canApplyLb(poolIps, targetIps)) return; const ips = repos.listServiceIps(db, service.id); validateTargetIpsInPool(targetIps, ips); const desiredIps = desiredAIps(db, "binding", latest.id, targetIps); diff --git a/apps/api/test/health-check.test.ts b/apps/api/test/health-check.test.ts index cd2deb4..9e4cf40 100644 --- a/apps/api/test/health-check.test.ts +++ b/apps/api/test/health-check.test.ts @@ -436,4 +436,46 @@ describe("CNAME health mapped onto service IPs", () => { const view = await getView(db, service.id); expect(view.health_status).toBe("up"); }); + + it("getView shows live OK over hysteresis unknown", 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 Panel", "rw-panel"); + repos.replaceServiceIps(db, service.id, ["2.59.161.102"]); + const binding = repos.insertBinding(db, domain.id, service.id, "c", null); + repos.replaceBindingIpsWithMeta(db, binding.id, [ + { ip: "2.59.161.102", weight: 1, priority: 1 }, + ]); + repos.upsertIpHealthStatus( + db, + "binding", + binding.id, + "2.59.161.102", + "unknown", + 63, + 0, + null, + 1, + ); + repos.insertHealthProbeLog(db, { + scope: "binding", + refId: binding.id, + ip: "2.59.161.102", + provider: "local", + status: "up", + ok: true, + latencyMs: 63, + colo: null, + error: null, + }); + + const view = await getView(db, service.id); + expect(view.health_status).toBe("up"); + expect(view.ip_health[0]?.status).toBe("up"); + expect(view.ip_health[0]?.latency_ms).toBe(63); + }); }); diff --git a/apps/api/test/lb-reconcile.test.ts b/apps/api/test/lb-reconcile.test.ts index 047e775..89d128b 100644 --- a/apps/api/test/lb-reconcile.test.ts +++ b/apps/api/test/lb-reconcile.test.ts @@ -158,6 +158,34 @@ describe("resolveDesiredAIps", () => { ).toEqual(["1.1.1.1"]); }); + it("does not drain when the service has only one unique IP", () => { + expect( + resolveDesiredAIps( + weightedConfig, + [row("1.1.1.1", { health: "down" })], + ["1.1.1.1", "1.1.1.1"], + 0, + ["1.1.1.1"], + ), + ).toEqual(["1.1.1.1", "1.1.1.1"]); + }); + + it("does not apply overlay when service pool is a single IP", () => { + const rows = [ + row("1.1.1.1", { health: "up" }), + row("2.2.2.2", { health: "down" }), + ]; + expect( + resolveDesiredAIps( + weightedConfig, + rows, + ["1.1.1.1", "2.2.2.2"], + 0, + ["1.1.1.1"], + ), + ).toEqual(["1.1.1.1", "2.2.2.2"]); + }); + it("applies weighted overlay on a shared pool", () => { const rows = [ row("1.1.1.1", { weight: 1, health: "up" }), @@ -170,6 +198,18 @@ describe("resolveDesiredAIps", () => { }); describe("shouldRecordFailoverDnsDiff", () => { + it("skips duplicate listings of the same IP", () => { + expect( + shouldRecordFailoverDnsDiff({ + configuredIps: ["1.1.1.1", "1.1.1.1"], + lbMode: "failover", + added: [], + removed: ["1.1.1.1"], + downIps: new Set(["1.1.1.1"]), + }), + ).toBe(false); + }); + it("skips dedicated extra-FQDN", () => { expect( shouldRecordFailoverDnsDiff({ diff --git a/apps/web/src/lib/failover-events.test.ts b/apps/web/src/lib/failover-events.test.ts index b297395..02a4956 100644 --- a/apps/web/src/lib/failover-events.test.ts +++ b/apps/web/src/lib/failover-events.test.ts @@ -51,7 +51,28 @@ describe('toFailoverEvents', () => { expect(isFailoverEventStatus('unhealthy')).toBe(false) }) - it('Down всегда виден, даже без A-записей', () => { + it('один IP у сервиса — не инцидент пула', () => { + const events = toFailoverEvents( + [ + row({ + ip: '10.0.0.1', + status: 'down', + consecutive_failures: 9, + last_error: 'fetch failed', + }), + ], + [ + { + fqdn: 'solo.example.com', + configured: ['10.0.0.1'], + active: [], + }, + ], + ) + expect(events).toEqual([]) + }) + + it('Down без shared pool не событие балансировки', () => { const events = toFailoverEvents([ row({ ip: '130.49.213.153', @@ -60,19 +81,7 @@ describe('toFailoverEvents', () => { last_error: 'fetch failed', }), ]) - expect(events).toEqual([ - { - id: '130.49.213.153', - address: '130.49.213.153', - status: 'down', - kind: 'last-resort', - fqdns: [], - consecutiveFailures: 9, - lastFailureReason: 'fetch failed', - lastCheckAt: null, - }, - ]) - expect(failoverEventCopy(events[0]!)).toBe('Down, в A-записях last-resort') + expect(events).toEqual([]) }) it('OK вне пула не инцидент (standby)', () => { @@ -151,9 +160,7 @@ describe('toFailoverEvents', () => { }, ], ) - expect(events[0]?.kind).toBe('last-resort') - expect(events[0]?.fqdns).toEqual([]) - expect(failoverEventCopy(events[0]!)).toBe('Down, в A-записях last-resort') + expect(events).toEqual([]) }) it('down без A-записи на configured FQDN — снятие', () => { @@ -293,6 +300,24 @@ describe('mergeFailoverHistory', () => { expect(history[0]?.source).toBe('dns') }) + it('drops probe leave/return when the service has no shared pool', () => { + const history = mergeFailoverHistory( + [], + [ + probe({ id: 1, status: 'up', checked_at: '2026-08-20T09:00:00Z' }), + probe({ id: 2, status: 'down', checked_at: '2026-08-20T09:10:00Z' }), + ], + [ + { + fqdn: 'solo.example.com', + configured: ['130.49.213.153'], + active: ['130.49.213.153'], + }, + ], + ) + expect(history).toEqual([]) + }) + it('drops dedicated extra-FQDN DNS rows', () => { const history = mergeFailoverHistory( [ diff --git a/apps/web/src/lib/failover-events.ts b/apps/web/src/lib/failover-events.ts index 63bf995..f1225cd 100644 --- a/apps/web/src/lib/failover-events.ts +++ b/apps/web/src/lib/failover-events.ts @@ -44,9 +44,20 @@ export interface FailoverBindingPool { active: readonly string[] } -/** Shared pool FQDN — two or more A targets. Dedicated extra-FQDN has one IP. */ +/** Unique A targets. Duplicates of the same IP are not a pool. */ +export function uniqueIpCount(ips: readonly string[]): number { + return new Set(ips.filter(Boolean)).size +} + +/** Shared pool FQDN — two or more unique A targets. */ export function isSharedPoolBinding(binding: FailoverBindingPool): boolean { - return binding.configured.length >= 2 + return uniqueIpCount(binding.configured) >= 2 +} + +export function hasSharedPool( + bindings: readonly FailoverBindingPool[], +): boolean { + return bindings.some(isSharedPoolBinding) } /** Инцидент только при down. up / degraded / unknown — не вывод из пула. */ @@ -66,13 +77,14 @@ export function failoverEventCopy(event: FailoverEvent): string { } /** - * Текущие Down всегда в панели. Per-FQDN: снята с hostname или last-resort. - * Standby (up / degraded / unknown) — не инцидент. + * Инциденты пула только если у сервиса есть shared FQDN (2+ уникальных IP). + * Один IP — не балансировка и не вывод из пула; Down смотрит health-монитор. */ export function toFailoverEvents( ipHealth: readonly FailoverHealthInput[], bindings: readonly FailoverBindingPool[] = [], ): FailoverEvent[] { + if (!hasSharedPool(bindings)) return [] return ipHealth.filter((row) => isFailoverEventStatus(row.status)).map((row) => { const removedFqdns: string[] = [] const lastResortFqdns: string[] = [] @@ -221,9 +233,9 @@ export function mergeFailoverHistory( ...dns .filter((item) => isPoolFqdn(item.fqdn, bindings)) .map(dnsHistoryItem), - ...toIpAliveTransitions(probes).map((transition) => - probeHistoryItem(transition, bindings), - ), + ...toIpAliveTransitions(probes) + .filter((transition) => fqdnsForIp(transition.ip, bindings).length > 0) + .map((transition) => probeHistoryItem(transition, bindings)), ] merged.sort( (a, b) => eventTime(b.created_at) - eventTime(a.created_at) || a.id.localeCompare(b.id), diff --git a/apps/web/src/lib/health-log.test.ts b/apps/web/src/lib/health-log.test.ts index 97d61db..79681b2 100644 --- a/apps/web/src/lib/health-log.test.ts +++ b/apps/web/src/lib/health-log.test.ts @@ -7,6 +7,7 @@ import { latestHealthByIp, providerHealthStatuses, resolveIpDisplayHealth, + resolveServiceDisplayHealth, worstHealthStatus, type HealthLogProbe, } from '@/lib/health-log' @@ -146,3 +147,22 @@ describe('resolveIpDisplayHealth', () => { expect(resolveIpDisplayHealth('unknown', undefined)).toBe('unknown') }) }) + +describe('resolveServiceDisplayHealth', () => { + it('shows OK when live probes recovered and stored is still unknown', () => { + expect( + resolveServiceDisplayHealth( + 'unknown', + [{ ip: '2.59.161.102', status: 'unknown' }], + [ + probe({ + id: 1, + ip: '2.59.161.102', + status: 'up', + checked_at: '2026-08-20T18:00:00Z', + }), + ], + ), + ).toBe('up') + }) +}) diff --git a/apps/web/src/lib/health-log.ts b/apps/web/src/lib/health-log.ts index a74e8cd..410771c 100644 --- a/apps/web/src/lib/health-log.ts +++ b/apps/web/src/lib/health-log.ts @@ -194,3 +194,22 @@ export function resolveIpDisplayHealth( if (live && live !== 'unknown') return live return stored ?? live ?? 'unknown' } + +/** Service KPI / card: any-up of per-IP overlay (live probes beat hysteresis). */ +export function resolveServiceDisplayHealth( + stored: HealthLogStatus | undefined, + ipHealth: readonly { ip: string; status: string }[], + probes: readonly HealthLogProbe[] = [], +): HealthLogStatus { + const liveByIp = latestHealthByIp(probes) + const statuses = + ipHealth.length > 0 + ? ipHealth.map((row) => + resolveIpDisplayHealth( + row.status as HealthLogStatus, + liveByIp.get(row.ip)?.status, + ), + ) + : [...liveByIp.values()].map((row) => row.status) + return resolveIpDisplayHealth(stored, bestAliveHealthStatus(statuses)) +} diff --git a/apps/web/src/queries/services.ts b/apps/web/src/queries/services.ts index 9362dc8..6f26953 100644 --- a/apps/web/src/queries/services.ts +++ b/apps/web/src/queries/services.ts @@ -36,6 +36,8 @@ export const serviceGroupsQueryOptions = () => } return parsed.data }, + refetchInterval: 10_000, + staleTime: 5_000, }) export const servicesQueryOptions = () => @@ -101,6 +103,8 @@ export const serviceViewQueryOptions = (id: number) => const data = await api.get(`/api/v1/services/${id}`) return serviceViewSchema.parse(data) }, + refetchInterval: 10_000, + staleTime: 5_000, }) export const serviceHealthLogQueryOptions = (id: number) => @@ -110,6 +114,8 @@ export const serviceHealthLogQueryOptions = (id: number) => const data = await api.get(`/api/v1/services/${id}/health-log`) return z.object({ items: z.array(healthProbeLogSchema) }).parse(data) }, + refetchInterval: 10_000, + staleTime: 5_000, }) export const serviceFailoverLogQueryOptions = (id: number) => diff --git a/apps/web/src/routes/_auth/services/$serviceId/index.tsx b/apps/web/src/routes/_auth/services/$serviceId/index.tsx index da95141..324df0a 100644 --- a/apps/web/src/routes/_auth/services/$serviceId/index.tsx +++ b/apps/web/src/routes/_auth/services/$serviceId/index.tsx @@ -32,9 +32,11 @@ import { ServiceHealthMonitor, } from '@/components/reui-kit' import { api } from '@/lib/api-client' +import { hasSharedPool } from '@/lib/failover-events' import { enabledHealthProviders, providerHealthStatuses, + resolveServiceDisplayHealth, } from '@/lib/health-log' import type { ServiceView, UpdateServiceConfigInput } from '@/lib/schemas' import { @@ -115,6 +117,22 @@ function ServiceDetailPage() { })), [service?.domains], ) + const uniqueEnabledIps = useMemo( + () => + (service?.ips ?? []).filter((ip) => service?.ip_enabled[ip] !== false), + [service?.ips, service?.ip_enabled], + ) + const displayHealth = useMemo( + () => + resolveServiceDisplayHealth( + service?.health_status, + service?.ip_health ?? [], + logItems, + ), + [service?.health_status, service?.ip_health, logItems], + ) + const showPoolPanel = + uniqueEnabledIps.length >= 2 && hasSharedPool(failoverBindings) const nodes = (nodesQuery.data as OverviewPayload['nodes']) ?? [] const [editOpen, setEditOpen] = useState(false) @@ -251,7 +269,7 @@ function ServiceDetailPage() { actions={ <> - + , label: 'Статус', - value: service.health_status === 'up' ? 'OK' : service.health_status, + value: displayHealth === 'up' ? 'OK' : displayHealth, variant: - service.health_status === 'down' + displayHealth === 'down' ? 'destructive' - : service.health_status === 'degraded' + : displayHealth === 'degraded' ? 'warning' : 'default', iconClassName: - service.health_status === 'down' + displayHealth === 'down' ? 'text-destructive' - : service.health_status === 'degraded' + : displayHealth === 'degraded' ? 'text-warning' : 'text-success', - hint: , + hint: , }, { id: 'fqdn', @@ -305,21 +323,33 @@ function ServiceDetailPage() { icon: , label: 'IP', value: String(service.ips.length), - hint: `${service.active_ips.length} в пуле`, + hint: showPoolPanel + ? `${service.active_ips.length} в пуле` + : uniqueEnabledIps.length <= 1 + ? 'без балансировки' + : service.ips.join(', ') || 'нет', }, { id: 'pool', icon: , - label: 'Активный пул', - value: String((overview?.active_addresses ?? service.active_ips).length), - hint: (overview?.active_addresses ?? service.active_ips).join(', ') || 'нет', + label: showPoolPanel ? 'Активный пул' : 'Адреса', + value: String( + (overview?.active_addresses ?? service.active_ips).length, + ), + hint: + (overview?.active_addresses ?? service.active_ips).join(', ') || + 'нет', }, ]} />
- + {showPoolPanel ? ( + + ) : null}
{service.ips.length === 0 && service.domains.length === 0 ? ( diff --git a/packages/db/src/repos.ts b/packages/db/src/repos.ts index b7c0add..588db01 100644 --- a/packages/db/src/repos.ts +++ b/packages/db/src/repos.ts @@ -2341,6 +2341,113 @@ export function listIpHealthByServiceIds( return result; } +function parseIpHealthState(status: string | null): IpHealthState | undefined { + if ( + status === "up" || + status === "down" || + status === "degraded" || + status === "unknown" + ) { + return status; + } + return undefined; +} + +function bestAliveIpState(statuses: readonly IpHealthState[]): IpHealthState { + if (statuses.some((status) => status === "up")) return "up"; + if (statuses.some((status) => status === "degraded")) return "degraded"; + if (statuses.some((status) => status === "down")) return "down"; + return "unknown"; +} + +/** + * Latest probe per service+IP+provider from health_probe_log, then any-up per IP. + * Display overlay — hysteresis in ip_health_status is unchanged (DNS). + */ +export function listLatestLiveHealthByServiceIds( + db: Db, + serviceIds: number[], +): Map { + const result = new Map(); + if (serviceIds.length === 0) return result; + const idList = sql.join( + serviceIds.map((id) => sql`${id}`), + sql`, `, + ); + const rows = db.all<{ + service_id: number; + ip: string; + provider: string | null; + status: string | null; + latency_ms: number | null; + last_error: string | null; + colo: string | null; + checked_at: string | null; + }>(sql` + SELECT sb.service_id AS service_id, + l.ip AS ip, + l.provider AS provider, + l.status AS status, + l.latency_ms AS latency_ms, + l.error AS last_error, + l.colo AS colo, + l.checked_at AS checked_at + FROM health_probe_log l + INNER JOIN service_bindings sb + ON l.scope = 'binding' AND l.ref_id = sb.id + INNER JOIN ( + SELECT sb2.service_id AS service_id, + l2.ip AS ip, + l2.provider AS provider, + MAX(l2.id) AS max_id + FROM health_probe_log l2 + INNER JOIN service_bindings sb2 + ON l2.scope = 'binding' AND l2.ref_id = sb2.id + WHERE sb2.service_id IN (${idList}) + GROUP BY sb2.service_id, l2.ip, l2.provider + ) latest + ON latest.max_id = l.id + `); + + const byServiceIp = new Map< + string, + { serviceId: number; ip: string; probes: ServiceIpHealthRow[] } + >(); + for (const row of rows) { + const status = parseIpHealthState(row.status); + if (!status) continue; + const key = `${row.service_id}\0${row.ip}`; + const probe: ServiceIpHealthRow = { + ip: row.ip, + status, + latency_ms: row.latency_ms, + last_checked_at: row.checked_at, + last_error: row.last_error, + provider: normalizeStatusProvider(row.provider), + colo: row.colo, + }; + const bucket = byServiceIp.get(key); + if (bucket) bucket.probes.push(probe); + else { + byServiceIp.set(key, { + serviceId: row.service_id, + ip: row.ip, + probes: [probe], + }); + } + } + + for (const { serviceId, ip, probes } of byServiceIp.values()) { + const status = bestAliveIpState(probes.map((probe) => probe.status)); + const preferred = + probes.find((probe) => probe.status === status) ?? probes[0]!; + const list = result.get(serviceId) ?? []; + list.push({ ...preferred, ip, status }); + result.set(serviceId, list); + } + return result; +} + export function mergeHealthAggregates( parts: Array, ): HealthAggregate {