mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-04 12:26:13 +00:00
Merge pull request #4794 from Infisical/ENG-3979
Improve PAM Session Logs
This commit is contained in:
@@ -9,22 +9,10 @@ export const HighlightText = ({
|
|||||||
}) => {
|
}) => {
|
||||||
if (!text) return null;
|
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(<br key={`${baseKeyPrefix}-br-${line}`} />);
|
|
||||||
}
|
|
||||||
return nodes;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const searchTerm = highlight.toLowerCase().trim();
|
const searchTerm = highlight.toLowerCase().trim();
|
||||||
|
|
||||||
if (!searchTerm) {
|
if (!searchTerm) {
|
||||||
return <span>{renderTextWithNewlines(text, "full-text")}</span>;
|
return <span>{text}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parts: React.ReactNode[] = [];
|
const parts: React.ReactNode[] = [];
|
||||||
@@ -36,16 +24,12 @@ export const HighlightText = ({
|
|||||||
text.replace(regex, (match: string, offset: number) => {
|
text.replace(regex, (match: string, offset: number) => {
|
||||||
if (offset > lastIndex) {
|
if (offset > lastIndex) {
|
||||||
const preMatchText = text.substring(lastIndex, offset);
|
const preMatchText = text.substring(lastIndex, offset);
|
||||||
parts.push(
|
parts.push(<span key={`pre-${lastIndex}`}>{preMatchText}</span>);
|
||||||
<span key={`pre-${lastIndex}`}>
|
|
||||||
{renderTextWithNewlines(preMatchText, `pre-${lastIndex}`)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
parts.push(
|
parts.push(
|
||||||
<span key={`match-${offset}`} className={highlightClassName || "bg-yellow/30"}>
|
<span key={`match-${offset}`} className={highlightClassName || "bg-yellow/30"}>
|
||||||
{renderTextWithNewlines(match, `match-${offset}`)}
|
{match}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -56,11 +40,7 @@ export const HighlightText = ({
|
|||||||
|
|
||||||
if (lastIndex < text.length) {
|
if (lastIndex < text.length) {
|
||||||
const postMatchText = text.substring(lastIndex);
|
const postMatchText = text.substring(lastIndex);
|
||||||
parts.push(
|
parts.push(<span key={`post-${lastIndex}`}>{postMatchText}</span>);
|
||||||
<span key={`post-${lastIndex}`}>
|
|
||||||
{renderTextWithNewlines(postMatchText, `post-${lastIndex}`)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return parts;
|
return parts;
|
||||||
|
|||||||
@@ -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<string, string | number | null>[];
|
||||||
|
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 (
|
||||||
|
<div className="font-sans">
|
||||||
|
{isRawView ? (
|
||||||
|
<div className="font-mono break-all whitespace-pre-wrap">
|
||||||
|
<HighlightText text={content} highlight={search} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{parsedContent.command && (
|
||||||
|
<div className="mb-2 font-mono">{`> ${parsedContent.command}`}</div>
|
||||||
|
)}
|
||||||
|
<div className="overflow-x-auto rounded-md border border-mineshaft-600">
|
||||||
|
<table className="w-full min-w-max text-left">
|
||||||
|
<thead className="bg-mineshaft-800">
|
||||||
|
<tr className="border-b border-mineshaft-600">
|
||||||
|
{headers.map((header) => (
|
||||||
|
<th key={header} className="p-2 font-semibold text-mineshaft-200 capitalize">
|
||||||
|
<HighlightText text={header.replace(/_/g, " ")} highlight={search} />
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{parsedContent.data_rows.map((row, rowIndex) => (
|
||||||
|
<tr
|
||||||
|
// eslint-disable-next-line react/no-array-index-key
|
||||||
|
key={`row-${rowIndex}`}
|
||||||
|
className="border-b border-mineshaft-700 bg-mineshaft-900 last:border-b-0 hover:bg-mineshaft-800/50"
|
||||||
|
>
|
||||||
|
{headers.map((header) => (
|
||||||
|
<td key={header} className="p-2">
|
||||||
|
<HighlightText text={String(row[header] ?? "")} highlight={search} />
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="mt-2 flex items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="cursor-pointer text-sm text-bunker-400 underline"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsRawView((v) => !v);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isRawView ? "View Formatted" : "View Raw"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{parsedContent.total_rows !== undefined && (
|
||||||
|
<div className="ml-auto text-right text-sm text-bunker-400">
|
||||||
|
Total rows: {parsedContent.total_rows}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="font-mono break-all whitespace-pre-wrap">
|
||||||
|
<HighlightText text={content} highlight={search} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,42 +1,76 @@
|
|||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { faChevronDown, faChevronRight } from "@fortawesome/free-solid-svg-icons";
|
import { faChevronRight, faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
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 { TPamSession } from "@app/hooks/api/pam";
|
||||||
|
|
||||||
|
import { PamSessionLogOutput } from "./PamSessionLogOutput";
|
||||||
|
import { formatLogContent } from "./PamSessionLogsSection.utils";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
session: TPamSession;
|
session: TPamSession;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PamSessionLogsSection = ({ session }: Props) => {
|
export const PamSessionLogsSection = ({ session }: Props) => {
|
||||||
const [expandedLogTimestamps, setExpandedLogTimestamps] = useState<Set<string>>(new Set());
|
const [expandedLogTimestamps, setExpandedLogTimestamps] = useState<Set<string>>(new Set());
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
const toggleExpand = (timestamp: string) => {
|
const toggleExpand = (timestamp: string) => {
|
||||||
setExpandedLogTimestamps((prev) => {
|
setExpandedLogTimestamps((prev) => {
|
||||||
const newSet = new Set(prev);
|
if (prev.has(timestamp)) {
|
||||||
if (newSet.has(timestamp)) {
|
return new Set();
|
||||||
newSet.delete(timestamp);
|
|
||||||
} else {
|
|
||||||
newSet.add(timestamp);
|
|
||||||
}
|
}
|
||||||
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 (
|
return (
|
||||||
<div className="flex h-full w-full flex-col gap-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
<div className="flex h-full w-full flex-col gap-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||||
<div className="flex items-center border-b border-mineshaft-400 pb-4">
|
<div className="flex items-center border-b border-mineshaft-400 pb-4">
|
||||||
<h3 className="text-lg font-medium text-mineshaft-100">Session Logs</h3>
|
<h3 className="text-lg font-medium text-mineshaft-100">Session Logs</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 overflow-y-auto text-xs">
|
|
||||||
{session.commandLogs.length > 0 ? (
|
<div className="flex gap-2">
|
||||||
session.commandLogs.map((log) => {
|
<Input
|
||||||
const isExpanded = expandedLogTimestamps.has(log.timestamp);
|
value={search}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newSearch = e.target.value;
|
||||||
|
setSearch(newSearch);
|
||||||
|
}}
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||||
|
placeholder="Search logs..."
|
||||||
|
className="flex-1 bg-mineshaft-800"
|
||||||
|
containerClassName="bg-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex grow flex-col gap-2 overflow-y-auto text-xs">
|
||||||
|
{filteredLogs.length > 0 ? (
|
||||||
|
filteredLogs.map((log) => {
|
||||||
|
const isExpanded = search.length || expandedLogTimestamps.has(log.timestamp);
|
||||||
|
const formattedInput = formatLogContent(log.input);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
key={log.timestamp}
|
key={log.timestamp}
|
||||||
className={`flex w-full flex-col rounded-md border border-mineshaft-700 p-3 text-left focus:ring-2 focus:ring-mineshaft-400 focus:outline-hidden ${
|
className={`flex w-full flex-col rounded-md border border-mineshaft-700 p-3 text-left focus:inset-ring-2 focus:inset-ring-mineshaft-400 focus:outline-hidden ${
|
||||||
isExpanded ? "bg-mineshaft-700" : "bg-mineshaft-800 hover:bg-mineshaft-700"
|
isExpanded ? "bg-mineshaft-700" : "bg-mineshaft-800 hover:bg-mineshaft-700"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => toggleExpand(log.timestamp)}
|
onClick={() => toggleExpand(log.timestamp)}
|
||||||
@@ -44,8 +78,11 @@ export const PamSessionLogsSection = ({ session }: Props) => {
|
|||||||
<div className="flex items-center justify-between text-bunker-400">
|
<div className="flex items-center justify-between text-bunker-400">
|
||||||
<div className="flex items-center gap-2 select-none">
|
<div className="flex items-center gap-2 select-none">
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon={isExpanded ? faChevronDown : faChevronRight}
|
icon={faChevronRight}
|
||||||
className="size-3 transition-transform duration-200"
|
className={twMerge(
|
||||||
|
"size-3 transition-transform duration-100 ease-in-out",
|
||||||
|
isExpanded && "rotate-90"
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<span>{new Date(log.timestamp).toLocaleString()}</span>
|
<span>{new Date(log.timestamp).toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -56,20 +93,44 @@ export const PamSessionLogsSection = ({ session }: Props) => {
|
|||||||
isExpanded ? "break-all whitespace-pre-wrap" : "truncate"
|
isExpanded ? "break-all whitespace-pre-wrap" : "truncate"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{log.input}
|
<HighlightText text={formattedInput} highlight={search} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isExpanded && log.output && (
|
<div
|
||||||
<div className="mt-2 border-t border-mineshaft-700 pt-2 font-mono break-all whitespace-pre-wrap text-bunker-300">
|
className={twMerge(
|
||||||
{log.output}
|
"grid transition-all duration-100 ease-in-out",
|
||||||
|
isExpanded && log.output ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
{log.output && (
|
||||||
|
<>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<div className="h-px w-full bg-mineshaft-400" />
|
||||||
|
<span className="text-xs text-mineshaft-400">OUTPUT</span>
|
||||||
|
<div className="h-px w-full bg-mineshaft-400" />
|
||||||
|
</div>
|
||||||
|
<div className="pt-2 text-bunker-300">
|
||||||
|
<PamSessionLogOutput
|
||||||
|
content={log.output}
|
||||||
|
resourceType={session.resourceType}
|
||||||
|
search={search}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<div className="flex w-full grow items-center justify-center text-bunker-300">
|
<div className="flex grow items-center justify-center text-bunker-300">
|
||||||
{session.startedAt && session.endedAt ? (
|
{search.length ? (
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mb-2">No logs match search criteria</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="mb-2">Session logs are not yet available</div>
|
<div className="mb-2">Session logs are not yet available</div>
|
||||||
<div className="text-xs text-bunker-400">
|
<div className="text-xs text-bunker-400">
|
||||||
@@ -78,8 +139,6 @@ export const PamSessionLogsSection = ({ session }: Props) => {
|
|||||||
If logs do not appear after some time, please contact your Gateway administrators.
|
If logs do not appear after some time, please contact your Gateway administrators.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
"No session logs"
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
};
|
||||||
@@ -27,6 +27,7 @@ import { HighlightText } from "@app/components/v2/HighlightText";
|
|||||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||||
import { PAM_RESOURCE_TYPE_MAP, TPamSession } from "@app/hooks/api/pam";
|
import { PAM_RESOURCE_TYPE_MAP, TPamSession } from "@app/hooks/api/pam";
|
||||||
|
|
||||||
|
import { formatLogContent } from "../../PamSessionsByIDPage/components/PamSessionLogsSection.utils";
|
||||||
import { PamSessionStatusBadge } from "./PamSessionStatusBadge";
|
import { PamSessionStatusBadge } from "./PamSessionStatusBadge";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -159,21 +160,28 @@ export const PamSessionRow = ({ session, search, filteredCommandLogs }: Props) =
|
|||||||
{filteredCommandLogs.length > 0 && (
|
{filteredCommandLogs.length > 0 && (
|
||||||
<Tr>
|
<Tr>
|
||||||
<Td colSpan={5} className="py-3 text-xs">
|
<Td colSpan={5} className="py-3 text-xs">
|
||||||
{logsToShow.map((log) => (
|
{logsToShow.map((log) => {
|
||||||
<div key={`${id}-log-${log.timestamp}`} className="mb-4 flex flex-col last:mb-0">
|
const formattedInput = formatLogContent(log.input);
|
||||||
<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">
|
return (
|
||||||
<HighlightText text={log.input.trim()} highlight={search} />
|
<div
|
||||||
|
key={`${id}-log-${log.timestamp}`}
|
||||||
|
className="mb-4 flex flex-col gap-1 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 break-all whitespace-pre-wrap">
|
||||||
|
<HighlightText text={formattedInput} highlight={search} />
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-bunker-300">
|
||||||
|
<HighlightText text={log.output.trim()} highlight={search} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="font-mono text-bunker-300">
|
);
|
||||||
<HighlightText text={log.output.trim()} highlight={search} />
|
})}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{filteredCommandLogs.length > LOGS_TO_SHOW && (
|
{filteredCommandLogs.length > LOGS_TO_SHOW && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
Reference in New Issue
Block a user