feat(api, web): integrate health status management for services and domains
Build, Test, and Push CFDM Docker Image / test (push) Successful in 10m17s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 1m48s
Build, Test, and Push CFDM Docker Image / update-wiki (push) Successful in 6s
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped
Build, Test, and Push CFDM Docker Image / test (push) Successful in 10m17s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 1m48s
Build, Test, and Push CFDM Docker Image / update-wiki (push) Successful in 6s
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped
- Added health status and latency fields to service and domain schemas, enhancing monitoring capabilities. - Implemented health status aggregation for services in the API, allowing for improved health checks and reporting. - Updated web components to display health status using HealthCheckBadge, improving user visibility of service health. - Refactored service and domain management components to incorporate health status in various views, enhancing overall functionality. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -340,23 +340,45 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
||||
updated_at: service.updated_at,
|
||||
ips,
|
||||
domains: domainViews,
|
||||
health_status: "unknown",
|
||||
health_latency_ms: null,
|
||||
};
|
||||
}
|
||||
|
||||
function attachServiceHealth(
|
||||
db: Db,
|
||||
views: ServiceView[],
|
||||
): ServiceView[] {
|
||||
const healthByService = repos.aggregateIpHealthByServiceIds(
|
||||
db,
|
||||
views.map((v) => v.id),
|
||||
);
|
||||
return views.map((view) => {
|
||||
const health = healthByService.get(view.id);
|
||||
return {
|
||||
...view,
|
||||
health_status: health?.health_status ?? "unknown",
|
||||
health_latency_ms: health?.health_latency_ms ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function listViews(db: Db): Promise<ServiceView[]> {
|
||||
return Promise.all(
|
||||
const views = await Promise.all(
|
||||
repos.listServices(db).map((s) => buildView(db, s.id)),
|
||||
);
|
||||
return attachServiceHealth(db, views);
|
||||
}
|
||||
|
||||
export async function getView(db: Db, id: number): Promise<ServiceView> {
|
||||
repos.getService(db, id);
|
||||
return buildView(db, id);
|
||||
const [view] = attachServiceHealth(db, [await buildView(db, id)]);
|
||||
return view!;
|
||||
}
|
||||
|
||||
export async function listGroupViews(db: Db): Promise<ServiceGroupsResponse> {
|
||||
const groups = repos.listServiceGroups(db);
|
||||
const groupViews = await Promise.all(
|
||||
const groupViewsRaw = await Promise.all(
|
||||
groups.map(async (group) => {
|
||||
const services = repos.listServicesByGroup(db, group.id);
|
||||
const serviceViews = await Promise.all(
|
||||
@@ -367,10 +389,52 @@ export async function listGroupViews(db: Db): Promise<ServiceGroupsResponse> {
|
||||
);
|
||||
|
||||
const ungroupedServices = repos.listUngroupedServices(db);
|
||||
const ungrouped = await Promise.all(
|
||||
const ungroupedRaw = await Promise.all(
|
||||
ungroupedServices.map((s) => buildView(db, s.id)),
|
||||
);
|
||||
|
||||
const allServiceViews = [
|
||||
...groupViewsRaw.flatMap((g) => g.services),
|
||||
...ungroupedRaw,
|
||||
];
|
||||
const withHealth = attachServiceHealth(db, allServiceViews);
|
||||
const healthById = new Map(withHealth.map((v) => [v.id, v]));
|
||||
|
||||
const groupHealthById = repos.aggregateIpHealthByRefs(
|
||||
db,
|
||||
"group",
|
||||
groups.map((g) => g.id),
|
||||
);
|
||||
|
||||
const groupViews = groupViewsRaw.map((group) => {
|
||||
const services = group.services.map(
|
||||
(s) => healthById.get(s.id) ?? { ...s, health_status: "unknown" as const, health_latency_ms: null },
|
||||
);
|
||||
const groupScopeHealth = groupHealthById.get(group.id);
|
||||
const merged = repos.mergeHealthAggregates([
|
||||
groupScopeHealth,
|
||||
...services.map((s) => ({
|
||||
health_status: s.health_status,
|
||||
health_latency_ms: s.health_latency_ms,
|
||||
})),
|
||||
]);
|
||||
return {
|
||||
...group,
|
||||
services,
|
||||
health_status: merged.health_status,
|
||||
health_latency_ms: merged.health_latency_ms,
|
||||
};
|
||||
});
|
||||
|
||||
const ungrouped = ungroupedRaw.map(
|
||||
(s) =>
|
||||
healthById.get(s.id) ?? {
|
||||
...s,
|
||||
health_status: "unknown" as const,
|
||||
health_latency_ms: null,
|
||||
},
|
||||
);
|
||||
|
||||
return { groups: groupViews, ungrouped };
|
||||
}
|
||||
|
||||
@@ -1224,7 +1288,8 @@ export async function updateConfig(
|
||||
|
||||
void syncServiceToVpsTracker(db, id, removedBindingIds);
|
||||
|
||||
return buildView(db, id);
|
||||
const [view] = attachServiceHealth(db, [await buildView(db, id)]);
|
||||
return view!;
|
||||
}
|
||||
|
||||
export async function createGroup(
|
||||
@@ -1318,12 +1383,18 @@ export async function toggleService(
|
||||
if (!enabled) {
|
||||
await cleanupServiceDnsOnly(db, cf, serviceId);
|
||||
await syncGroupDomainForService(db, cf, serviceId);
|
||||
return buildView(db, serviceId);
|
||||
const [disabledView] = attachServiceHealth(db, [
|
||||
await buildView(db, serviceId),
|
||||
]);
|
||||
return disabledView!;
|
||||
}
|
||||
|
||||
await syncServiceBindingsToDns(db, cf, serviceId);
|
||||
await syncGroupDomainForService(db, cf, serviceId);
|
||||
return buildView(db, serviceId);
|
||||
const [enabledView] = attachServiceHealth(db, [
|
||||
await buildView(db, serviceId),
|
||||
]);
|
||||
return enabledView!;
|
||||
}
|
||||
|
||||
export async function toggleGroup(
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildApp } from "../src/app.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import { repos } from "@cfdm/db";
|
||||
|
||||
async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
|
||||
const config = loadConfig();
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/auth/login",
|
||||
payload: { username: config.adminUsername, password: "admin" },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const { token } = res.json() as { token: string };
|
||||
return { authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
describe("service groups health enrichment", () => {
|
||||
it("GET /service-groups returns health_status from ip_health_status", async () => {
|
||||
const app = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
});
|
||||
const headers = await authHeaders(app);
|
||||
|
||||
const domain = repos.createDomain(app.db, null, "example.com", "zone-1");
|
||||
const group = repos.createServiceGroup(
|
||||
app.db,
|
||||
"VPN",
|
||||
"vpn",
|
||||
null,
|
||||
"vpn.example.com",
|
||||
);
|
||||
const service = repos.createService(app.db, "Panel", "panel");
|
||||
repos.setServiceGroup(app.db, service.id, group.id);
|
||||
const binding = repos.insertBinding(
|
||||
app.db,
|
||||
domain.id,
|
||||
service.id,
|
||||
"panel",
|
||||
null,
|
||||
);
|
||||
repos.replaceBindingIpsWithMeta(app.db, binding.id, [
|
||||
{ ip: "1.2.3.4", weight: 1, priority: 1 },
|
||||
]);
|
||||
repos.upsertIpHealthStatus(
|
||||
app.db,
|
||||
"binding",
|
||||
binding.id,
|
||||
"1.2.3.4",
|
||||
"degraded",
|
||||
120,
|
||||
1,
|
||||
null,
|
||||
);
|
||||
repos.upsertIpHealthStatus(
|
||||
app.db,
|
||||
"group",
|
||||
group.id,
|
||||
"5.6.7.8",
|
||||
"up",
|
||||
40,
|
||||
0,
|
||||
null,
|
||||
);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/service-groups",
|
||||
headers,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json() as {
|
||||
groups: Array<{
|
||||
id: number;
|
||||
health_status: string;
|
||||
health_latency_ms: number | null;
|
||||
services: Array<{
|
||||
id: number;
|
||||
health_status: string;
|
||||
health_latency_ms: number | null;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
const groupView = body.groups.find((g) => g.id === group.id);
|
||||
expect(groupView).toBeDefined();
|
||||
expect(groupView!.services[0]?.health_status).toBe("degraded");
|
||||
expect(groupView!.services[0]?.health_latency_ms).toBe(120);
|
||||
// group worst = degraded (from service) over up (group scope)
|
||||
expect(groupView!.health_status).toBe("degraded");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user