mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-28 03:32:20 +00:00
add server side filter and sort to alerts
This commit is contained in:
@@ -1399,7 +1399,7 @@
|
|||||||
"alertingSpecificHealthChecksDescription": "Choose specific health checks to watch",
|
"alertingSpecificHealthChecksDescription": "Choose specific health checks to watch",
|
||||||
"alertingAllResources": "All Resources",
|
"alertingAllResources": "All Resources",
|
||||||
"alertingAllResourcesDescription": "Alert fires for any resource",
|
"alertingAllResourcesDescription": "Alert fires for any resource",
|
||||||
"alertingSpecificResources": "Specific resources",
|
"alertingSpecificResources": "Specific Resources",
|
||||||
"alertingSpecificResourcesDescription": "Choose specific resources to watch",
|
"alertingSpecificResourcesDescription": "Choose specific resources to watch",
|
||||||
"alertingSelectResources": "Select resources…",
|
"alertingSelectResources": "Select resources…",
|
||||||
"alertingResourcesSelected": "{count} resources selected",
|
"alertingResourcesSelected": "{count} resources selected",
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import createHttpError from "http-errors";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import { and, eq, inArray, like, sql } from "drizzle-orm";
|
import { and, asc, desc, eq, inArray, like, sql } from "drizzle-orm";
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
const paramsSchema = z.strictObject({
|
||||||
orgId: z.string().nonempty()
|
orgId: z.string().nonempty()
|
||||||
@@ -50,7 +50,10 @@ const querySchema = z.strictObject({
|
|||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.transform((v) => (v !== undefined ? Number(v) : undefined))
|
.transform((v) => (v !== undefined ? Number(v) : undefined))
|
||||||
.pipe(z.number().int().positive().optional())
|
.pipe(z.number().int().positive().optional()),
|
||||||
|
sort_by: z.enum(["name", "last_triggered_at"]).optional(),
|
||||||
|
order: z.enum(["asc", "desc"]).optional().default("asc"),
|
||||||
|
enabled: z.enum(["true", "false"]).optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ListAlertRulesResponse = {
|
export type ListAlertRulesResponse = {
|
||||||
@@ -113,7 +116,16 @@ export async function listAlertRules(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { limit, offset, query, siteId, resourceId } = parsedQuery.data;
|
const {
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
query,
|
||||||
|
siteId,
|
||||||
|
resourceId,
|
||||||
|
sort_by,
|
||||||
|
order,
|
||||||
|
enabled: enabledFilter
|
||||||
|
} = parsedQuery.data;
|
||||||
|
|
||||||
// Resolve siteId filter → matching alertRuleIds
|
// Resolve siteId filter → matching alertRuleIds
|
||||||
let siteFilterRuleIds: number[] | null = null;
|
let siteFilterRuleIds: number[] | null = null;
|
||||||
@@ -169,14 +181,28 @@ export async function listAlertRules(
|
|||||||
: undefined,
|
: undefined,
|
||||||
resourceFilterRuleIds !== null
|
resourceFilterRuleIds !== null
|
||||||
? inArray(alertRules.alertRuleId, resourceFilterRuleIds)
|
? inArray(alertRules.alertRuleId, resourceFilterRuleIds)
|
||||||
|
: undefined,
|
||||||
|
enabledFilter !== undefined
|
||||||
|
? eq(alertRules.enabled, enabledFilter === "true")
|
||||||
: undefined
|
: undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const orderByClause =
|
||||||
|
sort_by === "name"
|
||||||
|
? order === "asc"
|
||||||
|
? asc(alertRules.name)
|
||||||
|
: desc(alertRules.name)
|
||||||
|
: sort_by === "last_triggered_at"
|
||||||
|
? order === "asc"
|
||||||
|
? sql`${alertRules.lastTriggeredAt} ASC NULLS FIRST`
|
||||||
|
: sql`${alertRules.lastTriggeredAt} DESC NULLS LAST`
|
||||||
|
: sql`${alertRules.createdAt} DESC`;
|
||||||
|
|
||||||
const list = await db
|
const list = await db
|
||||||
.select()
|
.select()
|
||||||
.from(alertRules)
|
.from(alertRules)
|
||||||
.where(whereClause)
|
.where(whereClause)
|
||||||
.orderBy(sql`${alertRules.createdAt} DESC`)
|
.orderBy(orderByClause)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset);
|
.offset(offset);
|
||||||
|
|
||||||
|
|||||||
@@ -17,18 +17,31 @@ import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
|||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import { orgQueries } from "@app/lib/queries";
|
import { orgQueries } from "@app/lib/queries";
|
||||||
|
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import { ArrowUpDown, MoreHorizontal } from "lucide-react";
|
import {
|
||||||
|
ArrowDown01Icon,
|
||||||
|
ArrowUp10Icon,
|
||||||
|
ChevronsUpDownIcon,
|
||||||
|
MoreHorizontal
|
||||||
|
} from "lucide-react";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import z from "zod";
|
||||||
|
import { ColumnFilterButton } from "./ColumnFilterButton";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { PaginationState } from "@tanstack/react-table";
|
import type { PaginationState } from "@tanstack/react-table";
|
||||||
import type { DataTablePaginationState } from "@app/components/ui/data-table";
|
import type { DataTablePaginationState } from "@app/components/ui/data-table";
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
|
|
||||||
|
const alertRulesEnabledQuerySchema = z
|
||||||
|
.enum(["true", "false"])
|
||||||
|
.optional()
|
||||||
|
.catch(undefined);
|
||||||
|
|
||||||
type AlertingRulesTableProps = {
|
type AlertingRulesTableProps = {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
siteId?: number;
|
siteId?: number;
|
||||||
@@ -126,6 +139,19 @@ export default function AlertingRulesTable({
|
|||||||
const pageSize = Math.max(1, Number(searchParams.get("pageSize") ?? 20));
|
const pageSize = Math.max(1, Number(searchParams.get("pageSize") ?? 20));
|
||||||
const pageIndex = page - 1;
|
const pageIndex = page - 1;
|
||||||
const query = searchParams.get("query") ?? undefined;
|
const query = searchParams.get("query") ?? undefined;
|
||||||
|
const sortBy = searchParams.get("sort_by") ?? undefined;
|
||||||
|
const order = searchParams.get("order") ?? undefined;
|
||||||
|
const enabledForQuery = alertRulesEnabledQuerySchema.parse(
|
||||||
|
searchParams.get("enabled") ?? undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
const enabledFilterOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{ value: "true", label: t("enabled") },
|
||||||
|
{ value: "false", label: t("disabled") }
|
||||||
|
],
|
||||||
|
[t]
|
||||||
|
);
|
||||||
|
|
||||||
const { data, isLoading, refetch, isRefetching } = useQuery(
|
const { data, isLoading, refetch, isRefetching } = useQuery(
|
||||||
orgQueries.alertRules({
|
orgQueries.alertRules({
|
||||||
@@ -134,7 +160,10 @@ export default function AlertingRulesTable({
|
|||||||
offset: pageIndex * pageSize,
|
offset: pageIndex * pageSize,
|
||||||
query,
|
query,
|
||||||
siteId,
|
siteId,
|
||||||
resourceId
|
resourceId,
|
||||||
|
sortBy,
|
||||||
|
order,
|
||||||
|
enabled: enabledForQuery
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -164,6 +193,22 @@ export default function AlertingRulesTable({
|
|||||||
filter({ searchParams });
|
filter({ searchParams });
|
||||||
}, 300);
|
}, 300);
|
||||||
|
|
||||||
|
function toggleSort(column: string) {
|
||||||
|
filter({
|
||||||
|
searchParams: getNextSortOrder(column, searchParams)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEnabledFilter(value: string | undefined | null) {
|
||||||
|
const sp = new URLSearchParams(searchParams);
|
||||||
|
sp.delete("enabled");
|
||||||
|
sp.delete("page");
|
||||||
|
if (value) {
|
||||||
|
sp.set("enabled", value);
|
||||||
|
}
|
||||||
|
filter({ searchParams: sp });
|
||||||
|
}
|
||||||
|
|
||||||
const invalidate = () =>
|
const invalidate = () =>
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["ORG", orgId, "ALERT_RULES"]
|
queryKey: ["ORG", orgId, "ALERT_RULES"]
|
||||||
@@ -212,17 +257,25 @@ export default function AlertingRulesTable({
|
|||||||
accessorKey: "name",
|
accessorKey: "name",
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
friendlyName: t("name"),
|
friendlyName: t("name"),
|
||||||
header: ({ column }) => (
|
header: () => {
|
||||||
|
const nameOrder = getSortDirection("name", searchParams);
|
||||||
|
const Icon =
|
||||||
|
nameOrder === "asc"
|
||||||
|
? ArrowDown01Icon
|
||||||
|
: nameOrder === "desc"
|
||||||
|
? ArrowUp10Icon
|
||||||
|
: ChevronsUpDownIcon;
|
||||||
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() =>
|
className="p-3"
|
||||||
column.toggleSorting(column.getIsSorted() === "asc")
|
onClick={() => toggleSort("name")}
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{t("name")}
|
{t("name")}
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
<Icon className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
),
|
);
|
||||||
|
},
|
||||||
cell: ({ row }) => <span>{row.original.name}</span>
|
cell: ({ row }) => <span>{row.original.name}</span>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -244,7 +297,28 @@ export default function AlertingRulesTable({
|
|||||||
{
|
{
|
||||||
accessorKey: "lastTriggeredAt",
|
accessorKey: "lastTriggeredAt",
|
||||||
friendlyName: t("lastTriggeredAt"),
|
friendlyName: t("lastTriggeredAt"),
|
||||||
header: () => <span className="p-3">{t("lastTriggeredAt")}</span>,
|
header: () => {
|
||||||
|
const triggerOrder = getSortDirection(
|
||||||
|
"last_triggered_at",
|
||||||
|
searchParams
|
||||||
|
);
|
||||||
|
const Icon =
|
||||||
|
triggerOrder === "asc"
|
||||||
|
? ArrowDown01Icon
|
||||||
|
: triggerOrder === "desc"
|
||||||
|
? ArrowUp10Icon
|
||||||
|
: ChevronsUpDownIcon;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="p-3"
|
||||||
|
onClick={() => toggleSort("last_triggered_at")}
|
||||||
|
>
|
||||||
|
{t("lastTriggeredAt")}
|
||||||
|
<Icon className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span>
|
<span>
|
||||||
{row.original.lastTriggeredAt
|
{row.original.lastTriggeredAt
|
||||||
@@ -257,7 +331,15 @@ export default function AlertingRulesTable({
|
|||||||
accessorKey: "enabled",
|
accessorKey: "enabled",
|
||||||
friendlyName: t("alertingColumnEnabled"),
|
friendlyName: t("alertingColumnEnabled"),
|
||||||
header: () => (
|
header: () => (
|
||||||
<span className="p-3">{t("alertingColumnEnabled")}</span>
|
<ColumnFilterButton
|
||||||
|
options={enabledFilterOptions}
|
||||||
|
selectedValue={enabledForQuery}
|
||||||
|
onValueChange={handleEnabledFilter}
|
||||||
|
searchPlaceholder={t("searchPlaceholder")}
|
||||||
|
emptyMessage={t("emptySearchOptions")}
|
||||||
|
label={t("alertingColumnEnabled")}
|
||||||
|
className="p-3"
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const r = row.original;
|
const r = row.original;
|
||||||
@@ -342,6 +424,7 @@ export default function AlertingRulesTable({
|
|||||||
onSearch={handleSearchChange}
|
onSearch={handleSearchChange}
|
||||||
searchQuery={query}
|
searchQuery={query}
|
||||||
manualFiltering
|
manualFiltering
|
||||||
|
manualSorting
|
||||||
onAdd={() => {
|
onAdd={() => {
|
||||||
router.push(`/${orgId}/settings/alerting/create`);
|
router.push(`/${orgId}/settings/alerting/create`);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -199,6 +199,8 @@ type DataTableProps<TData, TValue> = {
|
|||||||
columnVisibility?: Record<string, boolean>;
|
columnVisibility?: Record<string, boolean>;
|
||||||
enableColumnVisibility?: boolean;
|
enableColumnVisibility?: boolean;
|
||||||
manualFiltering?: boolean;
|
manualFiltering?: boolean;
|
||||||
|
/** When true, row order is controlled externally (e.g. server-side sorting). */
|
||||||
|
manualSorting?: boolean;
|
||||||
onSearch?: (input: string) => void;
|
onSearch?: (input: string) => void;
|
||||||
searchQuery?: string;
|
searchQuery?: string;
|
||||||
pagination?: DataTablePaginationState;
|
pagination?: DataTablePaginationState;
|
||||||
@@ -232,6 +234,7 @@ export function DataTable<TData, TValue>({
|
|||||||
enableColumnVisibility = false,
|
enableColumnVisibility = false,
|
||||||
persistColumnVisibility = false,
|
persistColumnVisibility = false,
|
||||||
manualFiltering = false,
|
manualFiltering = false,
|
||||||
|
manualSorting = false,
|
||||||
pagination: paginationState,
|
pagination: paginationState,
|
||||||
stickyLeftColumn,
|
stickyLeftColumn,
|
||||||
onSearch,
|
onSearch,
|
||||||
@@ -353,6 +356,7 @@ export function DataTable<TData, TValue>({
|
|||||||
}
|
}
|
||||||
: setPagination,
|
: setPagination,
|
||||||
manualFiltering,
|
manualFiltering,
|
||||||
|
manualSorting,
|
||||||
manualPagination: Boolean(paginationState),
|
manualPagination: Boolean(paginationState),
|
||||||
pageCount: paginationState?.pageCount,
|
pageCount: paginationState?.pageCount,
|
||||||
initialState: {
|
initialState: {
|
||||||
|
|||||||
@@ -262,7 +262,10 @@ export const orgQueries = {
|
|||||||
offset = 0,
|
offset = 0,
|
||||||
query,
|
query,
|
||||||
siteId,
|
siteId,
|
||||||
resourceId
|
resourceId,
|
||||||
|
sortBy,
|
||||||
|
order,
|
||||||
|
enabled
|
||||||
}: {
|
}: {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
@@ -270,9 +273,17 @@ export const orgQueries = {
|
|||||||
query?: string;
|
query?: string;
|
||||||
siteId?: number;
|
siteId?: number;
|
||||||
resourceId?: number;
|
resourceId?: number;
|
||||||
|
sortBy?: string;
|
||||||
|
order?: string;
|
||||||
|
enabled?: string;
|
||||||
}) =>
|
}) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["ORG", orgId, "ALERT_RULES", { limit, offset, query, siteId, resourceId }] as const,
|
queryKey: [
|
||||||
|
"ORG",
|
||||||
|
orgId,
|
||||||
|
"ALERT_RULES",
|
||||||
|
{ limit, offset, query, siteId, resourceId, sortBy, order, enabled }
|
||||||
|
] 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));
|
||||||
@@ -280,6 +291,11 @@ export const orgQueries = {
|
|||||||
if (query) sp.set("query", query);
|
if (query) sp.set("query", query);
|
||||||
if (siteId != null) sp.set("siteId", String(siteId));
|
if (siteId != null) sp.set("siteId", String(siteId));
|
||||||
if (resourceId != null) sp.set("resourceId", String(resourceId));
|
if (resourceId != null) sp.set("resourceId", String(resourceId));
|
||||||
|
if (sortBy) {
|
||||||
|
sp.set("sort_by", sortBy);
|
||||||
|
if (order) sp.set("order", order);
|
||||||
|
}
|
||||||
|
if (enabled) sp.set("enabled", enabled);
|
||||||
const res = await meta!.api.get<
|
const res = await meta!.api.get<
|
||||||
AxiosResponse<ListAlertRulesResponse>
|
AxiosResponse<ListAlertRulesResponse>
|
||||||
>(`/org/${orgId}/alert-rules?${sp.toString()}`, { signal });
|
>(`/org/${orgId}/alert-rules?${sp.toString()}`, { signal });
|
||||||
|
|||||||
Reference in New Issue
Block a user