feat(health-checks): implement health check IP toggling and configuration updates
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 8s
quality / changes (push) Successful in 5s
quality / docker-check (push) Skipped
quality / web (push) Successful in 51s
quality / api (push) Successful in 43s
CD / quality (push) Successful in 1m43s
CD / publish (push) Successful in 1m38s

- Added functionality to toggle individual IPs for services, allowing for dynamic management of IP health status.
- Enhanced the health check configuration in the UI, enabling users to set parameters directly from the settings page.
- Updated service views to include IP health tracking, improving visibility into the status of each IP associated with a service.
- Refactored relevant components to support the new IP toggling feature, ensuring a seamless user experience.

This commit significantly enhances the health management capabilities of services, providing users with more control over IP configurations and health monitoring.
This commit is contained in:
Denozordec
2026-08-19 15:33:47 +07:00
parent 4224db8eb3
commit d63c86065c
35 changed files with 1538 additions and 123 deletions
@@ -165,4 +165,94 @@ describe("create service then list groups", () => {
await app.close();
});
it("PATCH /services/:id/ips/toggle keeps IP in pool and removes it from A-binding", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const headers = await authHeaders(app);
const cf = mockCf();
repos.createDomain(app.db, null, "example.com", "zone-1");
const group = repos.createServiceGroup(
app.db,
"VPN",
"vpn",
null,
"vpn.example.com",
);
const createRes = await app.inject({
method: "POST",
url: "/api/v1/services",
headers,
payload: {
name: "Panel",
slug: "panel-ip-toggle",
service_group_id: group.id,
},
});
expect(createRes.statusCode).toBe(200);
const created = createRes.json() as { id: number };
await updateConfig(app.db, cf, created.id, {
ips: ["1.2.3.4", "5.6.7.8"],
service_group_id: group.id,
domains: [
{
fqdn: "panel.example.com",
target_ips: ["1.2.3.4", "5.6.7.8"],
target_ip_weights: { "1.2.3.4": 1, "5.6.7.8": 1 },
target_ip_priorities: { "1.2.3.4": 1, "5.6.7.8": 1 },
lb_mode: "round_robin",
health_check_enabled: false,
health_check_type: "tcp",
health_check_port: 443,
health_check_path: null,
health_check_expected_status: null,
health_check_interval_sec: 30,
health_check_timeout_ms: 3000,
health_check_verify_tls: false,
},
],
});
// HTTP toggle uses request.server.cf; disable DNS push so the test
// does not call the real Cloudflare client.
repos.setServiceEnabled(app.db, created.id, false);
const offRes = await app.inject({
method: "PATCH",
url: `/api/v1/services/${created.id}/ips/toggle`,
headers,
payload: { ip: "1.2.3.4", enabled: false },
});
expect(offRes.statusCode, JSON.stringify(offRes.json())).toBe(200);
const offView = offRes.json() as {
ips: string[];
ip_enabled: Record<string, boolean>;
};
expect(offView.ips).toEqual(expect.arrayContaining(["1.2.3.4", "5.6.7.8"]));
expect(offView.ip_enabled["1.2.3.4"]).toBe(false);
expect(offView.ip_enabled["5.6.7.8"]).toBe(true);
const binding = repos.listBindingsByService(app.db, created.id)[0]!;
expect(repos.listBindingIps(app.db, binding.id)).toEqual(["5.6.7.8"]);
const onRes = await app.inject({
method: "PATCH",
url: `/api/v1/services/${created.id}/ips/toggle`,
headers,
payload: { ip: "1.2.3.4", enabled: true },
});
expect(onRes.statusCode).toBe(200);
const onView = onRes.json() as { ip_enabled: Record<string, boolean> };
expect(onView.ip_enabled["1.2.3.4"]).toBe(true);
expect(repos.listBindingIps(app.db, binding.id)).toEqual(
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
);
await app.close();
});
});
+136
View File
@@ -0,0 +1,136 @@
import { describe, expect, it } from "vitest";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";
async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
const res = await app.inject({
method: "POST",
url: "/api/v1/auth/login",
payload: { username: "admin", password: "admin" },
});
expect(res.statusCode).toBe(200);
const { token } = res.json() as { token: string };
return { authorization: `Bearer ${token}` };
}
describe("settings health engine", () => {
it("GET /api/v1/settings returns env fallbacks for health fields", async () => {
const app = await buildApp({
config: {
...loadConfig(),
staticDir: null,
healthCheckCron: "*/30 * * * * *",
healthDegradedFailures: 3,
healthDownFailures: 4,
healthLatencyWarnMs: 1500,
healthSuccessRecoveries: 5,
},
memory: true,
});
const headers = await authHeaders(app);
const res = await app.inject({
method: "GET",
url: "/api/v1/settings",
headers,
});
expect(res.statusCode).toBe(200);
const body = res.json() as {
healthCheckCron: string;
healthDegradedFailures: number;
healthDownFailures: number;
healthLatencyWarnMs: number;
healthSuccessRecoveries: number;
};
expect(body.healthCheckCron).toBe("*/30 * * * * *");
expect(body.healthDegradedFailures).toBe(3);
expect(body.healthDownFailures).toBe(4);
expect(body.healthLatencyWarnMs).toBe(1500);
expect(body.healthSuccessRecoveries).toBe(5);
await app.close();
});
it("PATCH persists health engine settings", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const headers = await authHeaders(app);
const res = await app.inject({
method: "PATCH",
url: "/api/v1/settings",
headers,
payload: {
healthCheckCron: "0 */5 * * * *",
healthDegradedFailures: 2,
healthDownFailures: 4,
healthLatencyWarnMs: 800,
healthSuccessRecoveries: 3,
},
});
expect(res.statusCode).toBe(200);
const body = res.json() as {
healthCheckCron: string;
healthDegradedFailures: number;
healthDownFailures: number;
healthLatencyWarnMs: number;
healthSuccessRecoveries: number;
};
expect(body.healthCheckCron).toBe("0 */5 * * * *");
expect(body.healthDegradedFailures).toBe(2);
expect(body.healthDownFailures).toBe(4);
expect(body.healthLatencyWarnMs).toBe(800);
expect(body.healthSuccessRecoveries).toBe(3);
const again = await app.inject({
method: "GET",
url: "/api/v1/settings",
headers,
});
expect(again.json()).toMatchObject({
healthCheckCron: "0 */5 * * * *",
healthDegradedFailures: 2,
healthDownFailures: 4,
healthLatencyWarnMs: 800,
healthSuccessRecoveries: 3,
});
await app.close();
});
it("PATCH rejects invalid cron", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const headers = await authHeaders(app);
const res = await app.inject({
method: "PATCH",
url: "/api/v1/settings",
headers,
payload: { healthCheckCron: "not-a-cron" },
});
expect(res.statusCode).toBe(400);
expect(res.json()).toMatchObject({
error: { code: "VALIDATION_ERROR" },
});
await app.close();
});
it("PATCH rejects down < degraded", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const headers = await authHeaders(app);
const res = await app.inject({
method: "PATCH",
url: "/api/v1/settings",
headers,
payload: {
healthDegradedFailures: 5,
healthDownFailures: 2,
},
});
expect(res.statusCode).toBe(400);
await app.close();
});
});
+13
View File
@@ -134,6 +134,19 @@ describe("resolveBindingIpsForSync", () => {
expect(ips).toEqual(["203.0.113.10"]);
});
it("treats missing target_ips as empty instead of throwing", async () => {
const cname = binding({
id: 2,
hostname: "imsk",
zone_name: "rkns.top",
cname_target: "ihome.rkns.top",
});
delete (cname as { target_ips?: string[] }).target_ips;
const index = { byFqdn: new Map([["imsk.rkns.top", cname]]) };
const ips = await resolveBindingIpsForSync(cname, ["198.51.100.9"], index);
expect(ips).toEqual(["198.51.100.9"]);
});
it("prefers service IPs over empty CNAME resolution chain", async () => {
const cname = binding({
id: 2,