import { useCallback, useEffect, useRef, useState } from "react"; import { MultiValue } from "react-select"; import { faArrowDown, faArrowUp, faCheckCircle, faFilter, faFingerprint, faFolder, faKey, faRotate, faSearch } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { AlertTriangleIcon } from "lucide-react"; import { twMerge } from "tailwind-merge"; import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger, EmptyState, FilterableSelect, FormLabel, IconButton, Input, Lottie, Pagination, Table, TableContainer, TBody, Th, THead, Tooltip, Tr } from "@app/components/v2"; import { Badge } from "@app/components/v3"; import { useProject } from "@app/context"; import { getUserTablePreference, PreferenceKey, setUserTablePreference } from "@app/helpers/userTablePreferences"; import { useDebounce, usePagination, useResetPageHelper } from "@app/hooks"; import { useGetImportedSecretsAllEnvs } from "@app/hooks/api"; import { useGetProjectSecretsOverview } from "@app/hooks/api/dashboard"; import { DashboardSecretsOrderBy } from "@app/hooks/api/dashboard/types"; import { OrderByDirection } from "@app/hooks/api/generic/types"; import { ProjectEnv } from "@app/hooks/api/projects/types"; import { useResizableColWidth } from "@app/hooks/useResizableColWidth"; import { useDynamicSecretOverview, useFolderOverview, useSecretOverview, useSecretRotationOverview } from "@app/hooks/utils"; import { SecretTableResourceCount } from "@app/pages/secret-manager/OverviewPage/components/SecretTableResourceCount"; import { DynamicSecretRow } from "./components/DynamicSecretRow"; import { FolderRow } from "./components/FolderRow"; import { SecretRotationRow } from "./components/SecretRotationRow"; import { SecretNoAccessRow, SecretRow } from "./components/SecretRow"; type Props = { secretPath: string; }; enum RowType { Folder = "folder", DynamicSecret = "dynamic", Secret = "secret", SecretRotation = "rotation" } type Filter = { [key in RowType]: boolean; }; const DEFAULT_FILTER_STATE = { [RowType.Folder]: false, [RowType.DynamicSecret]: false, [RowType.Secret]: false, [RowType.SecretRotation]: false }; const COL_WIDTH_OFFSET = 220; export const CompareEnvironments = ({ secretPath }: Props) => { const { currentProject } = useProject(); const compareEnvironmentsKey = `compare-environments-${currentProject.id}`; const [selectedEnvironments, setSelectedEnvironments] = useState(() => { try { const storedEnvironments = JSON.parse(localStorage.getItem(compareEnvironmentsKey) ?? "[]"); if (Array.isArray(storedEnvironments) && storedEnvironments.length > 0) { const potentialEnvs: string[] = []; storedEnvironments.forEach((env) => { if (typeof env === "string") { potentialEnvs.push(env); } }); return currentProject.environments.filter((env) => potentialEnvs.includes(env.id)); } } catch { // do nothing and proceed } return currentProject.environments.slice(0, 2); }); const [filter, setFilter] = useState(DEFAULT_FILTER_STATE); const { offset, limit, orderDirection, setOrderDirection, setPage, perPage, page, setPerPage, orderBy } = usePagination(DashboardSecretsOrderBy.Name, { initPerPage: getUserTablePreference("secretCompareTable", PreferenceKey.PerPage, 50) }); const handlePerPageChange = (newPerPage: number) => { setPerPage(newPerPage); setUserTablePreference("secretCompareTable", PreferenceKey.PerPage, newPerPage); }; const projectId = currentProject.id; const [searchFilter, setSearchFilter] = useState(""); const [debouncedSearchFilter] = useDebounce(searchFilter); const [debouncedSelectedEnvironments] = useDebounce(selectedEnvironments); useEffect(() => { localStorage.setItem( compareEnvironmentsKey, JSON.stringify(selectedEnvironments.map((env) => env.id)) ); }, [debouncedSelectedEnvironments]); const { secretImports, isImportedSecretPresentInEnv, getImportedSecretByKey, getEnvImportedSecretKeyCount } = useGetImportedSecretsAllEnvs({ projectId, path: secretPath, environments: (currentProject.environments || []).map(({ slug }) => slug) }); const compareEnvironments = selectedEnvironments.length ? selectedEnvironments : currentProject.environments; const isFilteredByResources = Object.values(filter).some(Boolean); const { isPending: isOverviewLoading, data: overview } = useGetProjectSecretsOverview( { projectId, environments: compareEnvironments.map((env) => env.slug), secretPath, orderDirection, orderBy, includeFolders: isFilteredByResources ? filter.folder : true, includeDynamicSecrets: isFilteredByResources ? filter.dynamic : true, includeSecrets: isFilteredByResources ? filter.secret : true, includeImports: true, includeSecretRotations: isFilteredByResources ? filter.rotation : true, search: debouncedSearchFilter, limit, offset }, { enabled: Boolean(compareEnvironments.length) } ); const { secrets, folders, dynamicSecrets, secretRotations, totalFolderCount, totalSecretCount, totalDynamicSecretCount, totalSecretRotationCount, totalCount = 0, totalUniqueFoldersInPage, totalUniqueSecretsInPage, totalUniqueSecretImportsInPage, totalUniqueDynamicSecretsInPage, totalUniqueSecretRotationsInPage } = overview ?? {}; const secretImportsShaped = secretImports ?.flatMap(({ data }) => data) .filter(Boolean) .flatMap((item) => item?.secrets || []); const handleIsImportedSecretPresentInEnv = (envSlug: string, secretName: string) => { if (secrets?.some((s) => s.key === secretName && s.env === envSlug)) { return false; } if (secretImportsShaped.some((s) => s.key === secretName && s.sourceEnv === envSlug)) { return true; } return isImportedSecretPresentInEnv(envSlug, secretName); }; useResetPageHelper({ totalCount, offset, setPage }); const { folderNamesAndDescriptions, isFolderPresentInEnv } = useFolderOverview(folders); const { dynamicSecretNames, isDynamicSecretPresentInEnv } = useDynamicSecretOverview(dynamicSecrets); const { secretRotationNames, isSecretRotationPresentInEnv, getSecretRotationByName } = useSecretRotationOverview(secretRotations); const { secKeys, getEnvSecretKeyCount } = useSecretOverview( secrets?.concat(secretImportsShaped) || [] ); const getSecretByKey = useCallback( (env: string, key: string) => { const sec = secrets?.find((s) => s.env === env && s.key === key); return sec; }, [secrets] ); const [tableWidth, setTableWidth] = useState(0); const tableRef = useRef(null); const { handleMouseDown, isResizing, colWidth } = useResizableColWidth({ initialWidth: 320, minWidth: 160, maxWidth: tableRef.current ? tableRef.current.clientWidth - COL_WIDTH_OFFSET // ensure value column can't collapse completely : 800, ref: tableRef }); const handleToggleRowType = useCallback( (rowType: RowType) => setFilter((state) => { return { ...state, [rowType]: !state[rowType] }; }), [] ); const isTableEmpty = totalCount === 0; const isTableFiltered = isFilteredByResources; useEffect(() => { const element = tableRef.current; if (!element) return; const handleResize = () => { setTableWidth(element.clientWidth - 1); }; const resizeObserver = new ResizeObserver(handleResize); resizeObserver.observe(element); // eslint-disable-next-line consistent-return return () => { resizeObserver.disconnect(); }; }, [tableRef]); return ( // scott: this is reverse to fix z-indexing bug of dropdown with sticky table cols; couldn't resolve with flex-col
{!isOverviewLoading && totalCount > 0 && ( } className="rounded-b-lg border border-solid border-mineshaft-500 bg-mineshaft-700" count={totalCount} page={page} perPage={perPage} onChangePage={(newPage) => setPage(newPage)} onChangePerPage={handlePerPageChange} /> )}
{/* eslint-disable-next-line no-nested-ternary */} {isOverviewLoading ? (
) : isTableEmpty ? ( ) : ( {compareEnvironments?.map(({ name, slug }, index) => { const envSecKeyCount = getEnvSecretKeyCount(slug); const importedSecKeyCount = getEnvImportedSecretKeyCount(slug); const missingKeyCount = secKeys.length - envSecKeyCount - importedSecKeyCount; return ( ); })} {folderNamesAndDescriptions.map(({ name: folderName }, index) => ( ))} {dynamicSecretNames.map((dynamicSecretName, index) => ( ))} {secretRotationNames.map((secretRotationName, index) => ( ))} {secKeys.map((key, index) => ( ))} totalCount ? totalCount % perPage : perPage) - (totalUniqueFoldersInPage || 0) - (totalUniqueDynamicSecretsInPage || 0) - (totalUniqueSecretsInPage || 0) - (totalUniqueSecretImportsInPage || 0) - (totalUniqueSecretRotationsInPage || 0), 0 )} />
Name setOrderDirection((prev) => prev === OrderByDirection.ASC ? OrderByDirection.DESC : OrderByDirection.ASC ) } >
{name} {missingKeyCount > 0 && ( {missingKeyCount} secret{missingKeyCount > 1 ? "s" : ""} missing compared to other environments on this page } > {missingKeyCount} )}
)}
setSearchFilter(e.target.value)} className="h-full flex-1" placeholder="Search by resource name..." leftIcon={} containerClassName="h-10" /> {isTableFiltered && ( )} {compareEnvironments.length > 0 && ( Filter by Resource { e.preventDefault(); handleToggleRowType(RowType.Folder); }} icon={filter[RowType.Folder] && } iconPos="right" >
Folders
{ e.preventDefault(); handleToggleRowType(RowType.DynamicSecret); }} icon={filter[RowType.DynamicSecret] && } iconPos="right" >
Dynamic Secrets
{ e.preventDefault(); handleToggleRowType(RowType.SecretRotation); }} icon={filter[RowType.SecretRotation] && } iconPos="right" >
Secret Rotations
{ e.preventDefault(); handleToggleRowType(RowType.Secret); }} icon={filter[RowType.Secret] && } iconPos="right" >
Secrets
)}
{ const selected = value as MultiValue; setSelectedEnvironments((selected as ProjectEnv[]) ?? []); }} placeholder="Leave blank to compare all environments" options={currentProject.environments} getOptionValue={(option) => option.slug} getOptionLabel={(option) => option.name} isMulti />
); };