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
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user