mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-21 03:50:10 +00:00
feat(pam): PAM Platform V1
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { PageHeader } from "@app/components/v2";
|
||||
import { ProjectPermissionSub } from "@app/context";
|
||||
import { ProjectPermissionPamSessionActions } from "@app/context/ProjectPermissionContext/types";
|
||||
|
||||
import { PamSessionSection } from "./components/PamSessionSection";
|
||||
|
||||
export const PamSessionPage = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{t("common.head-title", { title: "PAM" })}</title>
|
||||
</Helmet>
|
||||
<ProjectPermissionCan
|
||||
renderGuardBanner
|
||||
I={ProjectPermissionPamSessionActions.Read}
|
||||
a={ProjectPermissionSub.PamSessions}
|
||||
>
|
||||
<div className="h-full bg-bunker-800">
|
||||
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
||||
<div className="mx-auto mb-6 w-full max-w-7xl">
|
||||
<PageHeader
|
||||
title="Sessions"
|
||||
description="Filter and search through account sessions."
|
||||
/>
|
||||
<PamSessionSection />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ProjectPermissionCan>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
faBoxOpen,
|
||||
faChevronDown,
|
||||
faChevronUp,
|
||||
faEdit,
|
||||
faEllipsisV,
|
||||
faTerminal
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
IconButton,
|
||||
Td,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { HighlightText } from "@app/components/v2/HighlightText";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
import { PAM_RESOURCE_TYPE_MAP, TPamSession } from "@app/hooks/api/pam";
|
||||
|
||||
import { PamSessionStatusBadge } from "./PamSessionStatusBadge";
|
||||
|
||||
type Props = {
|
||||
session: TPamSession;
|
||||
search: string;
|
||||
filteredCommandLogs: TPamSession["commandLogs"];
|
||||
};
|
||||
|
||||
export const PamSessionRow = ({ session, search, filteredCommandLogs }: Props) => {
|
||||
const router = useRouter();
|
||||
const [showAllLogs, setShowAllLogs] = useState(false);
|
||||
|
||||
const {
|
||||
id,
|
||||
accountName,
|
||||
resourceType,
|
||||
resourceName,
|
||||
projectId,
|
||||
status,
|
||||
actorName,
|
||||
actorEmail,
|
||||
createdAt,
|
||||
endedAt
|
||||
} = session;
|
||||
|
||||
const { image, name: resourceTypeName } = PAM_RESOURCE_TYPE_MAP[resourceType];
|
||||
|
||||
const LOGS_TO_SHOW = 5;
|
||||
const logsToShow = showAllLogs ? filteredCommandLogs : filteredCommandLogs.slice(0, LOGS_TO_SHOW);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tr
|
||||
className={twMerge("group h-10 cursor-pointer hover:bg-bunker-400/20")}
|
||||
onClick={() => router.history.push(`/projects/pam/${projectId}/sessions/${id}`)}
|
||||
>
|
||||
<Td>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<img
|
||||
alt={resourceTypeName}
|
||||
src={`/images/integrations/${image}`}
|
||||
className="size-6"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-col">
|
||||
<span>
|
||||
<HighlightText text={accountName} highlight={search} />
|
||||
</span>
|
||||
<div className="flex items-center gap-1 text-xs text-bunker-300">
|
||||
<FontAwesomeIcon icon={faBoxOpen} className="size-3" />
|
||||
<span>
|
||||
<HighlightText text={resourceName} highlight={search} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex flex-col">
|
||||
<span>
|
||||
<HighlightText text={actorName} highlight={search} />
|
||||
</span>
|
||||
<span className="text-xs text-bunker-300">
|
||||
<HighlightText text={actorEmail} highlight={search} />
|
||||
</span>
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex flex-col">
|
||||
<span>{new Date(createdAt).toLocaleTimeString()}</span>
|
||||
<span className="text-xs text-bunker-300">
|
||||
{new Date(createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
{endedAt ? (
|
||||
<div className="flex flex-col">
|
||||
<span>{new Date(endedAt).toLocaleTimeString()}</span>
|
||||
<span className="text-xs text-bunker-300">
|
||||
{new Date(endedAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-bunker-400">Ongoing</span>
|
||||
)}
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<PamSessionStatusBadge status={status} />
|
||||
<Tooltip className="max-w-sm text-center" content="Options">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Options"
|
||||
colorSchema="secondary"
|
||||
className="w-6"
|
||||
variant="plain"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisV} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent sideOffset={2} align="end">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={ProjectPermissionSub.PamResources}
|
||||
>
|
||||
{(isAllowed: boolean) => (
|
||||
<DropdownMenuItem
|
||||
isDisabled={!isAllowed}
|
||||
icon={<FontAwesomeIcon icon={faEdit} />}
|
||||
onClick={() =>
|
||||
router.history.push(`/projects/pam/${projectId}/sessions/${id}`)
|
||||
}
|
||||
>
|
||||
View Session
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
|
||||
{filteredCommandLogs.length > 0 && (
|
||||
<Tr>
|
||||
<Td colSpan={5} className="py-3 text-xs">
|
||||
{logsToShow.map((log) => (
|
||||
<div key={`${id}-log-${log.timestamp}`} className="mb-4 flex flex-col last:mb-0">
|
||||
<div className="flex items-center gap-1.5 text-bunker-400">
|
||||
<FontAwesomeIcon icon={faTerminal} className="size-3" />
|
||||
<span>{new Date(log.timestamp).toLocaleString()}</span>
|
||||
</div>
|
||||
|
||||
<div className="font-mono">
|
||||
<HighlightText text={log.input} highlight={search} />
|
||||
</div>
|
||||
<div className="font-mono text-bunker-300">
|
||||
<HighlightText text={log.output} highlight={search} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{filteredCommandLogs.length > LOGS_TO_SHOW && (
|
||||
<div className="mt-2">
|
||||
<Button
|
||||
variant="link"
|
||||
size="xs"
|
||||
leftIcon={<FontAwesomeIcon icon={showAllLogs ? faChevronUp : faChevronDown} />}
|
||||
onClick={() => setShowAllLogs(!showAllLogs)}
|
||||
className="p-0 text-mineshaft-300 hover:text-primary"
|
||||
>
|
||||
{showAllLogs
|
||||
? "Show less"
|
||||
: `Show ${filteredCommandLogs.length - LOGS_TO_SHOW} more log${filteredCommandLogs.length - LOGS_TO_SHOW === 1 ? "" : "s"}`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ContentLoader } from "@app/components/v2";
|
||||
import { useProject } from "@app/context";
|
||||
import { useListPamSessions } from "@app/hooks/api/pam";
|
||||
|
||||
import { PamSessionsTable } from "./PamSessionsTable";
|
||||
|
||||
export const PamSessionSection = () => {
|
||||
const { currentProject } = useProject();
|
||||
|
||||
const { data: sessions = [], isPending } = useListPamSessions(currentProject.id, {
|
||||
refetchInterval: 30000
|
||||
});
|
||||
|
||||
if (isPending) return <ContentLoader />;
|
||||
|
||||
return <PamSessionsTable sessions={sessions} />;
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
faBan,
|
||||
faCircle,
|
||||
faGavel,
|
||||
faHourglass,
|
||||
IconDefinition
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { Badge } from "@app/components/v2";
|
||||
import { PamSessionStatus } from "@app/hooks/api/pam";
|
||||
|
||||
interface StatusConfig {
|
||||
bgColor: string;
|
||||
textColor: string;
|
||||
icon: IconDefinition;
|
||||
iconClassName?: string;
|
||||
}
|
||||
|
||||
const PAM_SESSION_STATUS_CONFIG: Record<PamSessionStatus, StatusConfig> = {
|
||||
[PamSessionStatus.Active]: {
|
||||
bgColor: "bg-green/20",
|
||||
textColor: "text-green",
|
||||
icon: faCircle,
|
||||
iconClassName: "size-2 animate-pulse"
|
||||
},
|
||||
[PamSessionStatus.Terminated]: {
|
||||
bgColor: "bg-red/20",
|
||||
textColor: "text-red",
|
||||
icon: faGavel
|
||||
},
|
||||
[PamSessionStatus.Starting]: {
|
||||
bgColor: "bg-yellow/20",
|
||||
textColor: "text-yellow",
|
||||
icon: faHourglass,
|
||||
iconClassName: "animate-spin"
|
||||
},
|
||||
[PamSessionStatus.Ended]: {
|
||||
bgColor: "bg-bunker-300/20",
|
||||
textColor: "text-bunker-300",
|
||||
icon: faBan
|
||||
}
|
||||
};
|
||||
|
||||
export const PamSessionStatusBadge = ({ status }: { status: PamSessionStatus }) => {
|
||||
const config = PAM_SESSION_STATUS_CONFIG[status];
|
||||
|
||||
const displayName = status[0].toUpperCase() + status.slice(1);
|
||||
|
||||
return (
|
||||
<Badge
|
||||
className={twMerge(
|
||||
"flex h-5 w-min items-center gap-1.5 whitespace-nowrap",
|
||||
config.bgColor,
|
||||
config.textColor
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={config.icon} className={config.iconClassName} />
|
||||
{displayName}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,411 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { faCircleXmark } from "@fortawesome/free-regular-svg-icons";
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowUp,
|
||||
faCheckCircle,
|
||||
faFilter,
|
||||
faMagnifyingGlass,
|
||||
faSearch
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
Pagination,
|
||||
Table,
|
||||
TableContainer,
|
||||
TBody,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { ROUTE_PATHS } from "@app/const/routes";
|
||||
import { usePagination, useResetPageHelper } from "@app/hooks";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import {
|
||||
PAM_RESOURCE_TYPE_MAP,
|
||||
PamResourceType,
|
||||
PamSessionStatus,
|
||||
TPamSession
|
||||
} from "@app/hooks/api/pam";
|
||||
|
||||
import { PamSessionRow } from "./PamSessionRow";
|
||||
|
||||
enum OrderBy {
|
||||
Account = "account",
|
||||
Actor = "actor",
|
||||
CreatedAt = "createdAt",
|
||||
EndedAt = "endedAt"
|
||||
}
|
||||
|
||||
type Filters = {
|
||||
resourceType: PamResourceType[];
|
||||
status: PamSessionStatus[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
sessions: TPamSession[];
|
||||
};
|
||||
|
||||
export const PamSessionsTable = ({ sessions }: Props) => {
|
||||
const navigate = useNavigate({ from: ROUTE_PATHS.Pam.SessionsPage.path });
|
||||
|
||||
const { search: initSearch } = useSearch({
|
||||
from: ROUTE_PATHS.Pam.SessionsPage.id
|
||||
});
|
||||
|
||||
const [filters, setFilters] = useState<Filters>({
|
||||
resourceType: [],
|
||||
status: []
|
||||
});
|
||||
|
||||
const {
|
||||
search,
|
||||
setSearch,
|
||||
setPage,
|
||||
page,
|
||||
perPage,
|
||||
setPerPage,
|
||||
offset,
|
||||
orderDirection,
|
||||
toggleOrderDirection,
|
||||
orderBy,
|
||||
setOrderDirection,
|
||||
setOrderBy
|
||||
} = usePagination<OrderBy>(OrderBy.CreatedAt, {
|
||||
initPerPage: 20,
|
||||
initSearch
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setOrderDirection(OrderByDirection.DESC);
|
||||
}, []);
|
||||
|
||||
const sessionsWithMatches = useMemo(() => {
|
||||
const searchValue = search.trim().toLowerCase();
|
||||
|
||||
if (!searchValue) {
|
||||
return sessions.map((session) => ({
|
||||
session,
|
||||
isMatch: true,
|
||||
filteredLogs: []
|
||||
}));
|
||||
}
|
||||
|
||||
return sessions.map((session) => {
|
||||
const {
|
||||
resourceType,
|
||||
accountName,
|
||||
actorEmail,
|
||||
actorIp,
|
||||
actorName,
|
||||
actorUserAgent,
|
||||
id,
|
||||
resourceName,
|
||||
userId,
|
||||
commandLogs
|
||||
} = session;
|
||||
|
||||
const { name: resourceTypeName } = PAM_RESOURCE_TYPE_MAP[resourceType];
|
||||
|
||||
const isMetaMatch =
|
||||
resourceTypeName.toLowerCase().includes(searchValue) ||
|
||||
accountName.toLowerCase().includes(searchValue) ||
|
||||
actorEmail.toLowerCase().includes(searchValue) ||
|
||||
actorIp.toLowerCase().includes(searchValue) ||
|
||||
actorName.toLowerCase().includes(searchValue) ||
|
||||
actorUserAgent.toLowerCase().includes(searchValue) ||
|
||||
id.toLowerCase().includes(searchValue) ||
|
||||
(userId ?? "").toLowerCase().includes(searchValue) ||
|
||||
resourceName.toLowerCase().includes(searchValue);
|
||||
|
||||
const filteredLogs =
|
||||
searchValue.length >= 2
|
||||
? commandLogs.filter(
|
||||
(log) =>
|
||||
log.input.toLowerCase().includes(searchValue) ||
|
||||
log.output.toLowerCase().includes(searchValue)
|
||||
)
|
||||
: [];
|
||||
|
||||
return {
|
||||
session,
|
||||
isMatch: isMetaMatch || filteredLogs.length > 0,
|
||||
filteredLogs
|
||||
};
|
||||
});
|
||||
}, [sessions, search]);
|
||||
|
||||
const filteredSessions = useMemo(
|
||||
() =>
|
||||
sessionsWithMatches
|
||||
.filter((item) => {
|
||||
if (!item.isMatch) return false;
|
||||
|
||||
const { resourceType, status } = item.session;
|
||||
if (
|
||||
(filters.resourceType.length && !filters.resourceType.includes(resourceType)) ||
|
||||
(filters.status.length && !filters.status.includes(status))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const [one, two] =
|
||||
orderDirection === OrderByDirection.ASC
|
||||
? [a.session, b.session]
|
||||
: [b.session, a.session];
|
||||
|
||||
switch (orderBy) {
|
||||
case OrderBy.Account:
|
||||
return one.accountName.toLowerCase().localeCompare(two.accountName.toLowerCase());
|
||||
case OrderBy.Actor:
|
||||
return one.actorName.toLowerCase().localeCompare(two.actorName.toLowerCase());
|
||||
case OrderBy.EndedAt: {
|
||||
const dateOne = one.endedAt || one.createdAt;
|
||||
const dateTwo = two.endedAt || two.createdAt;
|
||||
return new Date(dateOne).getTime() - new Date(dateTwo).getTime();
|
||||
}
|
||||
case OrderBy.CreatedAt:
|
||||
default: {
|
||||
const dateOne = one.createdAt;
|
||||
const dateTwo = two.createdAt;
|
||||
return new Date(dateOne).getTime() - new Date(dateTwo).getTime();
|
||||
}
|
||||
}
|
||||
}),
|
||||
[sessionsWithMatches, orderDirection, orderBy, filters]
|
||||
);
|
||||
|
||||
useResetPageHelper({
|
||||
totalCount: filteredSessions.length,
|
||||
offset,
|
||||
setPage
|
||||
});
|
||||
|
||||
const currentPageData = useMemo(
|
||||
() => filteredSessions.slice(offset, perPage * page),
|
||||
[filteredSessions, offset, perPage, page]
|
||||
);
|
||||
|
||||
const handleSort = (column: OrderBy) => {
|
||||
if (column === orderBy) {
|
||||
toggleOrderDirection();
|
||||
return;
|
||||
}
|
||||
|
||||
setOrderBy(column);
|
||||
setOrderDirection(OrderByDirection.ASC);
|
||||
};
|
||||
|
||||
const getClassName = (col: OrderBy) => twMerge("ml-2", orderBy === col ? "" : "opacity-30");
|
||||
|
||||
const getColSortIcon = (col: OrderBy) =>
|
||||
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
|
||||
|
||||
const isTableFiltered = Boolean(filters.resourceType.length || filters.status.length);
|
||||
const isContentEmpty = !filteredSessions.length;
|
||||
const isSearchEmpty = isContentEmpty && (Boolean(search) || isTableFiltered);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
const newSearch = e.target.value;
|
||||
setSearch(newSearch);
|
||||
navigate({
|
||||
search: (prev) => ({ ...prev, search: newSearch || undefined }),
|
||||
replace: true
|
||||
});
|
||||
}}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search sessions and logs..."
|
||||
className="flex-1"
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Filter sessions"
|
||||
variant="plain"
|
||||
size="sm"
|
||||
className={twMerge(
|
||||
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
|
||||
isTableFiltered && "border-primary/50 text-primary"
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faFilter} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="thin-scrollbar max-h-[70vh] overflow-y-auto" align="end">
|
||||
<DropdownMenuLabel>Session Status</DropdownMenuLabel>
|
||||
{sessions.length ? (
|
||||
[...new Set(sessions.map(({ status }) => status))].map((status) => {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
status: prev.status.includes(status)
|
||||
? prev.status.filter((a) => a !== status)
|
||||
: [...prev.status, status]
|
||||
}));
|
||||
}}
|
||||
key={status}
|
||||
icon={
|
||||
filters.status.includes(status) && (
|
||||
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
{status[0].toUpperCase() + status.slice(1)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<DropdownMenuItem isDisabled>No Sessions</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuLabel>Resource Type</DropdownMenuLabel>
|
||||
{sessions.length ? (
|
||||
[...new Set(sessions.map(({ resourceType }) => resourceType))].map((type) => {
|
||||
const { name, image } = PAM_RESOURCE_TYPE_MAP[type];
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
resourceType: prev.resourceType.includes(type)
|
||||
? prev.resourceType.filter((a) => a !== type)
|
||||
: [...prev.resourceType, type]
|
||||
}));
|
||||
}}
|
||||
key={type}
|
||||
icon={
|
||||
filters.resourceType.includes(type) && (
|
||||
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
alt={`${name} resource type`}
|
||||
src={`/images/integrations/${image}`}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<span>{name}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<DropdownMenuItem isDisabled>No Sessions</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<TableContainer className="mt-4">
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>
|
||||
<div className="flex items-center">
|
||||
Account
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={getClassName(OrderBy.Account)}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(OrderBy.Account)}
|
||||
>
|
||||
<FontAwesomeIcon icon={getColSortIcon(OrderBy.Account)} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th>
|
||||
<div className="flex items-center">
|
||||
Actor
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={getClassName(OrderBy.Actor)}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(OrderBy.Actor)}
|
||||
>
|
||||
<FontAwesomeIcon icon={getColSortIcon(OrderBy.Actor)} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th>
|
||||
<div className="flex items-center">
|
||||
Created At
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={getClassName(OrderBy.CreatedAt)}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(OrderBy.CreatedAt)}
|
||||
>
|
||||
<FontAwesomeIcon icon={getColSortIcon(OrderBy.CreatedAt)} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th>
|
||||
<div className="flex items-center">
|
||||
Ended At
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={getClassName(OrderBy.EndedAt)}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(OrderBy.EndedAt)}
|
||||
>
|
||||
<FontAwesomeIcon icon={getColSortIcon(OrderBy.EndedAt)} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{currentPageData.map(({ session, filteredLogs }) => (
|
||||
<PamSessionRow
|
||||
key={session.id}
|
||||
session={session}
|
||||
search={search.trim().toLowerCase()}
|
||||
filteredCommandLogs={filteredLogs}
|
||||
/>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
{Boolean(filteredSessions.length) && (
|
||||
<Pagination
|
||||
count={filteredSessions.length}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={setPerPage}
|
||||
/>
|
||||
)}
|
||||
{isContentEmpty && (
|
||||
<EmptyState
|
||||
title={isSearchEmpty ? "No sessions match search" : "No sessions"}
|
||||
icon={isSearchEmpty ? faSearch : faCircleXmark}
|
||||
/>
|
||||
)}
|
||||
</TableContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { zodValidator } from "@tanstack/zod-adapter";
|
||||
import { z } from "zod";
|
||||
|
||||
import { PamSessionPage } from "./PamSessionsPage";
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_authenticate/_inject-org-details/_org-layout/projects/pam/$projectId/_pam-layout/sessions/"
|
||||
)({
|
||||
validateSearch: zodValidator(
|
||||
z.object({
|
||||
search: z.string().optional()
|
||||
})
|
||||
),
|
||||
beforeLoad: ({ context }) => {
|
||||
return {
|
||||
breadcrumbs: [
|
||||
...context.breadcrumbs,
|
||||
{
|
||||
label: "Sessions"
|
||||
}
|
||||
]
|
||||
};
|
||||
},
|
||||
component: PamSessionPage
|
||||
});
|
||||
Reference in New Issue
Block a user