add server filters to health check table

This commit is contained in:
miloschwartz
2026-04-21 18:35:38 -07:00
parent 6f07156075
commit 88eb1649e4
6 changed files with 562 additions and 73 deletions

View File

@@ -1499,6 +1499,22 @@
"standaloneHcColumnHealth": "Health", "standaloneHcColumnHealth": "Health",
"standaloneHcColumnMode": "Mode", "standaloneHcColumnMode": "Mode",
"standaloneHcColumnTarget": "Target", "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", "blueprints": "Blueprints",
"blueprintsDescription": "Apply declarative configurations and view previous runs", "blueprintsDescription": "Apply declarative configurations and view previous runs",
"blueprintAdd": "Add Blueprint", "blueprintAdd": "Add Blueprint",

View File

@@ -17,7 +17,7 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi"; 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 { NextFunction, Request, Response } from "express";
import { z } from "zod"; import { z } from "zod";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
@@ -40,7 +40,23 @@ const querySchema = z.object({
.default("0") .default("0")
.transform(Number) .transform(Number)
.pipe(z.int().nonnegative()), .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({ 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( const whereClause = and(
eq(targetHealthCheck.orgId, orgId), eq(targetHealthCheck.orgId, orgId),
@@ -91,6 +130,13 @@ export async function listHealthChecks(
sql`LOWER(${targetHealthCheck.name})`, sql`LOWER(${targetHealthCheck.name})`,
`%${query.toLowerCase()}%` `%${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 : undefined
); );

View File

@@ -3,7 +3,9 @@ import DismissableBanner from "@app/components/DismissableBanner";
import { internal } from "@app/lib/api"; import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies"; import { authCookieHeader } from "@app/lib/api/cookies";
import { ListHealthChecksResponse } from "@server/routers/healthChecks/types"; 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 { HeartPulse } from "lucide-react";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
@@ -26,24 +28,70 @@ function parsePositiveInt(s: string | undefined): number | undefined {
return n; 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( export default async function AlertingHealthChecksPage(
props: AlertingHealthChecksPageProps props: AlertingHealthChecksPageProps
) { ) {
const params = await props.params; const params = await props.params;
const searchParams = new URLSearchParams(await props.searchParams); 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( const pageSize = Math.max(
1, 1,
parsePositiveInt(searchParams.get("pageSize") ?? undefined) ?? 20 parsePositiveInt(searchParams.get("pageSize") ?? undefined) ?? 20
); );
const pageIndex = page - 1; const pageIndex = page - 1;
const query = searchParams.get("query") ?? undefined;
const apiSp = new URLSearchParams(); const apiSp = new URLSearchParams();
apiSp.set("limit", String(pageSize)); apiSp.set("limit", String(pageSize));
apiSp.set("offset", String(pageIndex * pageSize)); apiSp.set("offset", String(pageIndex * pageSize));
if (query) apiSp.set("query", query); appendListFilters(apiSp, searchParams);
let healthChecks: ListHealthChecksResponse["healthChecks"] = []; let healthChecks: ListHealthChecksResponse["healthChecks"] = [];
let pagination: ListHealthChecksResponse["pagination"] = { let pagination: ListHealthChecksResponse["pagination"] = {
@@ -51,18 +99,80 @@ export default async function AlertingHealthChecksPage(
limit: pageSize, limit: pageSize,
offset: pageIndex * pageSize offset: pageIndex * pageSize
}; };
try {
const res = await internal.get<AxiosResponse<ListHealthChecksResponse>>( const siteIdParam = parsePositiveInt(
`/org/${params.orgId}/health-checks?${apiSp.toString()}`, searchParams.get("siteId") ?? undefined
await authCookieHeader()
); );
const responseData = res.data.data; const resourceIdParam = parsePositiveInt(
searchParams.get("resourceId") ?? undefined
);
const header = await authCookieHeader();
try {
const res = await internal.get(
`/org/${params.orgId}/health-checks?${apiSp.toString()}`,
header
);
const responseData = (res.data as ResponseT<ListHealthChecksResponse>)
.data;
if (responseData) {
healthChecks = responseData.healthChecks; healthChecks = responseData.healthChecks;
pagination = responseData.pagination; pagination = responseData.pagination;
}
} catch { } catch {
// leave defaults // 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<GetSiteResponse>).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<GetResourceResponse>).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(); const t = await getTranslations();
return ( return (
@@ -80,6 +190,12 @@ export default async function AlertingHealthChecksPage(
orgId={params.orgId} orgId={params.orgId}
healthChecks={healthChecks} healthChecks={healthChecks}
rowCount={pagination.total} rowCount={pagination.total}
pagination={{
pageIndex,
pageSize
}}
initialFilterSite={initialFilterSite}
initialFilterResource={initialFilterResource}
/> />
</div> </div>
); );

View File

@@ -6,23 +6,42 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import HealthCheckCredenza, { import HealthCheckCredenza, {
HealthCheckRow HealthCheckRow
} from "@app/components/HealthCheckCredenza"; } 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 { 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 { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu"; } from "@app/components/ui/dropdown-menu";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { Switch } from "@app/components/ui/switch"; import { Switch } from "@app/components/ui/switch";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient, formatAxiosError } from "@app/lib/api"; 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 { 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 { PaginationState } from "@tanstack/react-table";
import type { DataTablePaginationState } from "@app/components/ui/data-table";
import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { useDebouncedCallback } from "use-debounce"; import { useDebouncedCallback } from "use-debounce";
import Link from "next/link"; import Link from "next/link";
@@ -30,11 +49,15 @@ import { useRouter } from "next/navigation";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { cn } from "@app/lib/cn";
type StandaloneHealthChecksTableProps = { type StandaloneHealthChecksTableProps = {
orgId: string; orgId: string;
healthChecks: HealthCheckRow[]; healthChecks: HealthCheckRow[];
rowCount: number; rowCount: number;
pagination: PaginationState;
initialFilterSite?: Selectedsite | null;
initialFilterResource?: SelectedResource | null;
}; };
function formatTarget(row: HealthCheckRow): string { function formatTarget(row: HealthCheckRow): string {
@@ -43,6 +66,12 @@ function formatTarget(row: HealthCheckRow): string {
if (!row.hcPort) return row.hcHostname; if (!row.hcPort) return row.hcHostname;
return `${row.hcHostname}:${row.hcPort}`; 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 // HTTP / default
const scheme = row.hcScheme ?? "http"; const scheme = row.hcScheme ?? "http";
const host = row.hcHostname; const host = row.hcHostname;
@@ -51,16 +80,13 @@ function formatTarget(row: HealthCheckRow): string {
return `${scheme}://${host}${port}${path}`; return `${scheme}://${host}${port}${path}`;
} }
const healthLabel: Record<HealthCheckRow["hcHealth"], string> = {
healthy: "Healthy",
unhealthy: "Unhealthy",
unknown: "Unknown"
};
export default function HealthChecksTable({ export default function HealthChecksTable({
orgId, orgId,
healthChecks, healthChecks,
rowCount rowCount,
pagination,
initialFilterSite = null,
initialFilterResource = null
}: StandaloneHealthChecksTableProps) { }: StandaloneHealthChecksTableProps) {
const router = useRouter(); const router = useRouter();
const t = useTranslations(); const t = useTranslations();
@@ -79,15 +105,56 @@ export default function HealthChecksTable({
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
const [selected, setSelected] = useState<HealthCheckRow | null>(null); const [selected, setSelected] = useState<HealthCheckRow | null>(null);
const [togglingId, setTogglingId] = useState<number | null>(null); const [togglingId, setTogglingId] = useState<number | null>(null);
const [siteFilterOpen, setSiteFilterOpen] = useState(false);
const [resourceFilterOpen, setResourceFilterOpen] = useState(false);
const page = Math.max(1, Number(searchParams.get("page") ?? 1)); const pageSize = pagination.pageSize;
const pageSize = Math.max(1, Number(searchParams.get("pageSize") ?? 20));
const pageIndex = page - 1;
const query = searchParams.get("query") ?? undefined; 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 rows = healthChecks;
const total = rowCount;
const pageCount = Math.max(1, Math.ceil(total / pageSize));
function refreshList() { function refreshList() {
startRefresh(() => { startRefresh(() => {
@@ -102,12 +169,6 @@ export default function HealthChecksTable({
return () => clearInterval(interval); return () => clearInterval(interval);
}, [router]); }, [router]);
const paginationState: DataTablePaginationState = {
pageIndex,
pageSize,
pageCount
};
const handlePaginationChange = (newState: PaginationState) => { const handlePaginationChange = (newState: PaginationState) => {
searchParams.set("page", (newState.pageIndex + 1).toString()); searchParams.set("page", (newState.pageIndex + 1).toString());
searchParams.set("pageSize", newState.pageSize.toString()); searchParams.set("pageSize", newState.pageSize.toString());
@@ -124,6 +185,39 @@ export default function HealthChecksTable({
filter({ searchParams }); filter({ searchParams });
}, 300); }, 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 ( const handleToggleEnabled = async (
row: HealthCheckRow, row: HealthCheckRow,
enabled: boolean 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<HealthCheckRow>[] = [ const columns: ExtendedColumnDef<HealthCheckRow>[] = [
{ {
accessorKey: "name", accessorKey: "name",
@@ -190,12 +305,34 @@ export default function HealthChecksTable({
id: "mode", id: "mode",
friendlyName: t("standaloneHcColumnMode"), friendlyName: t("standaloneHcColumnMode"),
header: () => ( header: () => (
<span className="p-3">{t("standaloneHcColumnMode")}</span> <ColumnFilterButton
options={[
{
value: "http",
label: t("standaloneHcFilterModeHttp")
},
{ value: "tcp", label: t("standaloneHcFilterModeTcp") },
{
value: "snmp",
label: t("standaloneHcFilterModeSnmp")
},
{
value: "ping",
label: t("standaloneHcFilterModePing")
}
]}
selectedValue={selectedHcMode}
onValueChange={(value) =>
handleFilterChange("hcMode", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("standaloneHcColumnMode")}
className="p-3"
/>
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<span> <span>{row.original.hcMode?.toUpperCase() ?? "-"}</span>
{row.original.hcMode?.toUpperCase() ?? "-"}
</span>
) )
}, },
{ {
@@ -208,9 +345,58 @@ export default function HealthChecksTable({
}, },
{ {
id: "resource", id: "resource",
friendlyName: "Resource", friendlyName: t("resource"),
header: () => ( header: () => (
<span className="p-3">Resource</span> <Popover
open={resourceFilterOpen}
onOpenChange={setResourceFilterOpen}
>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
role="combobox"
className={cn(
"justify-between text-sm h-8 px-2 w-full p-3",
!selectedResource && "text-muted-foreground"
)}
>
<div className="flex items-center gap-2 min-w-0">
{t("resource")}
<Funnel className="size-4 flex-none" />
{selectedResource && (
<Badge
className="truncate max-w-[10rem]"
variant="secondary"
>
{selectedResource.name}
</Badge>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[min(20rem,var(--radix-popover-trigger-width))] p-0"
align="start"
>
<div className="border-b p-1">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-full justify-start font-normal"
onClick={clearResourceFilter}
>
{t("standaloneHcFilterAnyResource")}
</Button>
</div>
<ResourceSelector
orgId={orgId}
selectedResource={selectedResource}
onSelectResource={onPickResource}
/>
</PopoverContent>
</Popover>
), ),
cell: ({ row }) => { cell: ({ row }) => {
const r = row.original; const r = row.original;
@@ -218,7 +404,9 @@ export default function HealthChecksTable({
return <span className="text-neutral-400">-</span>; return <span className="text-neutral-400">-</span>;
} }
return ( return (
<Link href={`/${orgId}/settings/resources/proxy/${r.resourceNiceId}`}> <Link
href={`/${orgId}/settings/resources/proxy/${r.resourceNiceId}`}
>
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">
{r.resourceName} {r.resourceName}
<ArrowUpRight className="ml-2 h-3 w-3" /> <ArrowUpRight className="ml-2 h-3 w-3" />
@@ -229,9 +417,55 @@ export default function HealthChecksTable({
}, },
{ {
id: "site", id: "site",
friendlyName: "Site", friendlyName: t("site"),
header: () => ( header: () => (
<span className="p-3">Site</span> <Popover open={siteFilterOpen} onOpenChange={setSiteFilterOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
role="combobox"
className={cn(
"justify-between text-sm h-8 px-2 w-full p-3",
!selectedSite && "text-muted-foreground"
)}
>
<div className="flex items-center gap-2 min-w-0">
{t("site")}
<Funnel className="size-4 flex-none" />
{selectedSite && (
<Badge
className="truncate max-w-[10rem]"
variant="secondary"
>
{selectedSite.name}
</Badge>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[min(20rem,var(--radix-popover-trigger-width))] p-0"
align="start"
>
<div className="border-b p-1">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-full justify-start font-normal"
onClick={clearSiteFilter}
>
{t("standaloneHcFilterAnySite")}
</Button>
</div>
<SitesSelector
orgId={orgId}
selectedSite={selectedSite}
onSelectSite={onPickSite}
/>
</PopoverContent>
</Popover>
), ),
cell: ({ row }) => { cell: ({ row }) => {
const r = row.original; const r = row.original;
@@ -239,7 +473,9 @@ export default function HealthChecksTable({
return <span className="text-neutral-400">-</span>; return <span className="text-neutral-400">-</span>;
} }
return ( return (
<Link href={`/${orgId}/settings/sites/${r.siteNiceId}/general`}> <Link
href={`/${orgId}/settings/sites/${r.siteNiceId}/general`}
>
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">
{r.siteName} {r.siteName}
<ArrowUpRight className="ml-2 h-3 w-3" /> <ArrowUpRight className="ml-2 h-3 w-3" />
@@ -252,29 +488,52 @@ export default function HealthChecksTable({
id: "health", id: "health",
friendlyName: t("standaloneHcColumnHealth"), friendlyName: t("standaloneHcColumnHealth"),
header: () => ( header: () => (
<span className="p-3">{t("standaloneHcColumnHealth")}</span> <ColumnFilterButton
options={[
{
value: "healthy",
label: t("standaloneHcHealthStateHealthy")
},
{
value: "unhealthy",
label: t("standaloneHcHealthStateUnhealthy")
},
{
value: "unknown",
label: t("standaloneHcHealthStateUnknown")
}
]}
selectedValue={selectedHcHealth}
onValueChange={(value) =>
handleFilterChange("hcHealth", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("standaloneHcColumnHealth")}
className="p-3"
/>
), ),
cell: ({ row }) => { cell: ({ row }) => {
const health = row.original.hcHealth; const health = row.original.hcHealth;
if (health === "healthy") { if (health === "healthy") {
return ( return (
<span className="text-green-500 flex items-center space-x-2"> <span className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div> <div className="w-2 h-2 bg-green-500 rounded-full" />
<span>{healthLabel.healthy}</span> <span>{t("standaloneHcHealthStateHealthy")}</span>
</span> </span>
); );
} else if (health === "unhealthy") { } else if (health === "unhealthy") {
return ( return (
<span className="text-red-500 flex items-center space-x-2"> <span className="text-red-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-red-500 rounded-full"></div> <div className="w-2 h-2 bg-red-500 rounded-full" />
<span>{healthLabel.unhealthy}</span> <span>{t("standaloneHcHealthStateUnhealthy")}</span>
</span> </span>
); );
} else { } else {
return ( return (
<span className="text-neutral-500 flex items-center space-x-2"> <span className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div> <div className="w-2 h-2 bg-neutral-500 rounded-full" />
<span>{healthLabel.unknown}</span> <span>{t("standaloneHcHealthStateUnknown")}</span>
</span> </span>
); );
} }
@@ -282,11 +541,15 @@ export default function HealthChecksTable({
}, },
{ {
id: "uptime", id: "uptime",
friendlyName: "Uptime", friendlyName: t("uptime30d"),
header: () => <span className="p-3">{t("uptime30d")}</span>, header: () => <span className="p-3">{t("uptime30d")}</span>,
cell: ({ row }) => { cell: ({ row }) => {
return ( return (
<UptimeMiniBar orgId={orgId} healthCheckId={row.original.targetHealthCheckId} days={30} /> <UptimeMiniBar
orgId={orgId}
healthCheckId={row.original.targetHealthCheckId}
days={30}
/>
); );
} }
}, },
@@ -294,7 +557,26 @@ export default function HealthChecksTable({
accessorKey: "hcEnabled", accessorKey: "hcEnabled",
friendlyName: t("alertingColumnEnabled"), friendlyName: t("alertingColumnEnabled"),
header: () => ( header: () => (
<span className="p-3">{t("alertingColumnEnabled")}</span> <ColumnFilterButton
options={[
{
value: "true",
label: t("standaloneHcFilterEnabledOn")
},
{
value: "false",
label: t("standaloneHcFilterEnabledOff")
}
]}
selectedValue={selectedHcEnabled}
onValueChange={(value) =>
handleFilterChange("hcEnabled", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("alertingColumnEnabled")}
className="p-3"
/>
), ),
cell: ({ row }) => { cell: ({ row }) => {
const r = row.original; const r = row.original;
@@ -302,8 +584,7 @@ export default function HealthChecksTable({
<Switch <Switch
checked={r.hcEnabled} checked={r.hcEnabled}
disabled={ disabled={
!isPaid || !isPaid || togglingId === r.targetHealthCheckId
togglingId === r.targetHealthCheckId
} }
onCheckedChange={(v) => handleToggleEnabled(r, v)} onCheckedChange={(v) => handleToggleEnabled(r, v)}
/> />
@@ -339,15 +620,13 @@ export default function HealthChecksTable({
{t("delete")} {t("delete")}
</span> </span>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{r.resourceId && r.resourceName && r.resourceNiceId ? ( {r.resourceId && r.resourceName && r.resourceNiceId ? (
<Link href={`/${orgId}/settings/resources/proxy/${r.resourceNiceId}`}> <Link
<Button href={`/${orgId}/settings/resources/proxy/${r.resourceNiceId}`}
variant="outline"
disabled={!isPaid}
> >
<Button variant="outline" disabled={!isPaid}>
{t("edit")} {t("edit")}
</Button> </Button>
</Link> </Link>
@@ -363,7 +642,6 @@ export default function HealthChecksTable({
{t("edit")} {t("edit")}
</Button> </Button>
)} )}
</div> </div>
); );
} }
@@ -405,14 +683,13 @@ export default function HealthChecksTable({
<PaidFeaturesAlert tiers={tierMatrix.standaloneHealthChecks} /> <PaidFeaturesAlert tiers={tierMatrix.standaloneHealthChecks} />
<DataTable <ControlledDataTable
columns={columns} columns={columns}
data={rows} rows={rows}
title={t("standaloneHcTableTitle")} tableId="health-checks-table"
searchPlaceholder={t("standaloneHcSearchPlaceholder")} searchPlaceholder={t("standaloneHcSearchPlaceholder")}
onSearch={handleSearchChange} onSearch={handleSearchChange}
searchQuery={query} searchQuery={query}
manualFiltering
onAdd={() => { onAdd={() => {
setSelected(null); setSelected(null);
setCredenzaOpen(true); setCredenzaOpen(true);
@@ -424,8 +701,9 @@ export default function HealthChecksTable({
enableColumnVisibility enableColumnVisibility
stickyLeftColumn="name" stickyLeftColumn="name"
stickyRightColumn="rowActions" stickyRightColumn="rowActions"
pagination={paginationState} pagination={pagination}
onPaginationChange={handlePaginationChange} onPaginationChange={handlePaginationChange}
rowCount={rowCount}
/> />
</> </>
); );

View File

@@ -42,7 +42,7 @@ import {
Search Search
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl"; 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 // Extended ColumnDef type that includes optional friendlyName for column visibility dropdown
export type ExtendedColumnDef<TData, TValue = unknown> = ColumnDef< export type ExtendedColumnDef<TData, TValue = unknown> = ColumnDef<
@@ -84,6 +84,8 @@ type ControlledDataTableProps<TData, TValue> = {
isNavigatingToAddPage?: boolean; isNavigatingToAddPage?: boolean;
searchPlaceholder?: string; searchPlaceholder?: string;
filters?: DataTableFilter[]; 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) filterDisplayMode?: "label" | "calculated"; // Global filter display mode (can be overridden per filter)
columnVisibility?: Record<string, boolean>; columnVisibility?: Record<string, boolean>;
enableColumnVisibility?: boolean; enableColumnVisibility?: boolean;
@@ -108,6 +110,7 @@ export function ControlledDataTable<TData, TValue>({
refreshButtonDisabled = false, refreshButtonDisabled = false,
searchPlaceholder = "Search...", searchPlaceholder = "Search...",
filters, filters,
filterExtras,
filterDisplayMode = "label", filterDisplayMode = "label",
columnVisibility: defaultColumnVisibility, columnVisibility: defaultColumnVisibility,
enableColumnVisibility = false, enableColumnVisibility = false,
@@ -343,6 +346,7 @@ export function ControlledDataTable<TData, TValue>({
})} })}
</div> </div>
)} )}
{filterExtras}
</div> </div>
<div className="flex items-center gap-2 sm:justify-end"> <div className="flex items-center gap-2 sm:justify-end">
{onRefresh && ( {onRefresh && (
@@ -350,7 +354,9 @@ export function ControlledDataTable<TData, TValue>({
<Button <Button
variant="outline" variant="outline"
onClick={onRefresh} onClick={onRefresh}
disabled={isRefreshing || refreshButtonDisabled} disabled={
isRefreshing || refreshButtonDisabled
}
> >
<RefreshCw <RefreshCw
className={`mr-0 sm:mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} className={`mr-0 sm:mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
@@ -361,7 +367,9 @@ export function ControlledDataTable<TData, TValue>({
</Button> </Button>
</div> </div>
)} )}
{addActions && addActions.length > 0 && addButtonText ? ( {addActions &&
addActions.length > 0 &&
addButtonText ? (
<div> <div>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>

View File

@@ -361,25 +361,50 @@ export const orgQueries = {
orgId, orgId,
limit = 20, limit = 20,
offset = 0, offset = 0,
query query,
hcMode,
siteId,
resourceId,
hcHealth,
hcEnabled
}: { }: {
orgId: string; orgId: string;
limit?: number; limit?: number;
offset?: number; offset?: number;
query?: string; query?: string;
hcMode?: "http" | "tcp" | "snmp" | "ping";
siteId?: number;
resourceId?: number;
hcHealth?: "healthy" | "unhealthy" | "unknown";
hcEnabled?: "true" | "false";
}) => }) =>
queryOptions({ queryOptions({
queryKey: [ queryKey: [
"ORG", "ORG",
orgId, orgId,
"STANDALONE_HEALTH_CHECKS", "STANDALONE_HEALTH_CHECKS",
{ limit, offset, query } {
limit,
offset,
query,
hcMode,
siteId,
resourceId,
hcHealth,
hcEnabled
}
] as const, ] as const,
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, meta }) => {
const sp = new URLSearchParams(); const sp = new URLSearchParams();
sp.set("limit", String(limit)); sp.set("limit", String(limit));
sp.set("offset", String(offset)); sp.set("offset", String(offset));
if (query) sp.set("query", query); 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< const res = await meta!.api.get<
AxiosResponse<{ AxiosResponse<{
healthChecks: { healthChecks: {