Files
cloudflare-domain-manager/apps/api/src/routes/services.ts
T
DenozordecandCursor fa7d2b6df3
quality / commitlint (push) Skipped
quality / changes (push) Successful in 11s
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / web (push) Successful in 1m11s
quality / api (push) Successful in 1m1s
CD / quality (push) Successful in 2m30s
CD / publish (push) Successful in 2m4s
feat(services): считать сервис живым при одном OK и писать историю failover
Сервис up, если жив хотя бы один IP. Failover показывает Down по FQDN и журнал add/remove A-записей.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 15:49:17 +07:00

260 lines
7.9 KiB
TypeScript

import type { FastifyInstance } from "fastify";
import { z } from "zod";
import {
changeDomainSchema,
createServiceNodeSchema,
reorderServicesSchema,
toggleServiceIpSchema,
updateServiceConfigSchema,
updateServiceNodeSchema,
} from "@cfdm/shared";
import { repos } from "@cfdm/db";
import * as serviceConfig from "../services/service-config-service.js";
import * as nodeService from "../services/node-service.js";
import * as changeDomain from "../services/change-domain-service.js";
import * as certificateService from "../services/certificate-service.js";
import { recordAudit } from "../lib/audit.js";
export async function serviceRoutes(app: FastifyInstance) {
const createSchema = z.object({
name: z.string(),
slug: z.string(),
service_group_id: z.number().nullable().optional(),
});
app.get("/services", async (request) => {
return serviceConfig.listViews(request.server.db);
});
app.patch("/services/reorder", async (request) => {
const body = reorderServicesSchema.parse(request.body);
serviceConfig.reorderServices(
request.server.db,
body.group_id,
body.service_ids,
);
return { ok: true };
});
app.post("/services", async (request) => {
const body = createSchema.parse(request.body);
const service = repos.createService(
request.server.db,
body.name,
body.slug,
);
if (body.service_group_id != null) {
repos.setServiceGroup(
request.server.db,
service.id,
body.service_group_id,
);
}
const view = await serviceConfig.getView(request.server.db, service.id);
recordAudit(request.server, request, {
action: "service.create",
targetType: "app_resource",
targetId: String(service.id),
summary: `Создан сервис «${service.name}»`,
details: { name: service.name, slug: service.slug },
});
return view;
});
app.get("/services/:id", async (request) => {
const { id } = request.params as { id: string };
return serviceConfig.getView(request.server.db, Number(id));
});
app.get("/services/:id/health-log", async (request) => {
const { id } = request.params as { id: string };
repos.getService(request.server.db, Number(id));
return {
items: repos.listHealthProbeLogForService(
request.server.db,
Number(id),
200,
),
};
});
app.get("/services/:id/failover-log", async (request) => {
const { id } = request.params as { id: string };
repos.getService(request.server.db, Number(id));
return {
items: repos.listFailoverLogForService(
request.server.db,
Number(id),
200,
),
};
});
app.get("/services/:id/certificates", async (request) => {
const { id } = request.params as { id: string };
return certificateService.listServiceCertificates(
request.server.db,
Number(id),
);
});
app.post("/services/:id/certificates/check", async (request) => {
const { id } = request.params as { id: string };
const checked = await certificateService.runServiceChecks(
request.server.db,
Number(id),
);
return { checked };
});
app.get("/services/:id/overview", async (request) => {
const { id } = request.params as { id: string };
return nodeService.getOverview(request.server.db, Number(id));
});
app.get("/services/:id/nodes", async (request) => {
const { id } = request.params as { id: string };
return nodeService.listNodes(request.server.db, Number(id));
});
app.post("/services/:id/nodes", async (request) => {
const { id } = request.params as { id: string };
const body = createServiceNodeSchema.parse(request.body);
const node = nodeService.createNode(request.server.db, Number(id), body);
recordAudit(request.server, request, {
action: "node.create",
targetType: "app_resource",
targetId: String(node.id),
summary: `Добавлена нода ${node.address}`,
details: { service_id: Number(id), address: node.address },
});
return node;
});
app.patch("/services/:id/nodes/:nodeId", async (request) => {
const { id, nodeId } = request.params as { id: string; nodeId: string };
const body = updateServiceNodeSchema.parse(request.body);
const node = nodeService.updateNode(
request.server.db,
Number(id),
Number(nodeId),
body,
);
recordAudit(request.server, request, {
action: "node.update",
targetType: "app_resource",
targetId: String(node.id),
summary: `Обновлена нода ${node.address}`,
details: body,
});
return node;
});
app.delete("/services/:id/nodes/:nodeId", async (request) => {
const { id, nodeId } = request.params as { id: string; nodeId: string };
const node = repos.getNode(request.server.db, Number(nodeId));
nodeService.deleteNode(request.server.db, Number(id), Number(nodeId));
recordAudit(request.server, request, {
action: "node.delete",
severity: "warning",
targetType: "app_resource",
targetId: nodeId,
summary: `Удалена нода ${node.address}`,
});
return { deleted: true };
});
app.post("/services/:id/change-domain", async (request) => {
const { id } = request.params as { id: string };
const body = changeDomainSchema.parse(request.body);
const result = await changeDomain.changeServiceDomain(
request.server.db,
request.server.cf,
Number(id),
body,
);
if (result.applied) {
recordAudit(request.server, request, {
action: "service.change_domain",
targetType: "app_resource",
targetId: id,
summary: result.message,
details: result,
});
}
return result;
});
app.get("/ops-summary", async (request) => {
return nodeService.opsSummary(request.server.db);
});
app.patch("/services/:id", async (request) => {
const { id } = request.params as { id: string };
const body = updateServiceConfigSchema.parse(request.body);
const view = await serviceConfig.updateConfig(
request.server.db,
request.server.cf,
Number(id),
body,
);
recordAudit(request.server, request, {
action: "service.update",
targetType: "app_resource",
targetId: String(id),
summary: `Обновлён сервис «${view.name}»`,
details: body,
});
return view;
});
app.delete("/services/:id", async (request) => {
const { id } = request.params as { id: string };
const view = await serviceConfig.getView(request.server.db, Number(id));
repos.deleteService(request.server.db, Number(id));
recordAudit(request.server, request, {
action: "service.delete",
severity: "warning",
targetType: "app_resource",
targetId: String(id),
summary: `Удалён сервис «${view.name}»`,
details: { name: view.name, slug: view.slug },
});
return { deleted: true };
});
app.patch("/services/:id/toggle", async (request) => {
const { id } = request.params as { id: string };
const body = z.object({ enabled: z.boolean() }).parse(request.body);
return serviceConfig.toggleService(
request.server.db,
request.server.cf,
Number(id),
body.enabled,
);
});
app.patch("/services/:id/ips/toggle", async (request) => {
const { id } = request.params as { id: string };
const body = toggleServiceIpSchema.parse(request.body);
const view = await serviceConfig.toggleServiceIp(
request.server.db,
request.server.cf,
Number(id),
body.ip,
body.enabled,
);
recordAudit(request.server, request, {
action: "service.ip.toggle",
targetType: "app_resource",
targetId: String(id),
summary: body.enabled
? `Включён IP ${body.ip} сервиса «${view.name}»`
: `Выключен IP ${body.ip} сервиса «${view.name}»`,
details: { ip: body.ip, enabled: body.enabled },
});
return view;
});
}