mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-07 02:27:39 +00:00
feat: session logging for ssh
This commit is contained in:
@@ -5,7 +5,11 @@ import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
|||||||
import { MySQLSessionCredentialsSchema } from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas";
|
import { MySQLSessionCredentialsSchema } from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas";
|
||||||
import { PostgresSessionCredentialsSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
|
import { PostgresSessionCredentialsSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
|
||||||
import { SSHSessionCredentialsSchema } from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas";
|
import { SSHSessionCredentialsSchema } from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas";
|
||||||
import { PamSessionCommandLogSchema, SanitizedSessionSchema } from "@app/ee/services/pam-session/pam-session-schemas";
|
import {
|
||||||
|
PamSessionCommandLogSchema,
|
||||||
|
SanitizedSessionSchema,
|
||||||
|
TerminalEventSchema
|
||||||
|
} from "@app/ee/services/pam-session/pam-session-schemas";
|
||||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||||
import { AuthMode } from "@app/services/auth/auth-type";
|
import { AuthMode } from "@app/services/auth/auth-type";
|
||||||
@@ -72,7 +76,7 @@ export const registerPamSessionRouter = async (server: FastifyZodProvider) => {
|
|||||||
sessionId: z.string().uuid()
|
sessionId: z.string().uuid()
|
||||||
}),
|
}),
|
||||||
body: z.object({
|
body: z.object({
|
||||||
logs: PamSessionCommandLogSchema.array()
|
logs: z.array(z.union([PamSessionCommandLogSchema, TerminalEventSchema]))
|
||||||
}),
|
}),
|
||||||
response: {
|
response: {
|
||||||
200: z.object({
|
200: z.object({
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { TPamSessions } from "@app/db/schemas";
|
|||||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||||
import { KmsDataKey } from "@app/services/kms/kms-types";
|
import { KmsDataKey } from "@app/services/kms/kms-types";
|
||||||
|
|
||||||
import { TPamSanitizedSession, TPamSessionCommandLog } from "./pam-session.types";
|
import { TPamSanitizedSession, TPamSessionCommandLog, TTerminalEvent } from "./pam-session.types";
|
||||||
|
|
||||||
export const decryptSessionCommandLogs = async ({
|
export const decryptSessionCommandLogs = async ({
|
||||||
projectId,
|
projectId,
|
||||||
@@ -22,7 +22,7 @@ export const decryptSessionCommandLogs = async ({
|
|||||||
cipherTextBlob: encryptedLogs
|
cipherTextBlob: encryptedLogs
|
||||||
});
|
});
|
||||||
|
|
||||||
return JSON.parse(decryptedPlainTextBlob.toString()) as TPamSessionCommandLog;
|
return JSON.parse(decryptedPlainTextBlob.toString()) as (TPamSessionCommandLog | TTerminalEvent)[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const decryptSession = async (
|
export const decryptSession = async (
|
||||||
@@ -32,7 +32,7 @@ export const decryptSession = async (
|
|||||||
) => {
|
) => {
|
||||||
return {
|
return {
|
||||||
...session,
|
...session,
|
||||||
commandLogs: session.encryptedLogsBlob
|
logs: session.encryptedLogsBlob
|
||||||
? await decryptSessionCommandLogs({
|
? await decryptSessionCommandLogs({
|
||||||
projectId,
|
projectId,
|
||||||
encryptedLogs: session.encryptedLogsBlob,
|
encryptedLogs: session.encryptedLogsBlob,
|
||||||
|
|||||||
@@ -8,8 +8,18 @@ export const PamSessionCommandLogSchema = z.object({
|
|||||||
timestamp: z.coerce.date()
|
timestamp: z.coerce.date()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// SSH Terminal Event schemas
|
||||||
|
export const TerminalEventTypeSchema = z.enum(["input", "output", "resize", "error"]);
|
||||||
|
|
||||||
|
export const TerminalEventSchema = z.object({
|
||||||
|
timestamp: z.coerce.date(),
|
||||||
|
eventType: TerminalEventTypeSchema,
|
||||||
|
data: z.string(), // Base64 encoded binary data
|
||||||
|
elapsedTime: z.number() // Seconds since session start (for replay)
|
||||||
|
});
|
||||||
|
|
||||||
export const SanitizedSessionSchema = PamSessionsSchema.omit({
|
export const SanitizedSessionSchema = PamSessionsSchema.omit({
|
||||||
encryptedLogsBlob: true
|
encryptedLogsBlob: true
|
||||||
}).extend({
|
}).extend({
|
||||||
commandLogs: PamSessionCommandLogSchema.array()
|
logs: z.array(z.union([PamSessionCommandLogSchema, TerminalEventSchema]))
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { PamSessionCommandLogSchema, SanitizedSessionSchema } from "./pam-session-schemas";
|
import { PamSessionCommandLogSchema, SanitizedSessionSchema, TerminalEventSchema } from "./pam-session-schemas";
|
||||||
|
|
||||||
export type TPamSessionCommandLog = z.infer<typeof PamSessionCommandLogSchema>;
|
export type TPamSessionCommandLog = z.infer<typeof PamSessionCommandLogSchema>;
|
||||||
|
export type TTerminalEvent = z.infer<typeof TerminalEventSchema>;
|
||||||
export type TPamSanitizedSession = z.infer<typeof SanitizedSessionSchema>;
|
export type TPamSanitizedSession = z.infer<typeof SanitizedSessionSchema>;
|
||||||
|
|
||||||
// DTOs
|
// DTOs
|
||||||
export type TUpdateSessionLogsDTO = {
|
export type TUpdateSessionLogsDTO = {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
logs: TPamSessionCommandLog[];
|
logs: (TPamSessionCommandLog | TTerminalEvent)[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,6 +21,22 @@ export type TPamFolder = {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Session log types
|
||||||
|
export type TPamCommandLog = {
|
||||||
|
input: string;
|
||||||
|
output: string;
|
||||||
|
timestamp: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TTerminalEvent = {
|
||||||
|
timestamp: string;
|
||||||
|
eventType: "input" | "output" | "resize" | "error";
|
||||||
|
data: string; // Base64 encoded binary data
|
||||||
|
elapsedTime: number; // Seconds since session start (for replay)
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TPamSessionLog = TPamCommandLog | TTerminalEvent;
|
||||||
|
|
||||||
export type TPamSession = {
|
export type TPamSession = {
|
||||||
id: string;
|
id: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -39,11 +55,7 @@ export type TPamSession = {
|
|||||||
endedAt?: string | null;
|
endedAt?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
commandLogs: {
|
logs: TPamSessionLog[];
|
||||||
input: string;
|
|
||||||
output: string;
|
|
||||||
timestamp: string;
|
|
||||||
}[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Resource DTOs
|
// Resource DTOs
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
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 { TPamCommandLog } from "@app/hooks/api/pam";
|
||||||
|
|
||||||
|
import { formatLogContent } from "./PamSessionLogsSection.utils";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
logs: TPamCommandLog[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CommandLogView = ({ logs }: Props) => {
|
||||||
|
const [expandedLogTimestamps, setExpandedLogTimestamps] = useState<Set<string>>(new Set());
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
const toggleExpand = (timestamp: string) => {
|
||||||
|
setExpandedLogTimestamps((prev) => {
|
||||||
|
if (prev.has(timestamp)) {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
return new Set([timestamp]);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredLogs = useMemo(
|
||||||
|
() =>
|
||||||
|
logs.filter((log) => {
|
||||||
|
const searchValue = search.trim().toLowerCase();
|
||||||
|
return (
|
||||||
|
log.input.toLowerCase().includes(searchValue) ||
|
||||||
|
log.output.toLowerCase().includes(searchValue)
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
[logs, search]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
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, index) => {
|
||||||
|
const isExpanded = search.length || expandedLogTimestamps.has(log.timestamp);
|
||||||
|
const formattedInput = formatLogContent(log.input);
|
||||||
|
const logKey = `${log.timestamp}-${index}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={logKey}
|
||||||
|
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"
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleExpand(log.timestamp)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between text-bunker-400">
|
||||||
|
<div className="flex items-center gap-2 select-none">
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faChevronRight}
|
||||||
|
className={twMerge(
|
||||||
|
"size-3 transition-transform duration-100 ease-in-out",
|
||||||
|
isExpanded && "rotate-90"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span>{new Date(log.timestamp).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`mt-2 font-mono ${
|
||||||
|
isExpanded ? "break-all whitespace-pre-wrap" : "truncate"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<HighlightText text={formattedInput} highlight={search} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={twMerge(
|
||||||
|
"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="pt-2 text-bunker-300">
|
||||||
|
<HighlightText text={log.output} highlight={search} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="flex grow items-center justify-center text-bunker-300">
|
||||||
|
{search.length ? (
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mb-2">No logs match search criteria</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mb-2">Session logs are not yet available</div>
|
||||||
|
<div className="text-xs text-bunker-400">
|
||||||
|
Logs will be uploaded after the session duration has elapsed.
|
||||||
|
<br />
|
||||||
|
If logs do not appear after some time, please contact your Gateway administrators.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,44 +1,19 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { PamResourceType, TPamCommandLog, TPamSession, TTerminalEvent } from "@app/hooks/api/pam";
|
||||||
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 { CommandLogView } from "./CommandLogView";
|
||||||
import { HighlightText } from "@app/components/v2/HighlightText";
|
import { TerminalEventView } from "./TerminalEventView";
|
||||||
import { TPamSession } from "@app/hooks/api/pam";
|
|
||||||
|
|
||||||
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());
|
// Determine log type based on resource type
|
||||||
const [search, setSearch] = useState("");
|
const isSSHSession = session.resourceType === PamResourceType.SSH;
|
||||||
|
const isDatabaseSession =
|
||||||
const toggleExpand = (timestamp: string) => {
|
session.resourceType === PamResourceType.Postgres ||
|
||||||
setExpandedLogTimestamps((prev) => {
|
session.resourceType === PamResourceType.MySQL;
|
||||||
if (prev.has(timestamp)) {
|
const hasLogs = session.logs.length > 0;
|
||||||
return new Set();
|
|
||||||
}
|
|
||||||
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">
|
||||||
@@ -46,91 +21,20 @@ export const PamSessionLogsSection = ({ session }: Props) => {
|
|||||||
<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 gap-2">
|
{isDatabaseSession && hasLogs && <CommandLogView logs={session.logs as TPamCommandLog[]} />}
|
||||||
<Input
|
{isSSHSession && hasLogs && <TerminalEventView events={session.logs as TTerminalEvent[]} />}
|
||||||
value={search}
|
{!hasLogs && (
|
||||||
onChange={(e) => {
|
<div className="flex grow items-center justify-center text-bunker-300">
|
||||||
const newSearch = e.target.value;
|
<div className="text-center">
|
||||||
setSearch(newSearch);
|
<div className="mb-2">Session logs are not yet available</div>
|
||||||
}}
|
<div className="text-xs text-bunker-400">
|
||||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
Logs will be uploaded after the session duration has elapsed.
|
||||||
placeholder="Search logs..."
|
<br />
|
||||||
className="flex-1 bg-mineshaft-800"
|
If logs do not appear after some time, please contact your Gateway administrators.
|
||||||
containerClassName="bg-transparent"
|
</div>
|
||||||
/>
|
|
||||||
</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 (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
key={log.timestamp}
|
|
||||||
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"
|
|
||||||
}`}
|
|
||||||
onClick={() => toggleExpand(log.timestamp)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between text-bunker-400">
|
|
||||||
<div className="flex items-center gap-2 select-none">
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={faChevronRight}
|
|
||||||
className={twMerge(
|
|
||||||
"size-3 transition-transform duration-100 ease-in-out",
|
|
||||||
isExpanded && "rotate-90"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span>{new Date(log.timestamp).toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={`mt-2 font-mono ${
|
|
||||||
isExpanded ? "break-all whitespace-pre-wrap" : "truncate"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<HighlightText text={formattedInput} highlight={search} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={twMerge(
|
|
||||||
"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="pt-2 text-bunker-300">
|
|
||||||
<HighlightText text={log.output} highlight={search} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<div className="flex grow items-center justify-center text-bunker-300">
|
|
||||||
{search.length ? (
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="mb-2">No logs match search criteria</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="mb-2">Session logs are not yet available</div>
|
|
||||||
<div className="text-xs text-bunker-400">
|
|
||||||
Logs will be uploaded after the session duration has elapsed.
|
|
||||||
<br />
|
|
||||||
If logs do not appear after some time, please contact your Gateway administrators.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
|
import { Input } from "@app/components/v2";
|
||||||
|
import { HighlightText } from "@app/components/v2/HighlightText";
|
||||||
|
import { TTerminalEvent } from "@app/hooks/api/pam";
|
||||||
|
|
||||||
|
import { aggregateTerminalEvents } from "./terminal-utils";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
events: TTerminalEvent[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TerminalEventView = ({ events }: Props) => {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
const aggregatedEvents = useMemo(() => aggregateTerminalEvents(events), [events]);
|
||||||
|
|
||||||
|
const filteredEvents = useMemo(
|
||||||
|
() =>
|
||||||
|
aggregatedEvents.filter((event) => {
|
||||||
|
const searchValue = search.trim().toLowerCase();
|
||||||
|
if (!searchValue) return true;
|
||||||
|
return event.data.toLowerCase().includes(searchValue);
|
||||||
|
}),
|
||||||
|
[aggregatedEvents, search]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||||
|
placeholder="Search terminal output..."
|
||||||
|
className="flex-1 bg-mineshaft-800"
|
||||||
|
containerClassName="bg-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex grow flex-col gap-2 overflow-y-auto text-xs">
|
||||||
|
{filteredEvents.length > 0 ? (
|
||||||
|
filteredEvents.map((event, index) => {
|
||||||
|
const eventKey = `${event.timestamp}-${index}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={eventKey}
|
||||||
|
className="flex w-full flex-col rounded-md border border-mineshaft-700 bg-mineshaft-800 p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between text-bunker-400">
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<span>{new Date(event.timestamp).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 font-mono whitespace-pre-wrap text-bunker-100">
|
||||||
|
<HighlightText text={event.data} highlight={search} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="flex grow items-center justify-center text-bunker-300">
|
||||||
|
{search.length ? (
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mb-2">No terminal output matches search criteria</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mb-2">Terminal session logs are not yet available</div>
|
||||||
|
<div className="text-xs text-bunker-400">
|
||||||
|
Logs will be uploaded after the session duration has elapsed.
|
||||||
|
<br />
|
||||||
|
If logs do not appear after some time, please contact your Gateway administrators.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { TTerminalEvent } from "@app/hooks/api/pam";
|
||||||
|
|
||||||
|
// Strip ANSI escape codes from terminal output
|
||||||
|
export const stripAnsiCodes = (text: string): string => {
|
||||||
|
// Remove ANSI escape sequences
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
return text.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "").replace(/\x1b\][0-9];[^\x07]*\x07/g, "");
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AggregatedTerminalEvent = {
|
||||||
|
timestamp: string;
|
||||||
|
eventType: string;
|
||||||
|
data: string;
|
||||||
|
elapsedTime: number;
|
||||||
|
eventCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Aggregate consecutive output events to avoid character-by-character display
|
||||||
|
export const aggregateTerminalEvents = (events: TTerminalEvent[]): AggregatedTerminalEvent[] => {
|
||||||
|
// Filter to only show output events (input is echoed, so redundant)
|
||||||
|
const outputEvents = events.filter((e) => e.eventType === "output");
|
||||||
|
|
||||||
|
if (outputEvents.length === 0) return [];
|
||||||
|
|
||||||
|
// First, combine all events into one string to process
|
||||||
|
const allText = outputEvents
|
||||||
|
.map((e) => {
|
||||||
|
try {
|
||||||
|
return stripAnsiCodes(atob(e.data));
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
// Split on lines that contain shell prompts
|
||||||
|
// Pattern matches: user@hostname:path# or user@hostname:path$
|
||||||
|
const promptPattern = /^[\w-]+@[\w-]+[^\s]*[:#$]\s+/;
|
||||||
|
|
||||||
|
const lines = allText.split("\n");
|
||||||
|
const segments: string[] = [];
|
||||||
|
let currentSegment: string[] = [];
|
||||||
|
|
||||||
|
lines.forEach((line) => {
|
||||||
|
const hasPrompt = promptPattern.test(line);
|
||||||
|
|
||||||
|
if (hasPrompt && currentSegment.length > 0) {
|
||||||
|
// Found a new prompt, save current segment and start new one
|
||||||
|
segments.push(currentSegment.join("\n"));
|
||||||
|
currentSegment = [line];
|
||||||
|
} else {
|
||||||
|
// Add line to current segment
|
||||||
|
currentSegment.push(line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add the last segment
|
||||||
|
if (currentSegment.length > 0) {
|
||||||
|
segments.push(currentSegment.join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out empty segments and convert to aggregated events
|
||||||
|
const validSegments = segments.filter((seg) => seg.trim().length > 0);
|
||||||
|
|
||||||
|
return validSegments.map((segment) => ({
|
||||||
|
timestamp: outputEvents[0].timestamp,
|
||||||
|
eventType: "output",
|
||||||
|
data: segment,
|
||||||
|
elapsedTime: outputEvents[0].elapsedTime,
|
||||||
|
eventCount: Math.ceil(outputEvents.length / validSegments.length)
|
||||||
|
}));
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
faBoxOpen,
|
faBoxOpen,
|
||||||
faChevronDown,
|
faChevronDown,
|
||||||
@@ -25,18 +25,19 @@ import {
|
|||||||
} from "@app/components/v2";
|
} from "@app/components/v2";
|
||||||
import { HighlightText } from "@app/components/v2/HighlightText";
|
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, TTerminalEvent, TPamSession } from "@app/hooks/api/pam";
|
||||||
|
|
||||||
import { formatLogContent } from "../../PamSessionsByIDPage/components/PamSessionLogsSection.utils";
|
import { formatLogContent } from "../../PamSessionsByIDPage/components/PamSessionLogsSection.utils";
|
||||||
|
import { aggregateTerminalEvents } from "../../PamSessionsByIDPage/components/terminal-utils";
|
||||||
import { PamSessionStatusBadge } from "./PamSessionStatusBadge";
|
import { PamSessionStatusBadge } from "./PamSessionStatusBadge";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
session: TPamSession;
|
session: TPamSession;
|
||||||
search: string;
|
search: string;
|
||||||
filteredCommandLogs: TPamSession["commandLogs"];
|
filteredLogs: TPamSession["logs"];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PamSessionRow = ({ session, search, filteredCommandLogs }: Props) => {
|
export const PamSessionRow = ({ session, search, filteredLogs }: Props) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [showAllLogs, setShowAllLogs] = useState(false);
|
const [showAllLogs, setShowAllLogs] = useState(false);
|
||||||
|
|
||||||
@@ -55,8 +56,24 @@ export const PamSessionRow = ({ session, search, filteredCommandLogs }: Props) =
|
|||||||
|
|
||||||
const { image, name: resourceTypeName } = PAM_RESOURCE_TYPE_MAP[resourceType];
|
const { image, name: resourceTypeName } = PAM_RESOURCE_TYPE_MAP[resourceType];
|
||||||
|
|
||||||
|
// Check if logs are terminal events and aggregate them
|
||||||
|
const processedLogs = useMemo(() => {
|
||||||
|
if (filteredLogs.length === 0) return [];
|
||||||
|
|
||||||
|
// Check if first log is a terminal event
|
||||||
|
const isTerminalEvents = "data" in filteredLogs[0];
|
||||||
|
|
||||||
|
if (isTerminalEvents) {
|
||||||
|
// Aggregate terminal events for better display
|
||||||
|
return aggregateTerminalEvents(filteredLogs as TTerminalEvent[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return command logs as-is
|
||||||
|
return filteredLogs;
|
||||||
|
}, [filteredLogs]);
|
||||||
|
|
||||||
const LOGS_TO_SHOW = 5;
|
const LOGS_TO_SHOW = 5;
|
||||||
const logsToShow = showAllLogs ? filteredCommandLogs : filteredCommandLogs.slice(0, LOGS_TO_SHOW);
|
const logsToShow = showAllLogs ? processedLogs : processedLogs.slice(0, LOGS_TO_SHOW);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -135,8 +152,8 @@ export const PamSessionRow = ({ session, search, filteredCommandLogs }: Props) =
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent sideOffset={2} align="end">
|
<DropdownMenuContent sideOffset={2} align="end">
|
||||||
<ProjectPermissionCan
|
<ProjectPermissionCan
|
||||||
I={ProjectPermissionActions.Edit}
|
I={ProjectPermissionActions.Read}
|
||||||
a={ProjectPermissionSub.PamResources}
|
a={ProjectPermissionSub.PamSessions}
|
||||||
>
|
>
|
||||||
{(isAllowed: boolean) => (
|
{(isAllowed: boolean) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@@ -157,32 +174,56 @@ export const PamSessionRow = ({ session, search, filteredCommandLogs }: Props) =
|
|||||||
</Td>
|
</Td>
|
||||||
</Tr>
|
</Tr>
|
||||||
|
|
||||||
{filteredCommandLogs.length > 0 && (
|
{filteredLogs.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, idx) => {
|
||||||
const formattedInput = formatLogContent(log.input);
|
// Handle command logs (database sessions)
|
||||||
|
if ("input" in log && "output" in log) {
|
||||||
|
const formattedInput = formatLogContent(log.input);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${id}-log-${log.timestamp}`}
|
key={`${id}-log-${log.timestamp}-${idx}`}
|
||||||
className="mb-4 flex flex-col gap-1 last:mb-0"
|
className="mb-4 flex flex-col gap-1 last:mb-0"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5 text-bunker-400">
|
<div className="flex items-center gap-1.5 text-bunker-400">
|
||||||
<FontAwesomeIcon icon={faTerminal} className="size-3" />
|
<FontAwesomeIcon icon={faTerminal} className="size-3" />
|
||||||
<span>{new Date(log.timestamp).toLocaleString()}</span>
|
<span>{new Date(log.timestamp).toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="font-mono break-all whitespace-pre-wrap">
|
<div className="font-mono break-all whitespace-pre-wrap">
|
||||||
<HighlightText text={formattedInput} highlight={search} />
|
<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} />
|
}
|
||||||
|
|
||||||
|
// Handle aggregated terminal events (SSH sessions)
|
||||||
|
if ("data" in log && typeof log.data === "string") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${id}-log-${log.timestamp}-${idx}`}
|
||||||
|
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 text-bunker-300">
|
||||||
|
<HighlightText text={log.data.trim()} highlight={search} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
})}
|
})}
|
||||||
{filteredCommandLogs.length > LOGS_TO_SHOW && (
|
{filteredLogs.length > LOGS_TO_SHOW && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
@@ -193,7 +234,7 @@ export const PamSessionRow = ({ session, search, filteredCommandLogs }: Props) =
|
|||||||
>
|
>
|
||||||
{showAllLogs
|
{showAllLogs
|
||||||
? "Show less"
|
? "Show less"
|
||||||
: `Show ${filteredCommandLogs.length - LOGS_TO_SHOW} more log${filteredCommandLogs.length - LOGS_TO_SHOW === 1 ? "" : "s"}`}
|
: `Show ${filteredLogs.length - LOGS_TO_SHOW} more log${filteredLogs.length - LOGS_TO_SHOW === 1 ? "" : "s"}`}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export const PamSessionsTable = ({ sessions }: Props) => {
|
|||||||
id,
|
id,
|
||||||
resourceName,
|
resourceName,
|
||||||
userId,
|
userId,
|
||||||
commandLogs
|
logs
|
||||||
} = session;
|
} = session;
|
||||||
|
|
||||||
const { name: resourceTypeName } = PAM_RESOURCE_TYPE_MAP[resourceType];
|
const { name: resourceTypeName } = PAM_RESOURCE_TYPE_MAP[resourceType];
|
||||||
@@ -131,11 +131,25 @@ export const PamSessionsTable = ({ sessions }: Props) => {
|
|||||||
|
|
||||||
const filteredLogs =
|
const filteredLogs =
|
||||||
searchValue.length >= 2
|
searchValue.length >= 2
|
||||||
? commandLogs.filter(
|
? logs.filter((log) => {
|
||||||
(log) =>
|
// Handle command logs (database sessions)
|
||||||
log.input.toLowerCase().includes(searchValue) ||
|
if ("input" in log && "output" in log) {
|
||||||
log.output.toLowerCase().includes(searchValue)
|
return (
|
||||||
)
|
log.input.toLowerCase().includes(searchValue) ||
|
||||||
|
log.output.toLowerCase().includes(searchValue)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Handle terminal events (SSH sessions)
|
||||||
|
if ("data" in log) {
|
||||||
|
try {
|
||||||
|
const decodedData = atob(log.data);
|
||||||
|
return decodedData.toLowerCase().includes(searchValue);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
})
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -385,7 +399,7 @@ export const PamSessionsTable = ({ sessions }: Props) => {
|
|||||||
key={session.id}
|
key={session.id}
|
||||||
session={session}
|
session={session}
|
||||||
search={search.trim().toLowerCase()}
|
search={search.trim().toLowerCase()}
|
||||||
filteredCommandLogs={filteredLogs}
|
filteredLogs={filteredLogs}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</TBody>
|
</TBody>
|
||||||
|
|||||||
Reference in New Issue
Block a user