diff --git a/frontend/src/components/v2/HighlightText/HighlightText.tsx b/frontend/src/components/v2/HighlightText/HighlightText.tsx index c81dab2df..92fdc1d6d 100644 --- a/frontend/src/components/v2/HighlightText/HighlightText.tsx +++ b/frontend/src/components/v2/HighlightText/HighlightText.tsx @@ -9,22 +9,10 @@ export const HighlightText = ({ }) => { if (!text) return null; - const renderTextWithNewlines = (input: string, baseKeyPrefix: string = ""): React.ReactNode[] => { - if (!input) return []; - const lines = input.split("\n"); - return lines.flatMap((line, index) => { - const nodes: React.ReactNode[] = [line]; - if (index < lines.length - 1) { - nodes.push(
); - } - return nodes; - }); - }; - const searchTerm = highlight.toLowerCase().trim(); if (!searchTerm) { - return {renderTextWithNewlines(text, "full-text")}; + return {text}; } const parts: React.ReactNode[] = []; @@ -36,16 +24,12 @@ export const HighlightText = ({ text.replace(regex, (match: string, offset: number) => { if (offset > lastIndex) { const preMatchText = text.substring(lastIndex, offset); - parts.push( - - {renderTextWithNewlines(preMatchText, `pre-${lastIndex}`)} - - ); + parts.push({preMatchText}); } parts.push( - {renderTextWithNewlines(match, `match-${offset}`)} + {match} ); @@ -56,11 +40,7 @@ export const HighlightText = ({ if (lastIndex < text.length) { const postMatchText = text.substring(lastIndex); - parts.push( - - {renderTextWithNewlines(postMatchText, `post-${lastIndex}`)} - - ); + parts.push({postMatchText}); } return parts; diff --git a/frontend/src/pages/pam/PamSessionsByIDPage/components/PamSessionLogOutput.tsx b/frontend/src/pages/pam/PamSessionsByIDPage/components/PamSessionLogOutput.tsx new file mode 100644 index 000000000..99e30627d --- /dev/null +++ b/frontend/src/pages/pam/PamSessionsByIDPage/components/PamSessionLogOutput.tsx @@ -0,0 +1,116 @@ +import { useState } from "react"; + +import { HighlightText } from "@app/components/v2/HighlightText"; +import { PamResourceType } from "@app/hooks/api/pam"; + +type TableLog = { + command?: string; + data_rows: Record[]; + total_rows?: number; +}; + +export const PamSessionLogOutput = ({ + content, + resourceType, + search +}: { + content: string; + resourceType: PamResourceType; + search: string; +}) => { + const [isRawView, setIsRawView] = useState(false); + + let parsedContent: TableLog | null = null; + + if (resourceType === PamResourceType.Postgres || resourceType === PamResourceType.MySQL) { + try { + const parsed = JSON.parse(content); + + if ( + parsed && + typeof parsed === "object" && + !Array.isArray(parsed) && + parsed.data_rows && + Array.isArray(parsed.data_rows) && + parsed.data_rows.length > 0 && + typeof parsed.data_rows[0] === "object" && + parsed.data_rows[0] !== null + ) { + parsedContent = parsed; + } + } catch { + // Not a valid JSON or doesn't match structure, will render as plain text + } + } + + if (parsedContent) { + const headers = Object.keys(parsedContent.data_rows[0]); + return ( +
+ {isRawView ? ( +
+ +
+ ) : ( + <> + {parsedContent.command && ( +
{`> ${parsedContent.command}`}
+ )} +
+ + + + {headers.map((header) => ( + + ))} + + + + {parsedContent.data_rows.map((row, rowIndex) => ( + + {headers.map((header) => ( + + ))} + + ))} + +
+ +
+ +
+
+ + )} +
+ + + {parsedContent.total_rows !== undefined && ( +
+ Total rows: {parsedContent.total_rows} +
+ )} +
+
+ ); + } + + return ( +
+ +
+ ); +}; diff --git a/frontend/src/pages/pam/PamSessionsByIDPage/components/PamSessionLogsSection.tsx b/frontend/src/pages/pam/PamSessionsByIDPage/components/PamSessionLogsSection.tsx index 5f06a64e2..0fbccbc9b 100644 --- a/frontend/src/pages/pam/PamSessionsByIDPage/components/PamSessionLogsSection.tsx +++ b/frontend/src/pages/pam/PamSessionsByIDPage/components/PamSessionLogsSection.tsx @@ -1,42 +1,76 @@ -import { useState } from "react"; -import { faChevronDown, faChevronRight } from "@fortawesome/free-solid-svg-icons"; +import { useMemo, useState } from "react"; +import { faChevronRight, faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; +import { Input } from "@app/components/v2"; +import { HighlightText } from "@app/components/v2/HighlightText"; import { TPamSession } from "@app/hooks/api/pam"; +import { PamSessionLogOutput } from "./PamSessionLogOutput"; +import { formatLogContent } from "./PamSessionLogsSection.utils"; + type Props = { session: TPamSession; }; export const PamSessionLogsSection = ({ session }: Props) => { const [expandedLogTimestamps, setExpandedLogTimestamps] = useState>(new Set()); + const [search, setSearch] = useState(""); const toggleExpand = (timestamp: string) => { setExpandedLogTimestamps((prev) => { - const newSet = new Set(prev); - if (newSet.has(timestamp)) { - newSet.delete(timestamp); - } else { - newSet.add(timestamp); + if (prev.has(timestamp)) { + return new Set(); } - return newSet; + return new Set([timestamp]); }); }; + const filteredLogs = useMemo( + () => + session.commandLogs.filter((log) => { + const { input, output } = log; + + const searchValue = search.trim().toLowerCase(); + + return ( + input.toLowerCase().includes(searchValue) || output.toLowerCase().includes(searchValue) + ); + }), + [session.commandLogs, search] + ); + return (

Session Logs

-
- {session.commandLogs.length > 0 ? ( - session.commandLogs.map((log) => { - const isExpanded = expandedLogTimestamps.has(log.timestamp); + +
+ { + const newSearch = e.target.value; + setSearch(newSearch); + }} + leftIcon={} + placeholder="Search logs..." + className="flex-1 bg-mineshaft-800" + containerClassName="bg-transparent" + /> +
+
+ {filteredLogs.length > 0 ? ( + filteredLogs.map((log) => { + const isExpanded = search.length || expandedLogTimestamps.has(log.timestamp); + const formattedInput = formatLogContent(log.input); + return ( ); }) ) : ( -
- {session.startedAt && session.endedAt ? ( +
+ {search.length ? ( +
+
No logs match search criteria
+
+ ) : (
Session logs are not yet available
@@ -78,8 +139,6 @@ export const PamSessionLogsSection = ({ session }: Props) => { If logs do not appear after some time, please contact your Gateway administrators.
- ) : ( - "No session logs" )}
)} diff --git a/frontend/src/pages/pam/PamSessionsByIDPage/components/PamSessionLogsSection.utils.ts b/frontend/src/pages/pam/PamSessionsByIDPage/components/PamSessionLogsSection.utils.ts new file mode 100644 index 000000000..d76145655 --- /dev/null +++ b/frontend/src/pages/pam/PamSessionsByIDPage/components/PamSessionLogsSection.utils.ts @@ -0,0 +1,46 @@ +// This function trims top and bottom empty padding, as well as moves all relative text to the left while still respecting indentation +export const formatLogContent = (text: string | null | undefined): string => { + if (!text) return ""; + + let lines = text.split("\n"); + + // Find the first and last non-empty lines to trim vertical padding + let firstLineIndex = -1; + for (let i = 0; i < lines.length; i += 1) { + if (lines[i].trim() !== "") { + firstLineIndex = i; + break; + } + } + + if (firstLineIndex === -1) { + return ""; + } + + let lastLineIndex = -1; + for (let i = lines.length - 1; i >= 0; i -= 1) { + if (lines[i].trim() !== "") { + lastLineIndex = i; + break; + } + } + + lines = lines.slice(firstLineIndex, lastLineIndex + 1); + + // Determine the minimum indentation of non-empty lines + const indentations = lines + .filter((line) => line.trim() !== "") + .map((line) => { + const match = line.match(/^\s*/); + return match ? match[0].length : 0; + }); + + const minIndentation = indentations.length > 0 ? Math.min(...indentations) : 0; + + // Remove the common indentation from all lines + if (minIndentation > 0) { + lines = lines.map((line) => line.substring(minIndentation)); + } + + return lines.join("\n"); +}; diff --git a/frontend/src/pages/pam/PamSessionsPage/components/PamSessionRow.tsx b/frontend/src/pages/pam/PamSessionsPage/components/PamSessionRow.tsx index f730e331d..8721560c4 100644 --- a/frontend/src/pages/pam/PamSessionsPage/components/PamSessionRow.tsx +++ b/frontend/src/pages/pam/PamSessionsPage/components/PamSessionRow.tsx @@ -27,6 +27,7 @@ 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 { formatLogContent } from "../../PamSessionsByIDPage/components/PamSessionLogsSection.utils"; import { PamSessionStatusBadge } from "./PamSessionStatusBadge"; type Props = { @@ -159,21 +160,28 @@ export const PamSessionRow = ({ session, search, filteredCommandLogs }: Props) = {filteredCommandLogs.length > 0 && ( - {logsToShow.map((log) => ( -
-
- - {new Date(log.timestamp).toLocaleString()} -
+ {logsToShow.map((log) => { + const formattedInput = formatLogContent(log.input); -
- + return ( +
+
+ + {new Date(log.timestamp).toLocaleString()} +
+ +
+ +
+
+ +
-
- -
-
- ))} + ); + })} {filteredCommandLogs.length > LOGS_TO_SHOW && (