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:
Vendored
+15
-2
File diff suppressed because one or more lines are too long
Vendored
+106
@@ -415,6 +415,8 @@ function getAppSwitcher(db) {
|
||||
var repos_exports = {};
|
||||
__export(repos_exports, {
|
||||
addDomainTags: () => addDomainTags,
|
||||
aggregateIpHealthByRefs: () => aggregateIpHealthByRefs,
|
||||
aggregateIpHealthByServiceIds: () => aggregateIpHealthByServiceIds,
|
||||
bindingsToRemove: () => bindingsToRemove,
|
||||
countCertificatesByStatus: () => countCertificatesByStatus,
|
||||
createDomain: () => createDomain,
|
||||
@@ -489,6 +491,7 @@ __export(repos_exports, {
|
||||
listSubdomainsByDomain: () => listSubdomainsByDomain,
|
||||
listUngroupedServices: () => listUngroupedServices,
|
||||
markDnsPendingDelete: () => markDnsPendingDelete,
|
||||
mergeHealthAggregates: () => mergeHealthAggregates,
|
||||
reorderServices: () => reorderServices,
|
||||
replaceBindingIps: () => replaceBindingIps,
|
||||
replaceBindingIpsWithMeta: () => replaceBindingIpsWithMeta,
|
||||
@@ -1294,6 +1297,109 @@ function listIpHealthStatus(db, scope, refId) {
|
||||
WHERE scope = ${scope} AND ref_id = ${refId}
|
||||
`);
|
||||
}
|
||||
var UNKNOWN_HEALTH = {
|
||||
health_status: "unknown",
|
||||
health_latency_ms: null
|
||||
};
|
||||
function parseHealthAggregateRow(row) {
|
||||
const status = row.health_status;
|
||||
if (status === "up" || status === "down" || status === "degraded" || status === "unknown") {
|
||||
return {
|
||||
health_status: status,
|
||||
health_latency_ms: row.health_latency_ms ?? null
|
||||
};
|
||||
}
|
||||
return UNKNOWN_HEALTH;
|
||||
}
|
||||
var WORST_HEALTH_SQL = sql2.raw(`CASE
|
||||
WHEN MAX(CASE
|
||||
WHEN status = 'down' THEN 3
|
||||
WHEN status = 'degraded' THEN 2
|
||||
WHEN status = 'up' THEN 1
|
||||
ELSE 0
|
||||
END) = 3 THEN 'down'
|
||||
WHEN MAX(CASE
|
||||
WHEN status = 'down' THEN 3
|
||||
WHEN status = 'degraded' THEN 2
|
||||
WHEN status = 'up' THEN 1
|
||||
ELSE 0
|
||||
END) = 2 THEN 'degraded'
|
||||
WHEN MAX(CASE
|
||||
WHEN status = 'down' THEN 3
|
||||
WHEN status = 'degraded' THEN 2
|
||||
WHEN status = 'up' THEN 1
|
||||
ELSE 0
|
||||
END) = 1 THEN 'up'
|
||||
ELSE 'unknown'
|
||||
END`);
|
||||
function aggregateIpHealthByRefs(db, scope, refIds) {
|
||||
const result = /* @__PURE__ */ new Map();
|
||||
if (refIds.length === 0) return result;
|
||||
const idList = sql2.join(
|
||||
refIds.map((id) => sql2`${id}`),
|
||||
sql2`, `
|
||||
);
|
||||
const rows = db.all(sql2`
|
||||
SELECT ref_id,
|
||||
${WORST_HEALTH_SQL} AS health_status,
|
||||
MAX(latency_ms) AS health_latency_ms
|
||||
FROM ip_health_status
|
||||
WHERE scope = ${scope} AND ref_id IN (${idList})
|
||||
GROUP BY ref_id
|
||||
`);
|
||||
for (const row of rows) {
|
||||
result.set(row.ref_id, parseHealthAggregateRow(row));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function aggregateIpHealthByServiceIds(db, serviceIds) {
|
||||
const result = /* @__PURE__ */ new Map();
|
||||
if (serviceIds.length === 0) return result;
|
||||
const idList = sql2.join(
|
||||
serviceIds.map((id) => sql2`${id}`),
|
||||
sql2`, `
|
||||
);
|
||||
const rows = db.all(sql2`
|
||||
SELECT sb.service_id AS service_id,
|
||||
${WORST_HEALTH_SQL} AS health_status,
|
||||
MAX(ihs.latency_ms) AS health_latency_ms
|
||||
FROM ip_health_status ihs
|
||||
INNER JOIN service_bindings sb
|
||||
ON ihs.scope = 'binding' AND ihs.ref_id = sb.id
|
||||
WHERE sb.service_id IN (${idList})
|
||||
GROUP BY sb.service_id
|
||||
`);
|
||||
for (const row of rows) {
|
||||
result.set(row.service_id, parseHealthAggregateRow(row));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function mergeHealthAggregates(parts) {
|
||||
const rank = {
|
||||
unknown: 0,
|
||||
up: 1,
|
||||
degraded: 2,
|
||||
down: 3
|
||||
};
|
||||
let worst = UNKNOWN_HEALTH;
|
||||
let hasAny = false;
|
||||
for (const part of parts) {
|
||||
if (!part) continue;
|
||||
hasAny = true;
|
||||
if (rank[part.health_status] > rank[worst.health_status]) {
|
||||
worst = {
|
||||
health_status: part.health_status,
|
||||
health_latency_ms: part.health_latency_ms
|
||||
};
|
||||
} else if (part.health_status === worst.health_status && part.health_latency_ms != null && (worst.health_latency_ms == null || part.health_latency_ms > worst.health_latency_ms)) {
|
||||
worst = {
|
||||
health_status: worst.health_status,
|
||||
health_latency_ms: part.health_latency_ms
|
||||
};
|
||||
}
|
||||
}
|
||||
return hasAny ? worst : UNKNOWN_HEALTH;
|
||||
}
|
||||
function getIpHealthStatusRow(db, scope, refId, ip) {
|
||||
const rows = db.all(sql2`
|
||||
SELECT scope, ref_id, ip, status, latency_ms, consecutive_failures,
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
HealthCheckScope,
|
||||
HealthCheckTarget,
|
||||
HealthCheckType,
|
||||
IpHealthState,
|
||||
IpHealthStatus,
|
||||
LbMode,
|
||||
Service,
|
||||
@@ -1466,6 +1467,152 @@ export function listIpHealthStatus(
|
||||
`);
|
||||
}
|
||||
|
||||
export type HealthAggregate = {
|
||||
health_status: IpHealthState;
|
||||
health_latency_ms: number | null;
|
||||
};
|
||||
|
||||
const UNKNOWN_HEALTH: HealthAggregate = {
|
||||
health_status: "unknown",
|
||||
health_latency_ms: null,
|
||||
};
|
||||
|
||||
function parseHealthAggregateRow(row: {
|
||||
health_status: string | null;
|
||||
health_latency_ms: number | null;
|
||||
}): HealthAggregate {
|
||||
const status = row.health_status;
|
||||
if (
|
||||
status === "up" ||
|
||||
status === "down" ||
|
||||
status === "degraded" ||
|
||||
status === "unknown"
|
||||
) {
|
||||
return {
|
||||
health_status: status,
|
||||
health_latency_ms: row.health_latency_ms ?? null,
|
||||
};
|
||||
}
|
||||
return UNKNOWN_HEALTH;
|
||||
}
|
||||
|
||||
const WORST_HEALTH_SQL = sql.raw(`CASE
|
||||
WHEN MAX(CASE
|
||||
WHEN status = 'down' THEN 3
|
||||
WHEN status = 'degraded' THEN 2
|
||||
WHEN status = 'up' THEN 1
|
||||
ELSE 0
|
||||
END) = 3 THEN 'down'
|
||||
WHEN MAX(CASE
|
||||
WHEN status = 'down' THEN 3
|
||||
WHEN status = 'degraded' THEN 2
|
||||
WHEN status = 'up' THEN 1
|
||||
ELSE 0
|
||||
END) = 2 THEN 'degraded'
|
||||
WHEN MAX(CASE
|
||||
WHEN status = 'down' THEN 3
|
||||
WHEN status = 'degraded' THEN 2
|
||||
WHEN status = 'up' THEN 1
|
||||
ELSE 0
|
||||
END) = 1 THEN 'up'
|
||||
ELSE 'unknown'
|
||||
END`);
|
||||
|
||||
/** Worst status across ip_health_status rows for each ref_id in a scope. */
|
||||
export function aggregateIpHealthByRefs(
|
||||
db: Db,
|
||||
scope: HealthCheckScope,
|
||||
refIds: number[],
|
||||
): Map<number, HealthAggregate> {
|
||||
const result = new Map<number, HealthAggregate>();
|
||||
if (refIds.length === 0) return result;
|
||||
const idList = sql.join(
|
||||
refIds.map((id) => sql`${id}`),
|
||||
sql`, `,
|
||||
);
|
||||
const rows = db.all<{
|
||||
ref_id: number;
|
||||
health_status: string | null;
|
||||
health_latency_ms: number | null;
|
||||
}>(sql`
|
||||
SELECT ref_id,
|
||||
${WORST_HEALTH_SQL} AS health_status,
|
||||
MAX(latency_ms) AS health_latency_ms
|
||||
FROM ip_health_status
|
||||
WHERE scope = ${scope} AND ref_id IN (${idList})
|
||||
GROUP BY ref_id
|
||||
`);
|
||||
for (const row of rows) {
|
||||
result.set(row.ref_id, parseHealthAggregateRow(row));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Worst binding-scope health rolled up per service_id. */
|
||||
export function aggregateIpHealthByServiceIds(
|
||||
db: Db,
|
||||
serviceIds: number[],
|
||||
): Map<number, HealthAggregate> {
|
||||
const result = new Map<number, HealthAggregate>();
|
||||
if (serviceIds.length === 0) return result;
|
||||
const idList = sql.join(
|
||||
serviceIds.map((id) => sql`${id}`),
|
||||
sql`, `,
|
||||
);
|
||||
const rows = db.all<{
|
||||
service_id: number;
|
||||
health_status: string | null;
|
||||
health_latency_ms: number | null;
|
||||
}>(sql`
|
||||
SELECT sb.service_id AS service_id,
|
||||
${WORST_HEALTH_SQL} AS health_status,
|
||||
MAX(ihs.latency_ms) AS health_latency_ms
|
||||
FROM ip_health_status ihs
|
||||
INNER JOIN service_bindings sb
|
||||
ON ihs.scope = 'binding' AND ihs.ref_id = sb.id
|
||||
WHERE sb.service_id IN (${idList})
|
||||
GROUP BY sb.service_id
|
||||
`);
|
||||
for (const row of rows) {
|
||||
result.set(row.service_id, parseHealthAggregateRow(row));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function mergeHealthAggregates(
|
||||
parts: Array<HealthAggregate | undefined | null>,
|
||||
): HealthAggregate {
|
||||
const rank: Record<IpHealthState, number> = {
|
||||
unknown: 0,
|
||||
up: 1,
|
||||
degraded: 2,
|
||||
down: 3,
|
||||
};
|
||||
let worst: HealthAggregate = UNKNOWN_HEALTH;
|
||||
let hasAny = false;
|
||||
for (const part of parts) {
|
||||
if (!part) continue;
|
||||
hasAny = true;
|
||||
if (rank[part.health_status] > rank[worst.health_status]) {
|
||||
worst = {
|
||||
health_status: part.health_status,
|
||||
health_latency_ms: part.health_latency_ms,
|
||||
};
|
||||
} else if (
|
||||
part.health_status === worst.health_status &&
|
||||
part.health_latency_ms != null &&
|
||||
(worst.health_latency_ms == null ||
|
||||
part.health_latency_ms > worst.health_latency_ms)
|
||||
) {
|
||||
worst = {
|
||||
health_status: worst.health_status,
|
||||
health_latency_ms: part.health_latency_ms,
|
||||
};
|
||||
}
|
||||
}
|
||||
return hasAny ? worst : UNKNOWN_HEALTH;
|
||||
}
|
||||
|
||||
export function getIpHealthStatusRow(
|
||||
db: Db,
|
||||
scope: HealthCheckScope,
|
||||
|
||||
Vendored
+42
@@ -497,6 +497,13 @@ declare const serviceViewSchema: z.ZodObject<{
|
||||
target_ip_priorities?: Record<string, number> | undefined;
|
||||
target_cname?: string | null | undefined;
|
||||
}>>>>;
|
||||
health_status: z.ZodDefault<z.ZodEnum<{
|
||||
unknown: "unknown";
|
||||
up: "up";
|
||||
down: "down";
|
||||
degraded: "degraded";
|
||||
}>>;
|
||||
health_latency_ms: z.ZodDefault<z.ZodNullable<z.ZodNumber>>;
|
||||
}, z.core.$strip>;
|
||||
declare const serviceGroupViewSchema: z.ZodObject<{
|
||||
id: z.ZodNumber;
|
||||
@@ -619,7 +626,21 @@ declare const serviceGroupViewSchema: z.ZodObject<{
|
||||
target_ip_priorities?: Record<string, number> | undefined;
|
||||
target_cname?: string | null | undefined;
|
||||
}>>>>;
|
||||
health_status: z.ZodDefault<z.ZodEnum<{
|
||||
unknown: "unknown";
|
||||
up: "up";
|
||||
down: "down";
|
||||
degraded: "degraded";
|
||||
}>>;
|
||||
health_latency_ms: z.ZodDefault<z.ZodNullable<z.ZodNumber>>;
|
||||
}, z.core.$strip>>>;
|
||||
health_status: z.ZodDefault<z.ZodEnum<{
|
||||
unknown: "unknown";
|
||||
up: "up";
|
||||
down: "down";
|
||||
degraded: "degraded";
|
||||
}>>;
|
||||
health_latency_ms: z.ZodDefault<z.ZodNullable<z.ZodNumber>>;
|
||||
}, z.core.$strip>;
|
||||
declare const serviceGroupsResponseSchema: z.ZodObject<{
|
||||
groups: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
@@ -743,7 +764,21 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
|
||||
target_ip_priorities?: Record<string, number> | undefined;
|
||||
target_cname?: string | null | undefined;
|
||||
}>>>>;
|
||||
health_status: z.ZodDefault<z.ZodEnum<{
|
||||
unknown: "unknown";
|
||||
up: "up";
|
||||
down: "down";
|
||||
degraded: "degraded";
|
||||
}>>;
|
||||
health_latency_ms: z.ZodDefault<z.ZodNullable<z.ZodNumber>>;
|
||||
}, z.core.$strip>>>;
|
||||
health_status: z.ZodDefault<z.ZodEnum<{
|
||||
unknown: "unknown";
|
||||
up: "up";
|
||||
down: "down";
|
||||
degraded: "degraded";
|
||||
}>>;
|
||||
health_latency_ms: z.ZodDefault<z.ZodNullable<z.ZodNumber>>;
|
||||
}, z.core.$strip>>>;
|
||||
ungrouped: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
id: z.ZodNumber;
|
||||
@@ -834,6 +869,13 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
|
||||
target_ip_priorities?: Record<string, number> | undefined;
|
||||
target_cname?: string | null | undefined;
|
||||
}>>>>;
|
||||
health_status: z.ZodDefault<z.ZodEnum<{
|
||||
unknown: "unknown";
|
||||
up: "up";
|
||||
down: "down";
|
||||
degraded: "degraded";
|
||||
}>>;
|
||||
health_latency_ms: z.ZodDefault<z.ZodNullable<z.ZodNumber>>;
|
||||
}, z.core.$strip>>>;
|
||||
}, z.core.$strip>;
|
||||
declare const domainSchema: z.ZodObject<{
|
||||
|
||||
Vendored
+6
-2
@@ -257,10 +257,14 @@ var serviceViewSchema = serviceSchema.extend({
|
||||
subdomain: z.string().default(""),
|
||||
enabled: z.coerce.boolean().default(false),
|
||||
ips: z.array(z.string()).default([]),
|
||||
domains: z.array(serviceDomainBindingSchema).default([])
|
||||
domains: z.array(serviceDomainBindingSchema).default([]),
|
||||
health_status: ipHealthStateSchema.default("unknown"),
|
||||
health_latency_ms: z.number().nullable().default(null)
|
||||
});
|
||||
var serviceGroupViewSchema = serviceGroupSchema.extend({
|
||||
services: z.array(serviceViewSchema).default([])
|
||||
services: z.array(serviceViewSchema).default([]),
|
||||
health_status: ipHealthStateSchema.default("unknown"),
|
||||
health_latency_ms: z.number().nullable().default(null)
|
||||
});
|
||||
var serviceGroupsResponseSchema = z.object({
|
||||
groups: z.array(serviceGroupViewSchema).default([]),
|
||||
|
||||
@@ -132,10 +132,14 @@ export const serviceViewSchema = serviceSchema.extend({
|
||||
enabled: z.coerce.boolean().default(false),
|
||||
ips: z.array(z.string()).default([]),
|
||||
domains: z.array(serviceDomainBindingSchema).default([]),
|
||||
health_status: ipHealthStateSchema.default('unknown'),
|
||||
health_latency_ms: z.number().nullable().default(null),
|
||||
})
|
||||
|
||||
export const serviceGroupViewSchema = serviceGroupSchema.extend({
|
||||
services: z.array(serviceViewSchema).default([]),
|
||||
health_status: ipHealthStateSchema.default('unknown'),
|
||||
health_latency_ms: z.number().nullable().default(null),
|
||||
})
|
||||
|
||||
export const serviceGroupsResponseSchema = z.object({
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface ServiceGroup {
|
||||
|
||||
export interface ServiceGroupView extends ServiceGroup {
|
||||
services: ServiceView[];
|
||||
health_status: IpHealthState;
|
||||
health_latency_ms: number | null;
|
||||
}
|
||||
|
||||
export interface ServiceGroupsResponse {
|
||||
@@ -185,6 +187,8 @@ export interface ServiceView {
|
||||
updated_at: string;
|
||||
ips: string[];
|
||||
domains: ServiceDomainBindingView[];
|
||||
health_status: IpHealthState;
|
||||
health_latency_ms: number | null;
|
||||
}
|
||||
|
||||
export interface GroupWithStats extends Group {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user