quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / web (push) Successful in 55s
quality / api (push) Successful in 52s
CD / quality (push) Successful in 2m4s
CD / publish (push) Successful in 1m51s
KPI и карточка показывают живой OK, а не гистерезис unknown; DNS по-прежнему ждёт повторные успехи. Co-authored-by: Cursor <[email protected]>
164 lines
4.5 KiB
TypeScript
164 lines
4.5 KiB
TypeScript
import type { Db } from "@cfdm/db";
|
|
import { repos } from "@cfdm/db";
|
|
import type {
|
|
CreateServiceNodeInput,
|
|
ServiceNode,
|
|
ServiceOverview,
|
|
UpdateServiceNodeInput,
|
|
} from "@cfdm/shared";
|
|
import { AppError } from "../errors.js";
|
|
import { isValidIpv4 } from "../lib/validators.js";
|
|
import { getView } from "./service-config-service.js";
|
|
import {
|
|
isSharedPool,
|
|
resolveDesiredAIps,
|
|
} from "./routing/index.js";
|
|
|
|
function assertAddress(address: string): void {
|
|
if (!isValidIpv4(address)) {
|
|
throw AppError.invalidIp(`Некорректный IP-адрес: ${address}`);
|
|
}
|
|
}
|
|
|
|
export function listNodes(db: Db, serviceId: number): ServiceNode[] {
|
|
repos.getService(db, serviceId);
|
|
return repos.listNodes(db, serviceId);
|
|
}
|
|
|
|
export function createNode(
|
|
db: Db,
|
|
serviceId: number,
|
|
input: CreateServiceNodeInput,
|
|
): ServiceNode {
|
|
repos.getService(db, serviceId);
|
|
assertAddress(input.address);
|
|
try {
|
|
return repos.createNode(db, serviceId, {
|
|
address: input.address,
|
|
protocol: input.protocol,
|
|
port: input.port,
|
|
enabled: input.enabled,
|
|
priority: input.priority,
|
|
weight: input.weight,
|
|
health_check_id: input.health_check_id,
|
|
});
|
|
} catch (err) {
|
|
if (err instanceof Error && err.name === "ConflictError") {
|
|
throw AppError.conflict(err.message);
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export function updateNode(
|
|
db: Db,
|
|
serviceId: number,
|
|
nodeId: number,
|
|
patch: UpdateServiceNodeInput,
|
|
): ServiceNode {
|
|
const node = repos.getNode(db, nodeId);
|
|
if (node.service_id !== serviceId) {
|
|
throw AppError.notFound(`node ${nodeId}`);
|
|
}
|
|
if (patch.address) assertAddress(patch.address);
|
|
return repos.updateNode(db, nodeId, patch);
|
|
}
|
|
|
|
export function deleteNode(db: Db, serviceId: number, nodeId: number): void {
|
|
const node = repos.getNode(db, nodeId);
|
|
if (node.service_id !== serviceId) {
|
|
throw AppError.notFound(`node ${nodeId}`);
|
|
}
|
|
repos.deleteNode(db, nodeId);
|
|
}
|
|
|
|
export async function getOverview(
|
|
db: Db,
|
|
serviceId: number,
|
|
): Promise<ServiceOverview> {
|
|
const service = await getView(db, serviceId);
|
|
const nodes = repos.listNodes(db, serviceId);
|
|
const bindings = repos.listBindingsByService(db, serviceId);
|
|
const first = bindings[0];
|
|
const routing = first?.routing_strategy ?? first?.lb_mode ?? "round_robin";
|
|
const healthCheck =
|
|
nodes
|
|
.map((n) => n.health_check_id)
|
|
.find((id): id is number => id != null) != null
|
|
? repos.getHealthCheck(
|
|
db,
|
|
nodes.find((n) => n.health_check_id != null)!.health_check_id!,
|
|
)
|
|
: null;
|
|
|
|
const active = new Set<string>();
|
|
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 {
|
|
ip: entry.ip,
|
|
weight: entry.weight,
|
|
priority: entry.priority,
|
|
health: status ? status.status : ("unknown" as const),
|
|
};
|
|
});
|
|
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);
|
|
}
|
|
}
|
|
|
|
return {
|
|
service,
|
|
nodes,
|
|
health_check: healthCheck,
|
|
routing_strategy: routing,
|
|
active_addresses: [...active],
|
|
};
|
|
}
|
|
|
|
export function opsSummary(db: Db) {
|
|
const allNodes = repos.listAllNodes(db);
|
|
const services = repos.listServices(db);
|
|
const domains = repos.listDomains(db);
|
|
const healthy = allNodes.filter(
|
|
(n) => n.health_status === "healthy" || n.health_status === "up",
|
|
).length;
|
|
const unhealthy = allNodes.filter(
|
|
(n) => n.health_status === "unhealthy" || n.health_status === "down",
|
|
).length;
|
|
const failoverActive = repos.listAllBindings(db).filter((binding) => {
|
|
if (binding.lb_mode !== "failover" || !binding.health_check_enabled) {
|
|
return false;
|
|
}
|
|
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,
|
|
services: services.length,
|
|
nodes: allNodes.length,
|
|
healthy,
|
|
unhealthy,
|
|
active_failovers: failoverActive,
|
|
};
|
|
}
|