diff --git a/messages/en-US.json b/messages/en-US.json index 4278a6aa2..9b14cee39 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1399,7 +1399,7 @@ "alertingSpecificHealthChecksDescription": "Choose specific health checks to watch", "alertingAllResources": "All Resources", "alertingAllResourcesDescription": "Alert fires for any resource", - "alertingSpecificResources": "Specific resources", + "alertingSpecificResources": "Specific Resources", "alertingSpecificResourcesDescription": "Choose specific resources to watch", "alertingSelectResources": "Select resources…", "alertingResourcesSelected": "{count} resources selected", diff --git a/server/private/routers/alertRule/listAlertRules.ts b/server/private/routers/alertRule/listAlertRules.ts index 601ab0fa3..e6a2e6381 100644 --- a/server/private/routers/alertRule/listAlertRules.ts +++ b/server/private/routers/alertRule/listAlertRules.ts @@ -21,7 +21,7 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; 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({ orgId: z.string().nonempty() @@ -50,7 +50,10 @@ const querySchema = z.strictObject({ .string() .optional() .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 = { @@ -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 let siteFilterRuleIds: number[] | null = null; @@ -169,14 +181,28 @@ export async function listAlertRules( : undefined, resourceFilterRuleIds !== null ? inArray(alertRules.alertRuleId, resourceFilterRuleIds) + : undefined, + enabledFilter !== undefined + ? eq(alertRules.enabled, enabledFilter === "true") : 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 .select() .from(alertRules) .where(whereClause) - .orderBy(sql`${alertRules.createdAt} DESC`) + .orderBy(orderByClause) .limit(limit) .offset(offset); diff --git a/src/components/AlertingRulesTable.tsx b/src/components/AlertingRulesTable.tsx index b1b984c96..3ddcc4c13 100644 --- a/src/components/AlertingRulesTable.tsx +++ b/src/components/AlertingRulesTable.tsx @@ -17,18 +17,31 @@ import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { orgQueries } from "@app/lib/queries"; +import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; 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 Link from "next/link"; import { useRouter } from "next/navigation"; 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 type { PaginationState } from "@tanstack/react-table"; import type { DataTablePaginationState } from "@app/components/ui/data-table"; import { useDebouncedCallback } from "use-debounce"; +const alertRulesEnabledQuerySchema = z + .enum(["true", "false"]) + .optional() + .catch(undefined); + type AlertingRulesTableProps = { orgId: string; siteId?: number; @@ -126,6 +139,19 @@ export default function AlertingRulesTable({ const pageSize = Math.max(1, Number(searchParams.get("pageSize") ?? 20)); const pageIndex = page - 1; 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( orgQueries.alertRules({ @@ -134,7 +160,10 @@ export default function AlertingRulesTable({ offset: pageIndex * pageSize, query, siteId, - resourceId + resourceId, + sortBy, + order, + enabled: enabledForQuery }) ); @@ -164,6 +193,22 @@ export default function AlertingRulesTable({ filter({ searchParams }); }, 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 = () => queryClient.invalidateQueries({ queryKey: ["ORG", orgId, "ALERT_RULES"] @@ -212,17 +257,25 @@ export default function AlertingRulesTable({ accessorKey: "name", enableHiding: false, friendlyName: t("name"), - header: ({ column }) => ( - - ), + header: () => { + const nameOrder = getSortDirection("name", searchParams); + const Icon = + nameOrder === "asc" + ? ArrowDown01Icon + : nameOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; + return ( + + ); + }, cell: ({ row }) => {row.original.name} }, { @@ -244,7 +297,28 @@ export default function AlertingRulesTable({ { accessorKey: "lastTriggeredAt", friendlyName: t("lastTriggeredAt"), - header: () => {t("lastTriggeredAt")}, + header: () => { + const triggerOrder = getSortDirection( + "last_triggered_at", + searchParams + ); + const Icon = + triggerOrder === "asc" + ? ArrowDown01Icon + : triggerOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; + return ( + + ); + }, cell: ({ row }) => ( {row.original.lastTriggeredAt @@ -257,7 +331,15 @@ export default function AlertingRulesTable({ accessorKey: "enabled", friendlyName: t("alertingColumnEnabled"), header: () => ( - {t("alertingColumnEnabled")} + ), cell: ({ row }) => { const r = row.original; @@ -342,6 +424,7 @@ export default function AlertingRulesTable({ onSearch={handleSearchChange} searchQuery={query} manualFiltering + manualSorting onAdd={() => { router.push(`/${orgId}/settings/alerting/create`); }} diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx index cf252f3ea..2c0e5e48c 100644 --- a/src/components/ui/data-table.tsx +++ b/src/components/ui/data-table.tsx @@ -199,6 +199,8 @@ type DataTableProps = { columnVisibility?: Record; enableColumnVisibility?: boolean; manualFiltering?: boolean; + /** When true, row order is controlled externally (e.g. server-side sorting). */ + manualSorting?: boolean; onSearch?: (input: string) => void; searchQuery?: string; pagination?: DataTablePaginationState; @@ -232,6 +234,7 @@ export function DataTable({ enableColumnVisibility = false, persistColumnVisibility = false, manualFiltering = false, + manualSorting = false, pagination: paginationState, stickyLeftColumn, onSearch, @@ -353,6 +356,7 @@ export function DataTable({ } : setPagination, manualFiltering, + manualSorting, manualPagination: Boolean(paginationState), pageCount: paginationState?.pageCount, initialState: { diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 1e7074e3a..8fb11989c 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -262,7 +262,10 @@ export const orgQueries = { offset = 0, query, siteId, - resourceId + resourceId, + sortBy, + order, + enabled }: { orgId: string; limit?: number; @@ -270,9 +273,17 @@ export const orgQueries = { query?: string; siteId?: number; resourceId?: number; + sortBy?: string; + order?: string; + enabled?: string; }) => 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 }) => { const sp = new URLSearchParams(); sp.set("limit", String(limit)); @@ -280,6 +291,11 @@ export const orgQueries = { if (query) sp.set("query", query); if (siteId != null) sp.set("siteId", String(siteId)); 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< AxiosResponse >(`/org/${orgId}/alert-rules?${sp.toString()}`, { signal });