pam: resources table server-side filter, search, pagination

This commit is contained in:
x032205
2025-11-07 16:54:24 -05:00
parent 433c16c732
commit 87f3032813
12 changed files with 251 additions and 115 deletions
+7
View File
@@ -1,3 +1,4 @@
// Resources
export enum PamResourceType {
Postgres = "postgres",
MySQL = "mysql",
@@ -6,6 +7,11 @@ export enum PamResourceType {
Kubernetes = "kubernetes"
}
export enum PamResourceOrderBy {
Name = "name"
}
// Sessions
export enum PamSessionStatus {
Starting = "starting",
Active = "active",
@@ -13,6 +19,7 @@ export enum PamSessionStatus {
Terminated = "terminated"
}
// Accounts
export enum PamAccountOrderBy {
Name = "name"
}
+3 -3
View File
@@ -31,7 +31,7 @@ export const useCreatePamResource = () => {
return data.resource;
},
onSuccess: ({ projectId }) => {
queryClient.invalidateQueries({ queryKey: pamKeys.listResources(projectId) });
queryClient.invalidateQueries({ queryKey: pamKeys.listResources({ projectId }) });
}
});
};
@@ -48,7 +48,7 @@ export const useUpdatePamResource = () => {
return data.resource;
},
onSuccess: ({ projectId }) => {
queryClient.invalidateQueries({ queryKey: pamKeys.listResources(projectId) });
queryClient.invalidateQueries({ queryKey: pamKeys.listResources({ projectId }) });
}
});
};
@@ -64,7 +64,7 @@ export const useDeletePamResource = () => {
return data.resource;
},
onSuccess: ({ projectId }) => {
queryClient.invalidateQueries({ queryKey: pamKeys.listResources(projectId) });
queryClient.invalidateQueries({ queryKey: pamKeys.listResources({ projectId }) });
}
});
};
+27 -11
View File
@@ -4,7 +4,14 @@ import { apiRequest } from "@app/config/request";
import { TPamResourceOption } from "./types/resource-options";
import { PamResourceType } from "./enums";
import { TListPamAccountsDTO, TPamAccount, TPamFolder, TPamResource, TPamSession } from "./types";
import {
TListPamAccountsDTO,
TListPamResourcesDTO,
TPamAccount,
TPamFolder,
TPamResource,
TPamSession
} from "./types";
export const pamKeys = {
all: ["pam"] as const,
@@ -12,7 +19,12 @@ export const pamKeys = {
account: () => [...pamKeys.all, "account"] as const,
session: () => [...pamKeys.all, "session"] as const,
listResourceOptions: () => [...pamKeys.resource(), "options"] as const,
listResources: (projectId: string) => [...pamKeys.resource(), "list", projectId],
listResources: ({ projectId, ...params }: TListPamResourcesDTO) => [
...pamKeys.resource(),
"list",
projectId,
params
],
getResource: (resourceType: string, resourceId: string) => [
...pamKeys.resource(),
"get",
@@ -54,27 +66,31 @@ export const useListPamResourceOptions = (
});
};
type TListPamResourcesResponse = {
resources: TPamResource[];
totalCount: number;
};
export const useListPamResources = (
projectId: string,
params: TListPamResourcesDTO,
options?: Omit<
UseQueryOptions<
TPamResource[],
TListPamResourcesResponse,
unknown,
TPamResource[],
TListPamResourcesResponse,
ReturnType<typeof pamKeys.listResources>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: pamKeys.listResources(projectId),
queryKey: pamKeys.listResources(params),
queryFn: async () => {
const { data } = await apiRequest.get<{ resources: TPamResource[] }>(
"/api/v1/pam/resources",
{ params: { projectId } }
);
const { data } = await apiRequest.get<TListPamResourcesResponse>("/api/v1/pam/resources", {
params
});
return data.resources;
return data;
},
...options
});
+16 -1
View File
@@ -1,5 +1,11 @@
import { OrderByDirection } from "../../generic/types";
import { PamAccountOrderBy, PamAccountView, PamResourceType, PamSessionStatus } from "../enums";
import {
PamAccountOrderBy,
PamAccountView,
PamResourceOrderBy,
PamResourceType,
PamSessionStatus
} from "../enums";
import { TMySQLAccount, TMySQLResource } from "./mysql-resource";
import { TPostgresAccount, TPostgresResource } from "./postgres-resource";
@@ -46,6 +52,15 @@ export type TPamSession = {
};
// Resource DTOs
export type TListPamResourcesDTO = {
projectId: string;
offset?: number;
limit?: number;
orderBy?: PamResourceOrderBy;
orderDirection?: OrderByDirection;
search?: string;
};
export type TCreatePamResourceDTO = Pick<
TPamResource,
"name" | "connectionDetails" | "resourceType" | "gatewayId" | "projectId"
@@ -1,11 +1,12 @@
import { useState } from "react";
import { Controller, FormProvider, useForm } from "react-hook-form";
import { SingleValue } from "react-select";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button, FilterableSelect, FormControl, ModalClose, Spinner } from "@app/components/v2";
import { Button, FilterableSelect, FormControl, ModalClose } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useDebounce, usePopUp } from "@app/hooks";
import { PamResourceType, useListPamResources } from "@app/hooks/api/pam";
import { PamAddResourceModal } from "../../PamResourcesPage/components/PamAddResourceModal";
@@ -28,7 +29,17 @@ type FormData = z.infer<typeof formSchema>;
export const ResourceSelect = ({ onSubmit, projectId }: Props) => {
const { permission } = useProjectPermission();
const { isPending, data: resources } = useListPamResources(projectId);
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebounce(search, 350);
const { isPending, data } = useListPamResources({
projectId,
limit: 100,
search: debouncedSearch
});
const resources = data?.resources || [];
const { popUp, handlePopUpToggle, handlePopUpOpen } = usePopUp(["addResource"] as const);
@@ -43,15 +54,6 @@ export const ResourceSelect = ({ onSubmit, projectId }: Props) => {
ProjectPermissionSub.PamResources
);
if (isPending) {
return (
<div className="flex h-full flex-col items-center justify-center py-2.5">
<Spinner size="lg" className="text-mineshaft-500" />
<p className="mt-4 text-sm text-mineshaft-400">Loading options...</p>
</div>
);
}
return (
<>
<FormProvider {...form}>
@@ -65,6 +67,12 @@ export const ResourceSelect = ({ onSubmit, projectId }: Props) => {
<FormControl isError={Boolean(error)} errorText={error?.message} label="Resource">
<FilterableSelect
value={value}
inputValue={search}
onInputChange={(val, actionMeta) => {
if (actionMeta.action === "input-change") {
setSearch(val);
}
}}
onChange={(newValue) => {
if ((newValue as SingleValue<{ id: string }>)?.id === "_create") {
handlePopUpOpen("addResource");
@@ -1,17 +1,9 @@
import { ContentLoader } from "@app/components/v2";
import { useProject } from "@app/context";
import { useListPamResources } from "@app/hooks/api/pam";
import { PamResourcesTable } from "./PamResourcesTable";
export const PamResourcesSection = () => {
const { currentProject } = useProject();
const { data: resources = [], isPending } = useListPamResources(currentProject.id, {
refetchInterval: 30000
});
if (isPending) return <ContentLoader />;
return <PamResourcesTable resources={resources} projectId={currentProject.id} />;
return <PamResourcesTable projectId={currentProject.id} />;
};
@@ -27,6 +27,7 @@ import {
Pagination,
Table,
TableContainer,
TableSkeleton,
TBody,
Th,
THead,
@@ -39,29 +40,34 @@ import {
OrgGatewayPermissionActions,
OrgPermissionSubjects
} from "@app/context/OrgPermissionContext/types";
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, usePopUp } from "@app/hooks";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { PAM_RESOURCE_TYPE_MAP, PamResourceType, TPamResource } from "@app/hooks/api/pam";
import {
PAM_RESOURCE_TYPE_MAP,
PamResourceOrderBy,
PamResourceType,
useListPamResources
} from "@app/hooks/api/pam";
import { PamAddResourceModal } from "./PamAddResourceModal";
import { PamDeleteResourceModal } from "./PamDeleteResourceModal";
import { PamResourceRow } from "./PamResourceRow";
import { PamUpdateResourceModal } from "./PamUpdateResourceModal";
enum OrderBy {
Name = "name"
}
type Filters = {
resourceType: PamResourceType[];
};
type Props = {
projectId: string;
resources: TPamResource[];
};
export const PamResourcesTable = ({ projectId, resources }: Props) => {
export const PamResourcesTable = ({ projectId }: Props) => {
const navigate = useNavigate({ from: ROUTE_PATHS.Pam.ResourcesPage.path });
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
@@ -80,6 +86,7 @@ export const PamResourcesTable = ({ projectId, resources }: Props) => {
const {
search,
debouncedSearch,
setSearch,
setPage,
page,
@@ -91,51 +98,48 @@ export const PamResourcesTable = ({ projectId, resources }: Props) => {
orderBy,
setOrderDirection,
setOrderBy
} = usePagination<OrderBy>(OrderBy.Name, { initPerPage: 20, initSearch });
} = usePagination<PamResourceOrderBy>(PamResourceOrderBy.Name, {
initPerPage: getUserTablePreference("pamResourcesTable", PreferenceKey.PerPage, 20),
initSearch
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("pamResourcesTable", PreferenceKey.PerPage, newPerPage);
};
const { data, isLoading } = useListPamResources({
projectId,
offset,
limit: perPage,
search: debouncedSearch,
orderBy,
orderDirection
});
const resources = data?.resources || [];
const totalCount = data?.totalCount || 0;
const filteredResources = useMemo(
() =>
resources
.filter((resource) => {
const { name, resourceType } = resource;
resources.filter((resource) => {
const { name, resourceType } = resource;
if (filters.resourceType.length && !filters.resourceType.includes(resourceType)) {
return false;
}
if (filters.resourceType.length && !filters.resourceType.includes(resourceType)) {
return false;
}
const searchValue = search.trim().toLowerCase();
const searchValue = search.trim().toLowerCase();
const { name: resourceTypeName } = PAM_RESOURCE_TYPE_MAP[resourceType];
return (
name.toLowerCase().includes(searchValue) ||
resourceTypeName.toLowerCase().includes(searchValue)
);
})
.sort((a, b) => {
const [one, two] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
switch (orderBy) {
case OrderBy.Name:
default:
return one.name.toLowerCase().localeCompare(two.name.toLowerCase());
}
}),
[resources, orderDirection, search, orderBy, filters]
return (
name.toLowerCase().includes(searchValue) ||
resourceType.toLowerCase().includes(searchValue)
);
}),
[resources, search, filters]
);
useResetPageHelper({
totalCount: filteredResources.length,
offset,
setPage
});
const currentPageData = useMemo(
() => filteredResources.slice(offset, perPage * page),
[filteredResources, offset, perPage, page]
);
const handleSort = (column: OrderBy) => {
const handleSort = (column: PamResourceOrderBy) => {
if (column === orderBy) {
toggleOrderDirection();
return;
@@ -145,9 +149,10 @@ export const PamResourcesTable = ({ projectId, resources }: Props) => {
setOrderDirection(OrderByDirection.ASC);
};
const getClassName = (col: OrderBy) => twMerge("ml-2", orderBy === col ? "" : "opacity-30");
const getClassName = (col: PamResourceOrderBy) =>
twMerge("ml-2", orderBy === col ? "" : "opacity-30");
const getColSortIcon = (col: OrderBy) =>
const getColSortIcon = (col: PamResourceOrderBy) =>
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
const isTableFiltered = Boolean(filters.resourceType.length);
@@ -263,11 +268,11 @@ export const PamResourcesTable = ({ projectId, resources }: Props) => {
Resource
<IconButton
variant="plain"
className={getClassName(OrderBy.Name)}
className={getClassName(PamResourceOrderBy.Name)}
ariaLabel="sort"
onClick={() => handleSort(OrderBy.Name)}
onClick={() => handleSort(PamResourceOrderBy.Name)}
>
<FontAwesomeIcon icon={getColSortIcon(OrderBy.Name)} />
<FontAwesomeIcon icon={getColSortIcon(PamResourceOrderBy.Name)} />
</IconButton>
</div>
</Th>
@@ -275,27 +280,29 @@ export const PamResourcesTable = ({ projectId, resources }: Props) => {
</Tr>
</THead>
<TBody>
{currentPageData.map((resource) => (
<PamResourceRow
key={resource.id}
resource={resource}
onUpdate={(e) => handlePopUpOpen("updateResource", e)}
onDelete={(e) => handlePopUpOpen("deleteResource", e)}
search={search.trim().toLowerCase()}
/>
))}
{isLoading && <TableSkeleton columns={2} innerKey="pam-resources" />}
{!isLoading &&
filteredResources.map((resource) => (
<PamResourceRow
key={resource.id}
resource={resource}
onUpdate={(e) => handlePopUpOpen("updateResource", e)}
onDelete={(e) => handlePopUpOpen("deleteResource", e)}
search={search.trim().toLowerCase()}
/>
))}
</TBody>
</Table>
{Boolean(filteredResources.length) && (
{Boolean(totalCount) && !isLoading && (
<Pagination
count={filteredResources.length}
count={totalCount}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={handlePerPageChange}
/>
)}
{isContentEmpty && (
{!isLoading && isContentEmpty && (
<EmptyState
title={isSearchEmpty ? "No resources match search" : "No resources"}
icon={isSearchEmpty ? faSearch : faCircleXmark}