filter resources by status

This commit is contained in:
Fred KISSIE
2026-02-05 03:15:18 +01:00
parent 67949b4968
commit d309ec249e
5 changed files with 185 additions and 80 deletions

View File

@@ -187,7 +187,9 @@ export const targetHealthCheck = pgTable("targetHealthCheck", {
hcFollowRedirects: boolean("hcFollowRedirects").default(true), hcFollowRedirects: boolean("hcFollowRedirects").default(true),
hcMethod: varchar("hcMethod").default("GET"), hcMethod: varchar("hcMethod").default("GET"),
hcStatus: integer("hcStatus"), // http code hcStatus: integer("hcStatus"), // http code
hcHealth: text("hcHealth").default("unknown"), // "unknown", "healthy", "unhealthy" hcHealth: text("hcHealth")
.$type<"unknown" | "healthy" | "unhealthy">()
.default("unknown"), // "unknown", "healthy", "unhealthy"
hcTlsServerName: text("hcTlsServerName") hcTlsServerName: text("hcTlsServerName")
}); });

View File

@@ -213,7 +213,9 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", {
}).default(true), }).default(true),
hcMethod: text("hcMethod").default("GET"), hcMethod: text("hcMethod").default("GET"),
hcStatus: integer("hcStatus"), // http code hcStatus: integer("hcStatus"), // http code
hcHealth: text("hcHealth").default("unknown"), // "unknown", "healthy", "unhealthy" hcHealth: text("hcHealth")
.$type<"unknown" | "healthy" | "unhealthy">()
.default("unknown"), // "unknown", "healthy", "unhealthy"
hcTlsServerName: text("hcTlsServerName") hcTlsServerName: text("hcTlsServerName")
}); });

View File

@@ -27,7 +27,8 @@ import {
ilike, ilike,
asc, asc,
not, not,
isNull isNull,
type SQL
} from "drizzle-orm"; } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromZodError } from "zod-validation-error"; import { fromZodError } from "zod-validation-error";
@@ -63,7 +64,7 @@ const listResourcesSchema = z.object({
.optional() .optional()
.catch(undefined), .catch(undefined),
healthStatus: z healthStatus: z
.enum(["online", "degraded", "offline", "unknown"]) .enum(["no_targets", "healthy", "degraded", "offline", "unknown"])
.optional() .optional()
.catch(undefined) .catch(undefined)
}); });
@@ -86,13 +87,18 @@ type JoinedRow = {
domainId: string | null; domainId: string | null;
headerAuthId: number | null; headerAuthId: number | null;
targetId: number | null; // total_targets: number;
targetIp: string | null; // healthy_targets: number;
targetPort: number | null; // unhealthy_targets: number;
targetEnabled: boolean | null; // unknown_targets: number;
hcHealth: string | null; // targetId: number | null;
hcEnabled: boolean | null; // targetIp: string | null;
// targetPort: number | null;
// targetEnabled: boolean | null;
// hcHealth: string | null;
// hcEnabled: boolean | null;
}; };
// grouped by resource with targets[]) // grouped by resource with targets[])
@@ -117,10 +123,68 @@ export type ResourceWithTargets = {
ip: string; ip: string;
port: number; port: number;
enabled: boolean; enabled: boolean;
healthStatus?: "healthy" | "unhealthy" | "unknown"; healthStatus: "healthy" | "unhealthy" | "unknown" | null;
}>; }>;
}; };
// Aggregate filters
const total_targets = count(targets.targetId);
const healthy_targets = sql<number>`SUM(
CASE
WHEN ${targetHealthCheck.hcHealth} = 'healthy' THEN 1
ELSE 0
END
) `;
const unknown_targets = sql<number>`SUM(
CASE
WHEN ${targetHealthCheck.hcHealth} = 'unknown' THEN 1
ELSE 0
END
) `;
const unhealthy_targets = sql<number>`SUM(
CASE
WHEN ${targetHealthCheck.hcHealth} = 'unhealthy' THEN 1
ELSE 0
END
) `;
function countResourcesBase() {
return db
.select({ count: count() })
.from(resources)
.leftJoin(
resourcePassword,
eq(resourcePassword.resourceId, resources.resourceId)
)
.leftJoin(
resourcePincode,
eq(resourcePincode.resourceId, resources.resourceId)
)
.leftJoin(
resourceHeaderAuth,
eq(resourceHeaderAuth.resourceId, resources.resourceId)
)
.leftJoin(
resourceHeaderAuthExtendedCompatibility,
eq(
resourceHeaderAuthExtendedCompatibility.resourceId,
resources.resourceId
)
)
.leftJoin(targets, eq(targets.resourceId, resources.resourceId))
.leftJoin(
targetHealthCheck,
eq(targetHealthCheck.targetId, targets.targetId)
)
.groupBy(
resources.resourceId,
resourcePassword.passwordId,
resourcePincode.pincodeId,
resourceHeaderAuth.headerAuthId,
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId
);
}
function queryResourcesBase() { function queryResourcesBase() {
return db return db
.select({ .select({
@@ -140,14 +204,7 @@ function queryResourcesBase() {
niceId: resources.niceId, niceId: resources.niceId,
headerAuthId: resourceHeaderAuth.headerAuthId, headerAuthId: resourceHeaderAuth.headerAuthId,
headerAuthExtendedCompatibilityId: headerAuthExtendedCompatibilityId:
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId, resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId
targetId: targets.targetId,
targetIp: targets.ip,
targetPort: targets.port,
targetEnabled: targets.enabled,
hcHealth: targetHealthCheck.hcHealth,
hcEnabled: targetHealthCheck.hcEnabled
}) })
.from(resources) .from(resources)
.leftJoin( .leftJoin(
@@ -173,6 +230,13 @@ function queryResourcesBase() {
.leftJoin( .leftJoin(
targetHealthCheck, targetHealthCheck,
eq(targetHealthCheck.targetId, targets.targetId) eq(targetHealthCheck.targetId, targets.targetId)
)
.groupBy(
resources.resourceId,
resourcePassword.passwordId,
resourcePincode.pincodeId,
resourceHeaderAuth.headerAuthId,
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId
); );
} }
@@ -323,45 +387,52 @@ export async function listResources(
} }
} }
let aggregateFilters: SQL<any> | null | undefined = null;
if (typeof healthStatus !== "undefined") { if (typeof healthStatus !== "undefined") {
switch (healthStatus) { switch (healthStatus) {
case "online": case "healthy":
aggregateFilters = and(
sql`${total_targets} > 0`,
sql`${healthy_targets} = ${total_targets}`
);
break; break;
default: case "degraded":
aggregateFilters = and(
sql`${total_targets} > 0`,
sql`${unhealthy_targets} > 0`
);
break;
case "no_targets":
aggregateFilters = sql`${total_targets} = 0`;
break;
case "offline":
aggregateFilters = and(
sql`${total_targets} > 0`,
sql`${healthy_targets} = 0`,
sql`${unhealthy_targets} = ${total_targets}`
);
break;
case "unknown":
aggregateFilters = and(
sql`${total_targets} > 0`,
sql`${unknown_targets} = ${total_targets}`
);
break; break;
} }
} }
const countQuery: any = db let baseQuery = queryResourcesBase();
.select({ count: count() }) let countQuery = countResourcesBase().where(conditions);
.from(resources)
.leftJoin(
resourcePassword,
eq(resourcePassword.resourceId, resources.resourceId)
)
.leftJoin(
resourcePincode,
eq(resourcePincode.resourceId, resources.resourceId)
)
.leftJoin(
resourceHeaderAuth,
eq(resourceHeaderAuth.resourceId, resources.resourceId)
)
.leftJoin(
resourceHeaderAuthExtendedCompatibility,
eq(
resourceHeaderAuthExtendedCompatibility.resourceId,
resources.resourceId
)
)
.leftJoin(targets, eq(targets.resourceId, resources.resourceId))
.leftJoin(
targetHealthCheck,
eq(targetHealthCheck.targetId, targets.targetId)
)
.where(conditions);
const baseQuery = queryResourcesBase(); if (aggregateFilters) {
// @ts-expect-error idk why this is causing a type error
baseQuery = baseQuery.having(aggregateFilters);
}
if (aggregateFilters) {
// @ts-expect-error idk why this is causing a type error
countQuery = countQuery.having(aggregateFilters);
}
const rows: JoinedRow[] = await baseQuery const rows: JoinedRow[] = await baseQuery
.where(conditions) .where(conditions)
@@ -369,6 +440,27 @@ export async function listResources(
.offset(pageSize * (page - 1)) .offset(pageSize * (page - 1))
.orderBy(asc(resources.resourceId)); .orderBy(asc(resources.resourceId));
const resourceIdList = rows.map((row) => row.resourceId);
const allResourceTargets =
resourceIdList.length === 0
? []
: await db
.select({
targetId: targets.targetId,
resourceId: targets.resourceId,
ip: targets.ip,
port: targets.port,
enabled: targets.enabled,
healthStatus: targetHealthCheck.hcHealth,
hcEnabled: targetHealthCheck.hcEnabled
})
.from(targets)
.where(sql`${targets.resourceId} in ${resourceIdList}`)
.leftJoin(
targetHealthCheck,
eq(targetHealthCheck.targetId, targets.targetId)
);
// avoids TS issues with reduce/never[] // avoids TS issues with reduce/never[]
const map = new Map<number, ResourceWithTargets>(); const map = new Map<number, ResourceWithTargets>();
@@ -396,30 +488,9 @@ export async function listResources(
map.set(row.resourceId, entry); map.set(row.resourceId, entry);
} }
if ( entry.targets = allResourceTargets.filter(
row.targetId != null && (t) => t.resourceId === entry.resourceId
row.targetIp && );
row.targetPort != null &&
row.targetEnabled != null
) {
let healthStatus: "healthy" | "unhealthy" | "unknown" =
"unknown";
if (row.hcEnabled && row.hcHealth) {
healthStatus = row.hcHealth as
| "healthy"
| "unhealthy"
| "unknown";
}
entry.targets.push({
targetId: row.targetId,
ip: row.targetIp,
port: row.targetPort,
enabled: row.targetEnabled,
healthStatus: healthStatus
});
}
} }
const resourcesList: ResourceWithTargets[] = Array.from(map.values()); const resourcesList: ResourceWithTargets[] = Array.from(map.values());

View File

@@ -111,6 +111,9 @@ const listSitesSchema = z.object({
.catch(undefined) .catch(undefined)
}); });
function countSitesBase() {
return db.select({ count: count() }).from(sites);
}
function querySitesBase() { function querySitesBase() {
return db return db
.select({ .select({
@@ -242,10 +245,7 @@ export async function listSites(
conditions = and(conditions, eq(sites.online, online)); conditions = and(conditions, eq(sites.online, online));
} }
const countQuery = db const countQuery = countSitesBase().where(conditions);
.select({ count: count() })
.from(sites)
.where(conditions);
const siteListQuery = baseQuery const siteListQuery = baseQuery
.where(conditions) .where(conditions)

View File

@@ -45,7 +45,7 @@ export type TargetHealth = {
ip: string; ip: string;
port: number; port: number;
enabled: boolean; enabled: boolean;
healthStatus?: "healthy" | "unhealthy" | "unknown"; healthStatus: "healthy" | "unhealthy" | "unknown" | null;
}; };
export type ResourceRow = { export type ResourceRow = {
@@ -347,7 +347,33 @@ export default function ProxyResourcesTable({
id: "status", id: "status",
accessorKey: "status", accessorKey: "status",
friendlyName: t("status"), friendlyName: t("status"),
header: () => <span className="p-3">{t("status")}</span>, header: () => (
<ColumnFilterButton
options={[
{ value: "healthy", label: t("resourcesTableHealthy") },
{
value: "degraded",
label: t("resourcesTableDegraded")
},
{ value: "offline", label: t("resourcesTableOffline") },
{
value: "no_targets",
label: t("resourcesTableNoTargets")
},
{ value: "unknown", label: t("resourcesTableUnknown") }
]}
selectedValue={
searchParams.get("healthStatus") ?? undefined
}
onValueChange={(value) =>
handleFilterChange("healthStatus", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("status")}
className="p-3"
/>
),
cell: ({ row }) => { cell: ({ row }) => {
const resourceRow = row.original; const resourceRow = row.original;
return <TargetStatusCell targets={resourceRow.targets} />; return <TargetStatusCell targets={resourceRow.targets} />;
@@ -558,6 +584,10 @@ export default function ProxyResourcesTable({
}); });
}, 300); }, 300);
console.log({
rowCount
});
return ( return (
<> <>
{selectedResource && ( {selectedResource && (