diff --git a/messages/en-US.json b/messages/en-US.json index 6da7ccb94..e4d55e4b9 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1499,6 +1499,22 @@ "standaloneHcColumnHealth": "Health", "standaloneHcColumnMode": "Mode", "standaloneHcColumnTarget": "Target", + "standaloneHcHealthStateHealthy": "Healthy", + "standaloneHcHealthStateUnhealthy": "Unhealthy", + "standaloneHcHealthStateUnknown": "Unknown", + "standaloneHcFilterAnySite": "All sites", + "standaloneHcFilterAnyResource": "All resources", + "standaloneHcFilterMode": "Mode", + "standaloneHcFilterModeHttp": "HTTP", + "standaloneHcFilterModeTcp": "TCP", + "standaloneHcFilterModeSnmp": "SNMP", + "standaloneHcFilterModePing": "Ping", + "standaloneHcFilterHealth": "Health", + "standaloneHcFilterEnabled": "Enabled", + "standaloneHcFilterEnabledOn": "Enabled", + "standaloneHcFilterEnabledOff": "Disabled", + "standaloneHcFilterSiteIdFallback": "Site {id}", + "standaloneHcFilterResourceIdFallback": "Resource {id}", "blueprints": "Blueprints", "blueprintsDescription": "Apply declarative configurations and view previous runs", "blueprintAdd": "Add Blueprint", diff --git a/server/private/routers/healthChecks/listHealthChecks.ts b/server/private/routers/healthChecks/listHealthChecks.ts index e87525a3f..26cb75e9c 100644 --- a/server/private/routers/healthChecks/listHealthChecks.ts +++ b/server/private/routers/healthChecks/listHealthChecks.ts @@ -17,7 +17,7 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; -import { and, eq, isNotNull, like, sql } from "drizzle-orm"; +import { and, eq, exists, isNotNull, like, sql } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import { z } from "zod"; import { fromError } from "zod-validation-error"; @@ -40,7 +40,23 @@ const querySchema = z.object({ .default("0") .transform(Number) .pipe(z.int().nonnegative()), - query: z.string().optional() + query: z.string().optional(), + hcMode: z.enum(["http", "tcp", "snmp", "ping"]).optional(), + siteId: z + .string() + .optional() + .transform((s) => (s == null || s === "" ? undefined : Number(s))) + .pipe(z.union([z.undefined(), z.number().int().positive()])), + resourceId: z + .string() + .optional() + .transform((s) => (s == null || s === "" ? undefined : Number(s))) + .pipe(z.union([z.undefined(), z.number().int().positive()])), + hcHealth: z.enum(["healthy", "unhealthy", "unknown"]).optional(), + hcEnabled: z + .enum(["true", "false"]) + .optional() + .transform((v) => (v === undefined ? undefined : v === "true")) }); registry.registerPath({ @@ -81,7 +97,30 @@ export async function listHealthChecks( ) ); } - const { limit, offset, query } = parsedQuery.data; + const { + limit, + offset, + query, + hcMode, + siteId, + resourceId, + hcHealth, + hcEnabled + } = parsedQuery.data; + + const resourceIdFilter = resourceId + ? exists( + db + .select() + .from(targets) + .where( + and( + eq(targets.targetId, targetHealthCheck.targetId), + eq(targets.resourceId, resourceId) + ) + ) + ) + : undefined; const whereClause = and( eq(targetHealthCheck.orgId, orgId), @@ -91,6 +130,13 @@ export async function listHealthChecks( sql`LOWER(${targetHealthCheck.name})`, `%${query.toLowerCase()}%` ) + : undefined, + hcMode ? eq(targetHealthCheck.hcMode, hcMode) : undefined, + siteId ? eq(targetHealthCheck.siteId, siteId) : undefined, + resourceIdFilter, + hcHealth ? eq(targetHealthCheck.hcHealth, hcHealth) : undefined, + hcEnabled !== undefined + ? eq(targetHealthCheck.hcEnabled, hcEnabled) : undefined ); diff --git a/src/app/[orgId]/settings/alerting/(list)/health-checks/page.tsx b/src/app/[orgId]/settings/alerting/(list)/health-checks/page.tsx index 8bb19fc8c..10e09bfe8 100644 --- a/src/app/[orgId]/settings/alerting/(list)/health-checks/page.tsx +++ b/src/app/[orgId]/settings/alerting/(list)/health-checks/page.tsx @@ -3,7 +3,9 @@ import DismissableBanner from "@app/components/DismissableBanner"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { ListHealthChecksResponse } from "@server/routers/healthChecks/types"; -import { AxiosResponse } from "axios"; +import { GetResourceResponse } from "@server/routers/resource/getResource"; +import { GetSiteResponse } from "@server/routers/site/getSite"; +import type ResponseT from "@server/types/Response"; import { HeartPulse } from "lucide-react"; import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; @@ -26,24 +28,70 @@ function parsePositiveInt(s: string | undefined): number | undefined { return n; } +function appendListFilters( + apiSp: URLSearchParams, + searchParams: URLSearchParams +) { + const query = searchParams.get("query"); + if (query) apiSp.set("query", query); + + const hcMode = searchParams.get("hcMode"); + if ( + hcMode === "http" || + hcMode === "tcp" || + hcMode === "snmp" || + hcMode === "ping" + ) { + apiSp.set("hcMode", hcMode); + } + + const hcHealth = searchParams.get("hcHealth"); + if ( + hcHealth === "healthy" || + hcHealth === "unhealthy" || + hcHealth === "unknown" + ) { + apiSp.set("hcHealth", hcHealth); + } + + const hcEnabled = searchParams.get("hcEnabled"); + if (hcEnabled === "true" || hcEnabled === "false") { + apiSp.set("hcEnabled", hcEnabled); + } + + const siteId = parsePositiveInt(searchParams.get("siteId") ?? undefined); + if (siteId) { + apiSp.set("siteId", String(siteId)); + } + + const resourceId = parsePositiveInt( + searchParams.get("resourceId") ?? undefined + ); + if (resourceId) { + apiSp.set("resourceId", String(resourceId)); + } +} + export default async function AlertingHealthChecksPage( props: AlertingHealthChecksPageProps ) { const params = await props.params; const searchParams = new URLSearchParams(await props.searchParams); - const page = Math.max(1, parsePositiveInt(searchParams.get("page") ?? undefined) ?? 1); + const page = Math.max( + 1, + parsePositiveInt(searchParams.get("page") ?? undefined) ?? 1 + ); const pageSize = Math.max( 1, parsePositiveInt(searchParams.get("pageSize") ?? undefined) ?? 20 ); const pageIndex = page - 1; - const query = searchParams.get("query") ?? undefined; const apiSp = new URLSearchParams(); apiSp.set("limit", String(pageSize)); apiSp.set("offset", String(pageIndex * pageSize)); - if (query) apiSp.set("query", query); + appendListFilters(apiSp, searchParams); let healthChecks: ListHealthChecksResponse["healthChecks"] = []; let pagination: ListHealthChecksResponse["pagination"] = { @@ -51,18 +99,80 @@ export default async function AlertingHealthChecksPage( limit: pageSize, offset: pageIndex * pageSize }; + + const siteIdParam = parsePositiveInt( + searchParams.get("siteId") ?? undefined + ); + const resourceIdParam = parsePositiveInt( + searchParams.get("resourceId") ?? undefined + ); + + const header = await authCookieHeader(); + try { - const res = await internal.get>( + const res = await internal.get( `/org/${params.orgId}/health-checks?${apiSp.toString()}`, - await authCookieHeader() + header ); - const responseData = res.data.data; - healthChecks = responseData.healthChecks; - pagination = responseData.pagination; + const responseData = (res.data as ResponseT) + .data; + if (responseData) { + healthChecks = responseData.healthChecks; + pagination = responseData.pagination; + } } catch { // leave defaults } + let initialFilterSite: { + siteId: number; + name: string; + type: string; + } | null = null; + if (siteIdParam) { + try { + const siteRes = await internal.get(`/site/${siteIdParam}`, header); + const s = (siteRes.data as ResponseT).data; + if (s && s.orgId === params.orgId) { + initialFilterSite = { + siteId: s.siteId, + name: s.name, + type: s.type + }; + } + } catch { + // leave null + } + } + + let initialFilterResource: { + name: string; + resourceId: number; + fullDomain: string | null; + niceId: string; + ssl: boolean; + } | null = null; + if (resourceIdParam) { + try { + const resourceRes = await internal.get( + `/resource/${resourceIdParam}`, + header + ); + const r = (resourceRes.data as ResponseT).data; + if (r && r.orgId === params.orgId) { + initialFilterResource = { + name: r.name, + resourceId: r.resourceId, + fullDomain: r.fullDomain, + niceId: r.niceId, + ssl: r.ssl + }; + } + } catch { + // leave null + } + } + const t = await getTranslations(); return ( @@ -80,6 +190,12 @@ export default async function AlertingHealthChecksPage( orgId={params.orgId} healthChecks={healthChecks} rowCount={pagination.total} + pagination={{ + pageIndex, + pageSize + }} + initialFilterSite={initialFilterSite} + initialFilterResource={initialFilterResource} /> ); diff --git a/src/components/HealthChecksTable.tsx b/src/components/HealthChecksTable.tsx index 510639b46..404ade547 100644 --- a/src/components/HealthChecksTable.tsx +++ b/src/components/HealthChecksTable.tsx @@ -6,23 +6,42 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import HealthCheckCredenza, { HealthCheckRow } from "@app/components/HealthCheckCredenza"; +import { ColumnFilterButton } from "@app/components/ColumnFilterButton"; +import { Badge } from "@app/components/ui/badge"; import { Button } from "@app/components/ui/button"; -import { DataTable, ExtendedColumnDef } from "@app/components/ui/data-table"; +import { + ControlledDataTable, + type ExtendedColumnDef +} from "@app/components/ui/controlled-data-table"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@app/components/ui/dropdown-menu"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; import { Switch } from "@app/components/ui/switch"; import { toast } from "@app/hooks/useToast"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { ArrowUpDown, ArrowUpRight, MoreHorizontal } from "lucide-react"; +import { Selectedsite, SitesSelector } from "@app/components/site-selector"; +import { + ResourceSelector, + SelectedResource +} from "@app/components/resource-selector"; +import { + ArrowUpDown, + ArrowUpRight, + Funnel, + MoreHorizontal +} from "lucide-react"; import { useTranslations } from "next-intl"; -import { useState, useTransition, useEffect } from "react"; +import { useState, useTransition, useEffect, useMemo } from "react"; import type { PaginationState } from "@tanstack/react-table"; -import type { DataTablePaginationState } from "@app/components/ui/data-table"; import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { useDebouncedCallback } from "use-debounce"; import Link from "next/link"; @@ -30,11 +49,15 @@ import { useRouter } from "next/navigation"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { cn } from "@app/lib/cn"; type StandaloneHealthChecksTableProps = { orgId: string; healthChecks: HealthCheckRow[]; rowCount: number; + pagination: PaginationState; + initialFilterSite?: Selectedsite | null; + initialFilterResource?: SelectedResource | null; }; function formatTarget(row: HealthCheckRow): string { @@ -43,6 +66,12 @@ function formatTarget(row: HealthCheckRow): string { if (!row.hcPort) return row.hcHostname; return `${row.hcHostname}:${row.hcPort}`; } + if (row.hcMode === "snmp" || row.hcMode === "ping") { + if (row.hcPort) { + return `${row.hcHostname}:${row.hcPort}`; + } + return row.hcHostname; + } // HTTP / default const scheme = row.hcScheme ?? "http"; const host = row.hcHostname; @@ -51,16 +80,13 @@ function formatTarget(row: HealthCheckRow): string { return `${scheme}://${host}${port}${path}`; } -const healthLabel: Record = { - healthy: "Healthy", - unhealthy: "Unhealthy", - unknown: "Unknown" -}; - export default function HealthChecksTable({ orgId, healthChecks, - rowCount + rowCount, + pagination, + initialFilterSite = null, + initialFilterResource = null }: StandaloneHealthChecksTableProps) { const router = useRouter(); const t = useTranslations(); @@ -79,15 +105,56 @@ export default function HealthChecksTable({ const [deleteOpen, setDeleteOpen] = useState(false); const [selected, setSelected] = useState(null); const [togglingId, setTogglingId] = useState(null); + const [siteFilterOpen, setSiteFilterOpen] = useState(false); + const [resourceFilterOpen, setResourceFilterOpen] = useState(false); - const page = Math.max(1, Number(searchParams.get("page") ?? 1)); - const pageSize = Math.max(1, Number(searchParams.get("pageSize") ?? 20)); - const pageIndex = page - 1; + const pageSize = pagination.pageSize; const query = searchParams.get("query") ?? undefined; + const siteIdQ = searchParams.get("siteId"); + const siteIdNum = siteIdQ ? parseInt(siteIdQ, 10) : NaN; + const selectedSite: Selectedsite | null = useMemo(() => { + if (!siteIdQ || !Number.isInteger(siteIdNum) || siteIdNum <= 0) { + return null; + } + if (initialFilterSite && initialFilterSite.siteId === siteIdNum) { + return initialFilterSite; + } + return { + siteId: siteIdNum, + name: t("standaloneHcFilterSiteIdFallback", { id: siteIdNum }), + type: "newt" + }; + }, [initialFilterSite, siteIdQ, siteIdNum, t]); + + const resourceIdQ = searchParams.get("resourceId"); + const resourceIdNum = resourceIdQ ? parseInt(resourceIdQ, 10) : NaN; + const selectedResource: SelectedResource | null = useMemo(() => { + if ( + !resourceIdQ || + !Number.isInteger(resourceIdNum) || + resourceIdNum <= 0 + ) { + return null; + } + if ( + initialFilterResource && + initialFilterResource.resourceId === resourceIdNum + ) { + return initialFilterResource; + } + return { + name: t("standaloneHcFilterResourceIdFallback", { + id: resourceIdNum + }), + resourceId: resourceIdNum, + fullDomain: null, + niceId: "", + ssl: false + }; + }, [initialFilterResource, resourceIdQ, resourceIdNum, t]); + const rows = healthChecks; - const total = rowCount; - const pageCount = Math.max(1, Math.ceil(total / pageSize)); function refreshList() { startRefresh(() => { @@ -102,12 +169,6 @@ export default function HealthChecksTable({ return () => clearInterval(interval); }, [router]); - const paginationState: DataTablePaginationState = { - pageIndex, - pageSize, - pageCount - }; - const handlePaginationChange = (newState: PaginationState) => { searchParams.set("page", (newState.pageIndex + 1).toString()); searchParams.set("pageSize", newState.pageSize.toString()); @@ -124,6 +185,39 @@ export default function HealthChecksTable({ filter({ searchParams }); }, 300); + function handleFilterChange( + column: string, + value: string | undefined | null + ) { + const sp = new URLSearchParams(searchParams); + sp.delete(column); + sp.delete("page"); + if (value) { + sp.set(column, value); + } + filter({ searchParams: sp }); + } + + const clearSiteFilter = () => { + handleFilterChange("siteId", undefined); + setSiteFilterOpen(false); + }; + + const clearResourceFilter = () => { + handleFilterChange("resourceId", undefined); + setResourceFilterOpen(false); + }; + + const onPickSite = (site: Selectedsite) => { + handleFilterChange("siteId", String(site.siteId)); + setSiteFilterOpen(false); + }; + + const onPickResource = (resource: SelectedResource) => { + handleFilterChange("resourceId", String(resource.resourceId)); + setResourceFilterOpen(false); + }; + const handleToggleEnabled = async ( row: HealthCheckRow, enabled: boolean @@ -166,6 +260,27 @@ export default function HealthChecksTable({ } }; + const modeParam = searchParams.get("hcMode"); + const selectedHcMode = + modeParam === "http" || + modeParam === "tcp" || + modeParam === "snmp" || + modeParam === "ping" + ? modeParam + : undefined; + const healthParam = searchParams.get("hcHealth"); + const selectedHcHealth = + healthParam === "healthy" || + healthParam === "unhealthy" || + healthParam === "unknown" + ? healthParam + : undefined; + const enabledParam = searchParams.get("hcEnabled"); + const selectedHcEnabled = + enabledParam === "true" || enabledParam === "false" + ? enabledParam + : undefined; + const columns: ExtendedColumnDef[] = [ { accessorKey: "name", @@ -190,12 +305,34 @@ export default function HealthChecksTable({ id: "mode", friendlyName: t("standaloneHcColumnMode"), header: () => ( - {t("standaloneHcColumnMode")} + + handleFilterChange("hcMode", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("standaloneHcColumnMode")} + className="p-3" + /> ), cell: ({ row }) => ( - - {row.original.hcMode?.toUpperCase() ?? "-"} - + {row.original.hcMode?.toUpperCase() ?? "-"} ) }, { @@ -208,9 +345,58 @@ export default function HealthChecksTable({ }, { id: "resource", - friendlyName: "Resource", + friendlyName: t("resource"), header: () => ( - Resource + + + + + +
+ +
+ +
+
), cell: ({ row }) => { const r = row.original; @@ -218,7 +404,9 @@ export default function HealthChecksTable({ return -; } return ( - + + + +
+ +
+ +
+ ), cell: ({ row }) => { const r = row.original; @@ -239,7 +473,9 @@ export default function HealthChecksTable({ return -; } return ( - + @@ -363,7 +642,6 @@ export default function HealthChecksTable({ {t("edit")} )} - ); } @@ -405,14 +683,13 @@ export default function HealthChecksTable({ - { setSelected(null); setCredenzaOpen(true); @@ -424,8 +701,9 @@ export default function HealthChecksTable({ enableColumnVisibility stickyLeftColumn="name" stickyRightColumn="rowActions" - pagination={paginationState} + pagination={pagination} onPaginationChange={handlePaginationChange} + rowCount={rowCount} /> ); diff --git a/src/components/ui/controlled-data-table.tsx b/src/components/ui/controlled-data-table.tsx index 1690d92a8..7217006e8 100644 --- a/src/components/ui/controlled-data-table.tsx +++ b/src/components/ui/controlled-data-table.tsx @@ -42,7 +42,7 @@ import { Search } from "lucide-react"; import { useTranslations } from "next-intl"; -import { useMemo, useState } from "react"; +import { useMemo, useState, type ReactNode } from "react"; // Extended ColumnDef type that includes optional friendlyName for column visibility dropdown export type ExtendedColumnDef = ColumnDef< @@ -84,6 +84,8 @@ type ControlledDataTableProps = { isNavigatingToAddPage?: boolean; searchPlaceholder?: string; filters?: DataTableFilter[]; + /** Extra filter controls (e.g. searchable entity pickers) shown after the filter dropdowns. */ + filterExtras?: ReactNode; filterDisplayMode?: "label" | "calculated"; // Global filter display mode (can be overridden per filter) columnVisibility?: Record; enableColumnVisibility?: boolean; @@ -108,6 +110,7 @@ export function ControlledDataTable({ refreshButtonDisabled = false, searchPlaceholder = "Search...", filters, + filterExtras, filterDisplayMode = "label", columnVisibility: defaultColumnVisibility, enableColumnVisibility = false, @@ -343,6 +346,7 @@ export function ControlledDataTable({ })} )} + {filterExtras}
{onRefresh && ( @@ -350,7 +354,9 @@ export function ControlledDataTable({
)} - {addActions && addActions.length > 0 && addButtonText ? ( + {addActions && + addActions.length > 0 && + addButtonText ? (
diff --git a/src/lib/queries.ts b/src/lib/queries.ts index ff909c311..97515e796 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -361,25 +361,50 @@ export const orgQueries = { orgId, limit = 20, offset = 0, - query + query, + hcMode, + siteId, + resourceId, + hcHealth, + hcEnabled }: { orgId: string; limit?: number; offset?: number; query?: string; + hcMode?: "http" | "tcp" | "snmp" | "ping"; + siteId?: number; + resourceId?: number; + hcHealth?: "healthy" | "unhealthy" | "unknown"; + hcEnabled?: "true" | "false"; }) => queryOptions({ queryKey: [ "ORG", orgId, "STANDALONE_HEALTH_CHECKS", - { limit, offset, query } + { + limit, + offset, + query, + hcMode, + siteId, + resourceId, + hcHealth, + hcEnabled + } ] as const, queryFn: async ({ signal, meta }) => { const sp = new URLSearchParams(); sp.set("limit", String(limit)); sp.set("offset", String(offset)); if (query) sp.set("query", query); + if (hcMode) sp.set("hcMode", hcMode); + if (siteId != null) sp.set("siteId", String(siteId)); + if (resourceId != null) + sp.set("resourceId", String(resourceId)); + if (hcHealth) sp.set("hcHealth", hcHealth); + if (hcEnabled) sp.set("hcEnabled", hcEnabled); const res = await meta!.api.get< AxiosResponse<{ healthChecks: {