Paginate the tables with queries

This commit is contained in:
Owen
2026-04-20 20:05:59 -07:00
parent c8d560d78f
commit f938e9c3c0
5 changed files with 97 additions and 49 deletions

View File

@@ -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, sql } from "drizzle-orm"; import { and, eq, inArray, like, sql } from "drizzle-orm";
const paramsSchema = z.strictObject({ const paramsSchema = z.strictObject({
orgId: z.string().nonempty() orgId: z.string().nonempty()
@@ -40,6 +40,7 @@ const querySchema = z.strictObject({
.default("0") .default("0")
.transform(Number) .transform(Number)
.pipe(z.number().int().nonnegative()), .pipe(z.number().int().nonnegative()),
query: z.string().optional(),
siteId: z siteId: z
.string() .string()
.optional() .optional()
@@ -112,7 +113,7 @@ export async function listAlertRules(
) )
); );
} }
const { limit, offset, siteId, resourceId } = parsedQuery.data; const { limit, offset, query, siteId, resourceId } = parsedQuery.data;
// Resolve siteId filter → matching alertRuleIds // Resolve siteId filter → matching alertRuleIds
let siteFilterRuleIds: number[] | null = null; let siteFilterRuleIds: number[] | null = null;
@@ -160,6 +161,9 @@ export async function listAlertRules(
const whereClause = and( const whereClause = and(
eq(alertRules.orgId, orgId), eq(alertRules.orgId, orgId),
query
? like(sql`LOWER(${alertRules.name})`, `%${query.toLowerCase()}%`)
: undefined,
siteFilterRuleIds !== null siteFilterRuleIds !== null
? inArray(alertRules.alertRuleId, siteFilterRuleIds) ? inArray(alertRules.alertRuleId, siteFilterRuleIds)
: undefined, : undefined,

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, isNull, sql } from "drizzle-orm"; import { and, eq, 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";
@@ -39,7 +39,8 @@ const querySchema = z.object({
.optional() .optional()
.default("0") .default("0")
.transform(Number) .transform(Number)
.pipe(z.int().nonnegative()) .pipe(z.int().nonnegative()),
query: z.string().optional()
}); });
registry.registerPath({ registry.registerPath({
@@ -80,10 +81,16 @@ export async function listHealthChecks(
) )
); );
} }
const { limit, offset } = parsedQuery.data; const { limit, offset, query } = parsedQuery.data;
const whereClause = and( const whereClause = and(
eq(targetHealthCheck.orgId, orgId), eq(targetHealthCheck.orgId, orgId),
query
? like(
sql`LOWER(${targetHealthCheck.name})`,
`%${query.toLowerCase()}%`
)
: undefined
); );
const list = await db const list = await db

View File

@@ -13,6 +13,7 @@ import {
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 { 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";
@@ -26,6 +27,7 @@ import { useState } from "react";
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";
type AlertingRulesTableProps = { type AlertingRulesTableProps = {
orgId: string; orgId: string;
@@ -105,28 +107,27 @@ export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) {
const { isPaidUser } = usePaidStatus(); const { isPaidUser } = usePaidStatus();
const isPaid = isPaidUser(tierMatrix.alertingRules); const isPaid = isPaidUser(tierMatrix.alertingRules);
const {
navigate: filter,
isNavigating: isFiltering,
searchParams
} = useNavigationContext();
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
const [selected, setSelected] = useState<AlertRuleRow | null>(null); const [selected, setSelected] = useState<AlertRuleRow | null>(null);
const [togglingId, setTogglingId] = useState<number | null>(null); const [togglingId, setTogglingId] = useState<number | null>(null);
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(() => { const page = Math.max(1, Number(searchParams.get("page") ?? 1));
if (typeof window === "undefined") return 20; const pageSize = Math.max(1, Number(searchParams.get("pageSize") ?? 20));
try { const pageIndex = page - 1;
const stored = localStorage.getItem("Org-alerting-rules-table-size"); const query = searchParams.get("query") ?? undefined;
if (stored) {
const parsed = parseInt(stored, 10);
if (parsed > 0 && parsed <= 1000) return parsed;
}
} catch {}
return 20;
});
const { const {
data, data,
isLoading, isLoading,
refetch, refetch,
isRefetching isRefetching
} = useQuery(orgQueries.alertRules({ orgId, limit: pageSize, offset: pageIndex * pageSize })); } = useQuery(orgQueries.alertRules({ orgId, limit: pageSize, offset: pageIndex * pageSize, query }));
const rows = data?.alertRules ?? []; const rows = data?.alertRules ?? [];
const total = data?.pagination.total ?? 0; const total = data?.pagination.total ?? 0;
@@ -135,10 +136,21 @@ export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) {
const paginationState: DataTablePaginationState = { pageIndex, pageSize, pageCount }; const paginationState: DataTablePaginationState = { pageIndex, pageSize, pageCount };
const handlePaginationChange = (newState: PaginationState) => { const handlePaginationChange = (newState: PaginationState) => {
setPageIndex(newState.pageIndex); searchParams.set("page", (newState.pageIndex + 1).toString());
setPageSize(newState.pageSize); searchParams.set("pageSize", newState.pageSize.toString());
filter({ searchParams });
}; };
const handleSearchChange = useDebouncedCallback((value: string) => {
if (value) {
searchParams.set("query", value);
} else {
searchParams.delete("query");
}
searchParams.delete("page");
filter({ searchParams });
}, 300);
const invalidate = () => const invalidate = () =>
queryClient.invalidateQueries({ queryKey: ["ORG", orgId, "ALERT_RULES"] }); queryClient.invalidateQueries({ queryKey: ["ORG", orgId, "ALERT_RULES"] });
@@ -308,15 +320,16 @@ export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) {
<DataTable <DataTable
columns={columns} columns={columns}
data={rows} data={rows}
persistPageSize="Org-alerting-rules-table"
title={t("alertingRules")} title={t("alertingRules")}
searchPlaceholder={t("alertingSearchRules")} searchPlaceholder={t("alertingSearchRules")}
searchColumn="name" onSearch={handleSearchChange}
searchQuery={query}
manualFiltering
onAdd={() => { onAdd={() => {
router.push(`/${orgId}/settings/alerting/create`); router.push(`/${orgId}/settings/alerting/create`);
}} }}
onRefresh={() => refetch()} onRefresh={() => refetch()}
isRefreshing={isRefetching || isLoading} isRefreshing={isRefetching || isLoading || isFiltering}
addButtonText={t("alertingAddRule")} addButtonText={t("alertingAddRule")}
enableColumnVisibility enableColumnVisibility
stickyLeftColumn="name" stickyLeftColumn="name"

View File

@@ -26,6 +26,8 @@ import { useTranslations } from "next-intl";
import { useState } from "react"; import { useState } 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 type { DataTablePaginationState } from "@app/components/ui/data-table";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { useDebouncedCallback } from "use-debounce";
import Link from "next/link"; import Link from "next/link";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { usePaidStatus } from "@app/hooks/usePaidStatus";
@@ -74,23 +76,20 @@ export default function HealthChecksTable({
const isPaid = isPaidUser(tierMatrix.standaloneHealthChecks); const isPaid = isPaidUser(tierMatrix.standaloneHealthChecks);
const [credenzaOpen, setCredenzaOpen] = useState(false); const [credenzaOpen, setCredenzaOpen] = useState(false);
const {
navigate: filter,
isNavigating: isFiltering,
searchParams
} = useNavigationContext();
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 [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(() => { const page = Math.max(1, Number(searchParams.get("page") ?? 1));
if (typeof window === "undefined") return 20; const pageSize = Math.max(1, Number(searchParams.get("pageSize") ?? 20));
try { const pageIndex = page - 1;
const stored = localStorage.getItem( const query = searchParams.get("query") ?? undefined;
"Org-standalone-health-checks-table-size"
);
if (stored) {
const parsed = parseInt(stored, 10);
if (parsed > 0 && parsed <= 1000) return parsed;
}
} catch {}
return 20;
});
const { const {
data, data,
@@ -101,7 +100,8 @@ export default function HealthChecksTable({
...orgQueries.standaloneHealthChecks({ ...orgQueries.standaloneHealthChecks({
orgId, orgId,
limit: pageSize, limit: pageSize,
offset: pageIndex * pageSize offset: pageIndex * pageSize,
query
}), }),
refetchInterval: 10_000 refetchInterval: 10_000
}); });
@@ -117,10 +117,21 @@ export default function HealthChecksTable({
}; };
const handlePaginationChange = (newState: PaginationState) => { const handlePaginationChange = (newState: PaginationState) => {
setPageIndex(newState.pageIndex); searchParams.set("page", (newState.pageIndex + 1).toString());
setPageSize(newState.pageSize); searchParams.set("pageSize", newState.pageSize.toString());
filter({ searchParams });
}; };
const handleSearchChange = useDebouncedCallback((value: string) => {
if (value) {
searchParams.set("query", value);
} else {
searchParams.delete("query");
}
searchParams.delete("page");
filter({ searchParams });
}, 300);
const invalidate = () => const invalidate = () =>
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["ORG", orgId, "STANDALONE_HEALTH_CHECKS"] queryKey: ["ORG", orgId, "STANDALONE_HEALTH_CHECKS"]
@@ -376,17 +387,18 @@ export default function HealthChecksTable({
<DataTable <DataTable
columns={columns} columns={columns}
data={rows} data={rows}
persistPageSize="Org-standalone-health-checks-table"
title={t("standaloneHcTableTitle")} title={t("standaloneHcTableTitle")}
searchPlaceholder={t("standaloneHcSearchPlaceholder")} searchPlaceholder={t("standaloneHcSearchPlaceholder")}
searchColumn="name" onSearch={handleSearchChange}
searchQuery={query}
manualFiltering
onAdd={() => { onAdd={() => {
setSelected(null); setSelected(null);
setCredenzaOpen(true); setCredenzaOpen(true);
}} }}
addButtonDisabled={!isPaid} addButtonDisabled={!isPaid}
onRefresh={() => refetch()} onRefresh={() => refetch()}
isRefreshing={isRefetching || isLoading} isRefreshing={isRefetching || isLoading || isFiltering}
addButtonText={t("standaloneHcAddButton")} addButtonText={t("standaloneHcAddButton")}
enableColumnVisibility enableColumnVisibility
stickyLeftColumn="name" stickyLeftColumn="name"

View File

@@ -259,18 +259,24 @@ export const orgQueries = {
alertRules: ({ alertRules: ({
orgId, orgId,
limit = 20, limit = 20,
offset = 0 offset = 0,
query
}: { }: {
orgId: string; orgId: string;
limit?: number; limit?: number;
offset?: number; offset?: number;
query?: string;
}) => }) =>
queryOptions({ queryOptions({
queryKey: ["ORG", orgId, "ALERT_RULES", { limit, offset }] as const, queryKey: ["ORG", orgId, "ALERT_RULES", { limit, offset, query }] as const,
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, meta }) => {
const sp = new URLSearchParams();
sp.set("limit", String(limit));
sp.set("offset", String(offset));
if (query) sp.set("query", query);
const res = await meta!.api.get< const res = await meta!.api.get<
AxiosResponse<ListAlertRulesResponse> AxiosResponse<ListAlertRulesResponse>
>(`/org/${orgId}/alert-rules?limit=${limit}&offset=${offset}`, { signal }); >(`/org/${orgId}/alert-rules?${sp.toString()}`, { signal });
return { return {
alertRules: res.data.data.alertRules, alertRules: res.data.data.alertRules,
pagination: res.data.data.pagination pagination: res.data.data.pagination
@@ -303,15 +309,21 @@ export const orgQueries = {
standaloneHealthChecks: ({ standaloneHealthChecks: ({
orgId, orgId,
limit = 20, limit = 20,
offset = 0 offset = 0,
query
}: { }: {
orgId: string; orgId: string;
limit?: number; limit?: number;
offset?: number; offset?: number;
query?: string;
}) => }) =>
queryOptions({ queryOptions({
queryKey: ["ORG", orgId, "STANDALONE_HEALTH_CHECKS", { limit, offset }] as const, queryKey: ["ORG", orgId, "STANDALONE_HEALTH_CHECKS", { limit, offset, query }] as const,
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, meta }) => {
const sp = new URLSearchParams();
sp.set("limit", String(limit));
sp.set("offset", String(offset));
if (query) sp.set("query", query);
const res = await meta!.api.get< const res = await meta!.api.get<
AxiosResponse<{ AxiosResponse<{
healthChecks: { healthChecks: {
@@ -344,7 +356,7 @@ export const orgQueries = {
offset: number; offset: number;
}; };
}> }>
>(`/org/${orgId}/health-checks?limit=${limit}&offset=${offset}`, { signal }); >(`/org/${orgId}/health-checks?${sp.toString()}`, { signal });
return { return {
healthChecks: res.data.data.healthChecks, healthChecks: res.data.data.healthChecks,
pagination: res.data.data.pagination pagination: res.data.data.pagination