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 });