better markdown/terminal view for task output

This commit is contained in:
its-a-feature
2026-05-27 15:35:22 -07:00
parent 0348656564
commit 829aee698a
8 changed files with 645 additions and 1840 deletions
@@ -33,8 +33,8 @@ import EditIcon from '@mui/icons-material/Edit';
import SettingsInputComponentRoundedIcon from '@mui/icons-material/SettingsInputComponentRounded';
import KeyboardArrowDownRoundedIcon from '@mui/icons-material/KeyboardArrowDownRounded';
const fileDataFragment = gql`
fragment fileObjData on mythictree {
const fileBrowserDataFragment = gql`
fragment fileBrowserObjData on mythictree {
comment
deleted
task_id
@@ -65,45 +65,45 @@ const fileDataFragment = gql`
}
`;
const rootFileQuery = gql`
${fileDataFragment}
${fileBrowserDataFragment}
query myRootCustomFolderQuery($tree_type: String!) {
mythictree(where: { parent_path_text: { _eq: "" }, tree_type: {_eq: $tree_type} }) {
...fileObjData
...fileBrowserObjData
}
}
`;
const folderQuery = gql`
${fileDataFragment}
${fileBrowserDataFragment}
query myFolderQuery($parent_path_text: String!, $parents: [String], $tree_type: String!) {
children: mythictree(
where: { parent_path_text: { _eq: $parent_path_text }, tree_type: {_eq: $tree_type} }
order_by: { can_have_children: asc, name: asc }
) {
...fileObjData
...fileBrowserObjData
}
parents: mythictree(
where: { full_path_text: { _in: $parents }, tree_type: {_eq: $tree_type} }
order_by: { can_have_children: asc, name: asc }
) {
...fileObjData
...fileBrowserObjData
}
self: mythictree(
where: { full_path_text: { _eq: $parent_path_text }, tree_type: {_eq: $tree_type} }
order_by: { can_have_children: asc, name: asc }
) {
...fileObjData
...fileBrowserObjData
}
}
`;
const fileDataSubscription = gql`
${fileDataFragment}
${fileBrowserDataFragment}
subscription liveData($now: timestamp!, $tree_type: String!) {
mythictree_stream(
batch_size: 1000,
cursor: {initial_value: {timestamp: $now}},
where: {tree_type: {_eq: $tree_type} }
) {
...fileObjData
...fileBrowserObjData
}
}
`;
@@ -6,12 +6,9 @@ import {b64DecodeUnicode} from "./ResponseDisplay";
import {SearchBar} from './ResponseDisplay';
import Pagination from '@mui/material/Pagination';
import {Typography, CircularProgress, Backdrop, Menu} from '@mui/material';
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
import MenuItem from '@mui/material/MenuItem';
import Anser from "anser";
import PaletteIcon from '@mui/icons-material/Palette';
import {MythicStyledTooltip} from "../../MythicComponents/MythicStyledTooltip";
import './ResponseDisplayInteractiveANSI.css';
import {Terminal} from '@xterm/xterm';
import {FitAddon} from '@xterm/addon-fit';
import '@xterm/xterm/css/xterm.css';
@@ -77,145 +74,24 @@ const sortInteractiveEntries = (entries) => {
return (a.id || 0) < (b.id || 0) ? -1 : 1;
});
}
const getTaskingStatus = (task) => {
if(task.status === "completed" || task.status === "success"){
return <CheckCircleOutlineIcon color={"success"} fontSize={"1rem"} style={{marginRight: "2px"}} />
} else if(task.status === "submitted"){
return <CircularProgress size={"1rem"} />
} else {
console.log(task.status);
return null
}
}
const getClassnames = (entry) => {
let classnames = [];
if(entry.decoration && entry.decoration === "reverse"){
if(entry.fg){
classnames.push(entry.fg + "-background");
}
if(entry.bg){
classnames.push(entry.bg);
}
} else {
if(entry.fg){
classnames.push(entry.fg);
}
if(entry.bg){
classnames.push(entry.bg + "-background");
}
}
if(entry.decoration){
classnames.push("ansi-" + entry.decoration);
}
for(let i = 0; i < entry.decorations.length; i++){
classnames.push("ansi-" + entry.decorations[i])
}
if(entry.bg_truecolor || entry.fg_truecolor){
console.log(entry);
}
//console.log(entry);
return classnames.join(" ");
}
const handleTerminalCodes = (response) => {
let output = response.replaceAll("[?2004h", "");
output = output.replaceAll("[?2004l", "");
let indexOfTitleSeq = output.indexOf("]0");
if(indexOfTitleSeq >= 0){
let endIndexOfTitleSeq = output.indexOf("]");
if(endIndexOfTitleSeq >= 0 && endIndexOfTitleSeq > indexOfTitleSeq){
output = output.substring(0, indexOfTitleSeq + 2) + output.substring(endIndexOfTitleSeq);
}
}
let indexOfClearLeft = output.indexOf("[J");
if(indexOfClearLeft >= 0){
let indexOfLastNewLine = 0;
for(let i = indexOfClearLeft; i >= 0; i--){
if(output[i] === "\n"){
indexOfLastNewLine = i;
break;
}
}
output = output.substring(0, indexOfLastNewLine+1) + output.substring(indexOfClearLeft + 2);
}
return output;
}
export const GetOutputFormatAll = ({data, useASNIColor, messagesEndRef, showTaskStatus, wrapText, autoScroll=false}) => {
const theme = useTheme();
const errorOutputBackground = theme.palette.error.main + (theme.palette.mode === "dark" ? "33" : "22");
const outputTextColor = theme.outputTextColor;
const [dataElement, setDataElement] = React.useState(null);
React.useEffect( () => {
const elements = data.map( d => {
if(d.response !== undefined) {
// we're looking at response output
if(d.is_error){
return (<pre id={"response" + d.timestamp + d.id} style={{display: "inline", backgroundColor: errorOutputBackground, color: outputTextColor, margin: "0 0 0 0",
wordBreak: wrapText ? "break-all" : "",
whiteSpace: wrapText ? "pre-wrap" : ""}} key={d.timestamp + d.id}>
{d.response}
</pre>)
} else {
if(useASNIColor){
let removedTerminalCodes = handleTerminalCodes(d.response);
let ansiJSON = Anser.ansiToJson(removedTerminalCodes, { use_classes: true });
//console.log(ansiJSON)
return (
ansiJSON.map( (a, i) => (
<pre id={"response" + d.timestamp + d.id} style={{display: "inline", margin: "0 0 0 0",
wordBreak: wrapText ? "break-all" : "",
whiteSpace: wrapText ? "pre-wrap" : "",
}} className={getClassnames(a)} key={d.id + d.timestamp + i}>{a.content}</pre>
))
)
} else {
return (<pre id={"response" + d.timestamp + d.id} style={{display: "inline", margin: "0 0 0 0",
wordBreak: wrapText ? "break-all" : "",
whiteSpace: wrapText ? "pre-wrap" : "",
}} key={d.timestamp + d.id}>{d.response}</pre>)
}
}
} else {
// we're looking at tasking
return(
<pre id={"task" + d.timestamp + d.id} key={d.timestamp + d.id} style={{display: "inline",margin: "0 0 0 0",
wordBreak: wrapText ? "break-all" : "", whiteSpace: "pre-wrap"}}>
{showTaskStatus && getTaskingStatus(d)}
{d.original_params}
</pre>
)
}
})
setDataElement(elements);
}, [data, useASNIColor, showTaskStatus, wrapText, errorOutputBackground, outputTextColor]);
React.useLayoutEffect( () => {
if(autoScroll){
messagesEndRef?.current?.scrollIntoView({ behavior: "auto", block: "nearest" });
}
}, [autoScroll, dataElement, messagesEndRef]);
return (
dataElement
)
}
const INTERACTIVE_TERMINAL_SCROLLBACK = 5000;
const INTERACTIVE_TERMINAL_MAX_UNWRAPPED_COLS = 300;
const ANSI_STYLE_CODE_REGEX = /\x1b\[[0-?]*[ -/]*m/g;
const TERMINAL_CONTROL_CODE_REGEX = /\x1b\][^\x07]*(?:\x07|\x1b\\)|\x1b\[[0-?]*[ -/]*[@-~]|\x1b[ -/]*[@-~]/g;
const TERMINAL_QUERY_CODE_REGEX = /\x1bc|\x1b\[(?:\?|>)?[0-9;]*[cn]|\x1b\](?:4|10|11|12);[^\x07]*(?:\x07|\x1b\\)/g;
const stripAnsiStyleCodes = (value) => {
export const INTERACTIVE_TERMINAL_SCROLLBACK = 5000;
export const INTERACTIVE_TERMINAL_MAX_UNWRAPPED_COLS = 300;
// eslint-disable-next-line no-control-regex
export const ANSI_STYLE_CODE_REGEX = /\x1b\[[0-?]*[ -/]*m/g;
// eslint-disable-next-line no-control-regex
export const TERMINAL_CONTROL_CODE_REGEX = /\x1b\][^\x07]*(?:\x07|\x1b\\)|\x1b\[[0-?]*[ -/]*[@-~]|\x1b[ -/]*[@-~]/g;
// eslint-disable-next-line no-control-regex
export const TERMINAL_QUERY_CODE_REGEX = /\x1bc|\x1b\[(?:\?|>)?[0-9;]*[cn]|\x1b\](?:4|10|11|12);[^\x07]*(?:\x07|\x1b\\)/g;
export const stripAnsiStyleCodes = (value) => {
return value.replace(ANSI_STYLE_CODE_REGEX, "");
}
const getTerminalText = (value) => {
export const getTerminalText = (value) => {
return value === undefined || value === null ? "" : String(value);
}
const sanitizeTerminalOutput = (value) => {
export const sanitizeTerminalOutput = (value) => {
return getTerminalText(value).replace(TERMINAL_QUERY_CODE_REGEX, "");
}
const getInteractiveTerminalTheme = (theme) => {
export const getInteractiveTerminalTheme = (theme) => {
return {
background: theme.outputBackgroundColor,
foreground: theme.outputTextColor,
@@ -229,20 +105,23 @@ const getInteractiveTerminalEntrySignature = (entry) => {
}
return `task:${entry.id}:${entry.status}:${entry.original_params}:${entry.display_params}`;
}
const getInteractiveTerminalMeasurementText = (value) => {
export const getInteractiveTerminalMeasurementText = (value) => {
return sanitizeTerminalOutput(value).replace(TERMINAL_CONTROL_CODE_REGEX, "");
}
const getInteractiveTerminalEntryMeasurementText = (entry) => {
export const getLongestTerminalTextLineLength = (value) => {
return getInteractiveTerminalMeasurementText(value).split(/\r\n|\n|\r/).reduce( (longestLineLength, line) => {
return Math.max(longestLineLength, Array.from(line.replaceAll("\t", " ")).length);
}, 0);
}
const getInteractiveTerminalEntryText = (entry) => {
if(entry.response !== undefined){
return getInteractiveTerminalMeasurementText(entry.response);
return entry.response;
}
return getInteractiveTerminalMeasurementText(entry.original_params || entry.display_params);
return entry.original_params || entry.display_params;
}
const getLongestInteractiveTerminalLineLength = (entries) => {
return entries.reduce( (longestLineLength, entry) => {
return getInteractiveTerminalEntryMeasurementText(entry).split(/\r\n|\n|\r/).reduce( (longestEntryLineLength, line) => {
return Math.max(longestEntryLineLength, Array.from(line.replaceAll("\t", " ")).length);
}, longestLineLength);
return Math.max(longestLineLength, getLongestTerminalTextLineLength(getInteractiveTerminalEntryText(entry)));
}, 0);
}
const getInteractiveTerminalTaskStatus = ({task, useASNIColor}) => {
@@ -257,7 +136,7 @@ const getInteractiveTerminalTaskStatus = ({task, useASNIColor}) => {
}
return task.status ? `[${task.status}] ` : "";
}
const formatInteractiveTerminalEntry = ({entry, useASNIColor, showTaskStatus}) => {
export const formatInteractiveTerminalEntry = ({entry, useASNIColor, showTaskStatus}) => {
if(entry.response !== undefined){
const terminalResponse = sanitizeTerminalOutput(entry.response);
const response = useASNIColor ? terminalResponse : stripAnsiStyleCodes(terminalResponse);
@@ -270,6 +149,13 @@ const formatInteractiveTerminalEntry = ({entry, useASNIColor, showTaskStatus}) =
const prompt = useASNIColor ? "\x1b[36m> \x1b[0m" : "> ";
return `\r\n${status}${prompt}${getTerminalText(entry.original_params || entry.display_params)}\r\n`;
}
export const formatInteractiveTerminalEntries = ({entries, useASNIColor=true, showTaskStatus=false}) => {
return entries.map((entry) => formatInteractiveTerminalEntry({
entry,
useASNIColor,
showTaskStatus,
})).join("");
}
const InteractiveTerminalDisplay = ({
data,
useASNIColor,
@@ -400,7 +286,8 @@ const InteractiveTerminalDisplay = ({
scheduleFitTerminal();
}, [longestLineLength, scheduleFitTerminal, wrapText]);
React.useEffect( () => {
if(!terminalElementRef.current){
const terminalElement = terminalElementRef.current;
if(!terminalElement){
return;
}
const terminal = new Terminal({
@@ -427,7 +314,7 @@ const InteractiveTerminalDisplay = ({
event.preventDefault();
writeLocalTerminalAction(onTerminalInputRef.current?.(pastedText));
};
terminalElementRef.current.addEventListener("paste", handleTerminalPaste);
terminalElement.addEventListener("paste", handleTerminalPaste);
terminal.attachCustomKeyEventHandler((event) => {
if(!onTerminalKeyEventRef.current){
return true;
@@ -441,7 +328,7 @@ const InteractiveTerminalDisplay = ({
});
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.open(terminalElementRef.current);
terminal.open(terminalElement);
terminalRef.current = terminal;
fitAddonRef.current = fitAddon;
resizeObserverRef.current = new ResizeObserver(() => scheduleFitTerminal());
@@ -465,7 +352,7 @@ const InteractiveTerminalDisplay = ({
resizeObserverRef.current = null;
fitAddonRef.current = null;
terminalRef.current = null;
terminalElementRef.current?.removeEventListener("paste", handleTerminalPaste);
terminalElement.removeEventListener("paste", handleTerminalPaste);
keyDisposable.dispose();
terminal.dispose();
}
@@ -496,11 +383,11 @@ const InteractiveTerminalDisplay = ({
if(!canAppend){
terminal.reset();
}
const terminalOutput = entriesToWrite.map( (entry) => formatInteractiveTerminalEntry({
entry,
const terminalOutput = formatInteractiveTerminalEntries({
entries: entriesToWrite,
useASNIColor,
showTaskStatus,
})).join("");
});
replayStateRef.current = {
settingsKey,
signatures: nextSignatures,
@@ -611,6 +498,7 @@ const TERMINAL_KEY_EVENT_INPUTS = {
PageDown: "\x1b[6~",
};
const isPrintableTerminalText = (value) => {
// eslint-disable-next-line no-control-regex
return value !== "" && !/[\x00-\x1f\x7f]/.test(value) && !value.startsWith("\x1b");
};
const getTerminalInputLabel = (value) => {
File diff suppressed because it is too large Load Diff
@@ -3,22 +3,203 @@ import AceEditor from 'react-ace';
import {useTheme} from '@mui/material/styles';
import {snackActions} from "../../utilities/Snackbar";
import {modeOptions} from "./ResponseDisplayMedia";
import FormControl from '@mui/material/FormControl';
import WrapTextIcon from '@mui/icons-material/WrapText';
import {MythicStyledTooltip} from "../../MythicComponents/MythicStyledTooltip";
import MenuItem from '@mui/material/MenuItem';
import TextField from '@mui/material/TextField';
import { IconButton, Select } from '@mui/material';
import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore';
import UnfoldLessIcon from '@mui/icons-material/UnfoldLess';
import { useReactiveVar } from '@apollo/client';
import { meState } from '../../../cache';
import CodeIcon from '@mui/icons-material/Code';
import {GetOutputFormatAll} from "./ResponseDisplayInteractive";
import PaletteIcon from '@mui/icons-material/Palette';
import Input from '@mui/material/Input';
import ArticleIcon from '@mui/icons-material/Article';
import NotesIcon from '@mui/icons-material/Notes';
import TerminalIcon from '@mui/icons-material/Terminal';
import ReactMarkdown from 'react-markdown';
import {Terminal} from '@xterm/xterm';
import {FitAddon} from '@xterm/addon-fit';
import '@xterm/xterm/css/xterm.css';
import {
getInteractiveTerminalTheme as getTerminalTheme,
getLongestTerminalTextLineLength as getLongestTerminalLineLength,
sanitizeTerminalOutput
} from "./ResponseDisplayInteractive";
import {isAllowedMarkdownLink, markdownPlugins} from "../../utilities/Markdown";
const MaxRenderSize = 2000000;
const RenderModes = {
plaintext: "plaintext",
markdown: "markdown",
terminal: "terminal",
};
const renderModeOptions = [
{value: RenderModes.plaintext, label: "Plaintext", Icon: ArticleIcon},
{value: RenderModes.markdown, label: "Markdown", Icon: NotesIcon},
{value: RenderModes.terminal, label: "Terminal", Icon: TerminalIcon},
];
const normalizeRenderMode = (value) => {
return Object.values(RenderModes).includes(value) ? value : RenderModes.plaintext;
}
const getInitialRenderMode = (props) => {
if(props?.render_mode !== undefined){
return normalizeRenderMode(props.render_mode);
}
if(props?.initial_render_mode !== undefined){
return normalizeRenderMode(props.initial_render_mode);
}
return props?.render_colors ? RenderModes.terminal : RenderModes.plaintext;
}
const markdownComponents = {
a: ({href, children}) => {
if(!isAllowedMarkdownLink(href)){
return children;
}
return <a href={href} target="_blank" rel="noopener noreferrer">{children}</a>;
},
code: ({className, children}) => (
<code className={className || "mythic-response-markdown-inline-code"}>{children}</code>
),
table: ({children}) => <table className="mythic-response-markdown-table">{children}</table>,
pre: ({children}) => <pre className="mythic-response-markdown-code-block">{children}</pre>,
};
const ResponseMarkdownDisplay = ({value, wrapText, expand}) => {
return (
<div className={`mythic-response-markdown${wrapText ? " is-wrapped" : " is-unwrapped"}${expand ? " is-expanded" : " is-capped"}`}>
<ReactMarkdown remarkPlugins={markdownPlugins} components={markdownComponents} skipHtml>
{value}
</ReactMarkdown>
</div>
)
}
const ResponseTerminalDisplay = ({value, wrapText, expand, theme}) => {
const terminalScrollContainerRef = React.useRef(null);
const terminalElementRef = React.useRef(null);
const terminalRef = React.useRef(null);
const fitAddonRef = React.useRef(null);
const resizeObserverRef = React.useRef(null);
const fitAnimationFrameRef = React.useRef(null);
const wrapTextRef = React.useRef(wrapText);
const unwrappedColumnCountRef = React.useRef(0);
const [terminalReady, setTerminalReady] = React.useState(false);
const longestLineLength = React.useMemo( () => getLongestTerminalLineLength(value), [value]);
const fitTerminal = React.useCallback( () => {
const terminal = terminalRef.current;
const fitAddon = fitAddonRef.current;
const terminalElement = terminalElementRef.current;
const scrollContainer = terminalScrollContainerRef.current;
if(!terminal || !fitAddon || !terminalElement || !scrollContainer){
return;
}
try{
terminalElement.style.width = "100%";
const proposedDimensions = fitAddon.proposeDimensions();
if(!proposedDimensions){
return;
}
const rows = Math.max(1, proposedDimensions.rows);
if(wrapTextRef.current){
fitAddon.fit();
scrollContainer.scrollLeft = 0;
} else {
const visibleCols = Math.max(1, proposedDimensions.cols);
const cols = Math.max(visibleCols, unwrappedColumnCountRef.current);
terminalElement.style.width = `${Math.ceil((cols / visibleCols) * scrollContainer.clientWidth)}px`;
terminal.resize(cols, rows);
}
}catch(error){
console.error(error);
}
}, []);
const scheduleFitTerminal = React.useCallback( () => {
if(fitAnimationFrameRef.current !== null){
return;
}
fitAnimationFrameRef.current = window.requestAnimationFrame(() => {
fitAnimationFrameRef.current = null;
fitTerminal();
});
}, [fitTerminal]);
React.useEffect( () => {
wrapTextRef.current = wrapText;
if(wrapText){
unwrappedColumnCountRef.current = 0;
} else {
unwrappedColumnCountRef.current = Math.min(Math.max(longestLineLength + 2, 1), 1000);
}
scheduleFitTerminal();
}, [longestLineLength, scheduleFitTerminal, wrapText]);
React.useEffect( () => {
if(!terminalElementRef.current){
return;
}
const terminal = new Terminal({
allowTransparency: true,
convertEol: true,
cursorBlink: false,
disableStdin: true,
fontFamily: 'Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
fontSize: 13,
scrollback: 5000,
theme: getTerminalTheme(theme),
});
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.open(terminalElementRef.current);
terminalRef.current = terminal;
fitAddonRef.current = fitAddon;
resizeObserverRef.current = new ResizeObserver(() => scheduleFitTerminal());
if(terminalScrollContainerRef.current){
resizeObserverRef.current.observe(terminalScrollContainerRef.current);
}
fitAnimationFrameRef.current = window.requestAnimationFrame(() => {
fitAnimationFrameRef.current = null;
fitTerminal();
setTerminalReady(true);
});
return () => {
if(fitAnimationFrameRef.current !== null){
window.cancelAnimationFrame(fitAnimationFrameRef.current);
fitAnimationFrameRef.current = null;
}
resizeObserverRef.current?.disconnect();
resizeObserverRef.current = null;
fitAddonRef.current = null;
terminalRef.current = null;
terminal.dispose();
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
React.useEffect( () => {
if(terminalRef.current){
terminalRef.current.options.theme = getTerminalTheme(theme);
}
}, [theme]);
React.useEffect( () => {
if(!terminalReady || !terminalRef.current){
return;
}
const terminal = terminalRef.current;
scheduleFitTerminal();
terminal.reset();
terminal.write(sanitizeTerminalOutput(value), () => terminal.scrollToBottom());
}, [scheduleFitTerminal, terminalReady, value]);
return (
<div className="mythic-response-terminal-shell"
style={{height: expand ? "100%" : "360px", minHeight: expand ? 0 : "140px"}}>
<div
ref={terminalScrollContainerRef}
className={"MythicInteractiveTerminal mythic-response-terminal"}
style={{
height: "100%",
overflowX: wrapText ? "hidden" : "auto",
overflowY: "hidden",
width: "100%",
}}>
<div ref={terminalElementRef} style={{height: "100%", width: "100%"}} />
</div>
</div>
)
}
export const ResponseDisplayPlaintext = (props) =>{
const theme = useTheme();
const me = useReactiveVar(meState);
@@ -26,9 +207,9 @@ export const ResponseDisplayPlaintext = (props) =>{
const [plaintextView, setPlaintextView] = React.useState("");
const initialMode = props?.initial_mode || "html";
const [mode, setMode] = React.useState(initialMode);
const [renderMode, setRenderMode] = React.useState(getInitialRenderMode(props));
const [wrapText, setWrapText] = React.useState(props?.wrap_text === undefined ? true : props.wrap_text);
const [showOptions, setShowOptions] = React.useState(false);
const [renderColors, setRenderColors] = React.useState(props?.render_colors === undefined ? false : props.render_colors);
const largeResponseWarningShown = React.useRef(false);
const onChangeText = (data) => {
if(props.onChangeContent){
@@ -40,43 +221,48 @@ export const ResponseDisplayPlaintext = (props) =>{
largeResponseWarningShown.current = false;
}, [props?.task?.id]);
useEffect( () => {
if(props.plaintext.length > MaxRenderSize){
const plaintext = props.plaintext === undefined || props.plaintext === null ? "" : String(props.plaintext);
if(plaintext.length > MaxRenderSize){
if(!largeResponseWarningShown.current){
snackActions.warning("Response too large (> 2MB), truncating the render. Download task output to view entire response.");
largeResponseWarningShown.current = true;
}
setPlaintextView(props.plaintext.substring(0, MaxRenderSize));
setPlaintextView(plaintext.substring(0, MaxRenderSize));
} else {
largeResponseWarningShown.current = false;
try{
if(props.autoFormat === undefined || props.autoFormat){
const newPlaintext = JSON.stringify(JSON.parse(String(props.plaintext)), null, 4);
const newPlaintext = JSON.stringify(JSON.parse(plaintext), null, 4);
setPlaintextView(newPlaintext);
setMode("json");
} else {
setPlaintextView(String(props.plaintext));
setPlaintextView(plaintext);
}
}catch(error){
setPlaintextView(String(props.plaintext));
setPlaintextView(plaintext);
}
}
}, [props.plaintext]);
}, [props.plaintext, props.autoFormat]);
const onChangeMode = (event) => {
setMode(event.target.value);
}
const onChangeRenderMode = (newMode) => {
setRenderMode(newMode);
}
const toggleWrapText = () => {
setWrapText(!wrapText);
}
const formatJSON = () => {
try{
let tmp = JSON.stringify(JSON.parse(currentContentRef.current?.editor?.getValue()), null, 2);
const currentValue = currentContentRef.current?.editor?.getValue?.() || plaintextView;
let tmp = JSON.stringify(JSON.parse(currentValue), null, 2);
setPlaintextView(tmp);
setMode("json");
}catch(error){
snackActions.warning("Failed to reformat as JSON")
}
}
const onChangeShowOptions = (e) => {
const onChangeShowOptions = () => {
setShowOptions(!showOptions);
}
const scrollContent = (node, isAppearing) => {
@@ -98,75 +284,85 @@ export const ResponseDisplayPlaintext = (props) =>{
}
React.useLayoutEffect( () => {
scrollContent()
}, []);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
React.useEffect( () => {
setMode(props?.initial_mode || "html");
}, [props?.initial_mode]);
React.useEffect( () => {
setRenderMode(getInitialRenderMode({
initial_render_mode: props?.initial_render_mode,
render_colors: props?.render_colors,
render_mode: props?.render_mode,
}));
}, [props?.initial_render_mode, props?.render_mode, props?.render_colors]);
const currentRenderModeLabel = renderModeOptions.find((option) => option.value === renderMode)?.label || "Plaintext";
const displayWrapText = renderMode === RenderModes.plaintext ? wrapText : true;
return (
<div style={{display: "flex", height: "100%", flexDirection: "column"}}>
{showOptions &&
<div style={{display: "inline-flex", flexDirection: "row", alignItems: "center",
color: theme.outputTextColor,
backgroundColor: theme.outputBackgroundColor + (theme.palette.mode === 'dark' ? "D0" : "20"),}}>
<FormControl sx={{ display: "inline-block", marginLeft: "10px", color: theme.outputTextColor, }} size="small">
<TextField
label={"Syntax"}
select
margin={"dense"}
size={"small"}
sx={{
// Target the icon element directly within the Select
'.MuiSelect-icon': {
color: theme.outputTextColor, // Set your desired color
},
'.MuiInputBase-root': {
color: theme.outputTextColor,
}
}}
style={{display: "inline-block", width: "100%",}}
value={mode}
onChange={onChangeMode}
>
{
modeOptions.map((opt, i) => (
<MenuItem key={"searchopt" + opt} value={opt}>{opt}</MenuItem>
))
}
</TextField>
</FormControl>
<MythicStyledTooltip title={wrapText ? "Unwrap Text" : "Wrap Text"} >
<IconButton onClick={toggleWrapText} style={{}}>
<WrapTextIcon color={wrapText ? "success" : "secondary"}
style={{cursor: "pointer"}}
/>
</IconButton>
</MythicStyledTooltip>
<MythicStyledTooltip title={"Auto format JSON"} >
<IconButton onClick={formatJSON} style={{}}>
<CodeIcon color={"info"} style={{cursor: "pointer"}} />
</IconButton>
</MythicStyledTooltip>
<MythicStyledTooltip title={renderColors ? "Render Plaintext" : "Render Colors"} >
<IconButton onClick={() => {setRenderColors(!renderColors)}}>
<PaletteIcon color={renderColors ? "success" : "secondary"} style={{cursor: "pointer"}} />
</IconButton>
</MythicStyledTooltip>
</div>
}
{props.displayType !== 'console' &&
<div style={{
height: "1px",
width: "100%",
display: "flex",
zIndex: 1,
}}>
<div className={`mythic-response-render-toolbar${showOptions ? " is-open" : ""}`}>
<button className="mythic-response-render-toolbar-toggle"
type="button"
onClick={onChangeShowOptions}
style={{color: theme.outputTextColor}}>
{showOptions ? <UnfoldLessIcon fontSize="small" /> : <UnfoldMoreIcon fontSize="small" />}
<span className="mythic-response-render-toolbar-title">Output</span>
<span className="mythic-response-render-toolbar-mode">{currentRenderModeLabel}</span>
</button>
{showOptions &&
<UnfoldLessIcon onClick={onChangeShowOptions}
style={{cursor: "pointer", position: "relative", top: "-9px"}}/>
}
{!showOptions &&
<UnfoldMoreIcon onClick={onChangeShowOptions}
style={{cursor: "pointer", position: "relative", top: "-9px"}}/>
<div className="mythic-response-render-toolbar-controls">
<div className="mythic-response-render-mode-group" role="group" aria-label="Response render mode">
{renderModeOptions.map(({value, label, Icon}) => (
<button
aria-pressed={renderMode === value}
className={`mythic-response-render-mode-button${renderMode === value ? " is-selected" : ""}`}
key={value}
onClick={() => onChangeRenderMode(value)}
type="button">
<Icon fontSize="small" />
<span>{label}</span>
</button>
))}
</div>
{renderMode === RenderModes.plaintext &&
<>
<div className="mythic-response-render-action-group">
<MythicStyledTooltip title={wrapText ? "Unwrap Text" : "Wrap Text"} >
<button
aria-label={wrapText ? "Unwrap Text" : "Wrap Text"}
aria-pressed={wrapText}
className={`mythic-response-render-action-button${wrapText ? " is-selected" : ""}`}
onClick={toggleWrapText}
type="button">
<WrapTextIcon fontSize="small" />
</button>
</MythicStyledTooltip>
<MythicStyledTooltip title={"Auto format JSON"} >
<button
aria-label="Auto format JSON"
className="mythic-response-render-action-button"
onClick={formatJSON}
type="button">
<CodeIcon fontSize="small" />
</button>
</MythicStyledTooltip>
</div>
<label className="mythic-response-syntax-group">
<span>Syntax</span>
<select
className="mythic-response-syntax-select"
value={mode}
onChange={onChangeMode}>
{
modeOptions.map((opt) => (
<option key={"searchopt" + opt} value={opt}>{opt}</option>
))
}
</select>
</label>
</>
}
</div>
}
</div>
}
@@ -174,21 +370,15 @@ export const ResponseDisplayPlaintext = (props) =>{
<div style={{
flexGrow: 1,
height: "100%",
paddingLeft: renderColors ? "15px" : "0px",
paddingRight: renderColors ? "5px" : "0px"
minHeight: 0,
}}>
{renderColors &&
<GetOutputFormatAll data={[
{response: plaintextView, id: props?.task?.id || 0, timestamp: "1970-01-01"}]}
myTask={false}
taskID={props?.task?.id || 0}
useASNIColor={true}
messagesEndRef={null}
showTaskStatus={false}
search={undefined}
wrapText={wrapText}/>
{renderMode === RenderModes.markdown &&
<ResponseMarkdownDisplay value={plaintextView} wrapText={displayWrapText} expand={props.expand} />
}
{!renderColors &&
{renderMode === RenderModes.terminal &&
<ResponseTerminalDisplay value={plaintextView} wrapText={displayWrapText} expand={props.expand} theme={theme} />
}
{renderMode === RenderModes.plaintext &&
<AceEditor
className={"roundedBottomCorners"}
ref={currentContentRef}
@@ -206,7 +396,7 @@ export const ResponseDisplayPlaintext = (props) =>{
width={"100%"}
//style={{backgroundColor: "transparent"}}
//autoScrollEditorIntoView={true}
wrapEnabled={props.displayType !== 'console' ? wrapText : true}
wrapEnabled={props.displayType !== 'console' ? displayWrapText : true}
minLines={1}
setOptions={{
showLineNumbers: props.displayType !== 'console',
@@ -1,7 +1,6 @@
import React from 'react';
import {gql, useLazyQuery, useMutation, useQuery, useSubscription} from '@apollo/client';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import {alpha, useTheme} from '@mui/material/styles';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
@@ -48,6 +47,7 @@ import {MythicConfirmDialog} from "../../MythicComponents/MythicConfirmDialog";
import {MeContext} from "../../App";
import {snackActions} from "../../utilities/Snackbar";
import {getSkewedNow} from "../../utilities/Time";
import {isAllowedMarkdownLink, markdownPlugins} from "../../utilities/Markdown";
import {EventStepUserInteractionDialog} from "../Eventing/EventStepRender";
const CHAT_MESSAGE_LIMIT = 250;
@@ -351,8 +351,6 @@ query ChatSearch($query: String!, $channel_id: Int, $limit: Int, $offset: Int) {
}
`;
const allowedLinkSchemes = ["http:", "https:", "mailto:"];
const isGeneralChatChannel = (channel) => channel?.channel_type === "standard" && channel?.slug === "general";
const CHAT_SEARCH_SNIPPET_LENGTH = 260;
@@ -544,7 +542,6 @@ const mergeReadStateRows = (current, incoming) => {
}, current);
};
const markdownPlugins = [remarkGfm];
const markdownTableAlignments = ["left", "right", "center"];
const getMarkdownTableAlign = (align) => markdownTableAlignments.includes(align) ? align : "left";
@@ -596,15 +593,7 @@ const markdownComponents = {
<code className={className || "mythic-chat-inline-code"}>{children}</code>
),
a: ({href, children}) => {
if(!href){
return children;
}
try{
const url = new URL(href, window.location.origin);
if(!allowedLinkSchemes.includes(url.protocol)){
return children;
}
}catch(error){
if(!isAllowedMarkdownLink(href)){
return children;
}
return <a href={href} target="_blank" rel="noopener noreferrer">{children}</a>;
@@ -4,10 +4,11 @@ import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import {useTheme} from '@mui/material/styles';
import { gql, useQuery } from '@apollo/client';
import {ColoredTaskLabel, StyledPaper, classes, StyledAccordionSummary, accordionClasses} from '../Callbacks/TaskDisplay';
import {GetOutputFormatAll} from '../Callbacks/ResponseDisplayInteractive';
import {formatInteractiveTerminalEntries} from '../Callbacks/ResponseDisplayInteractive';
import {b64DecodeUnicode} from "../Callbacks/ResponseDisplay";
import Accordion from '@mui/material/Accordion';
import {MythicStyledTooltip} from "../../MythicComponents/MythicStyledTooltip";
import {ResponseDisplayPlaintext} from "../Callbacks/ResponseDisplayPlaintext";
const responsesQuery = gql`
@@ -25,7 +26,7 @@ query subResponsesQuery($task_id: Int!, $task_processing_time: timestamp!, $resp
export const TaskDisplayInteractiveSearch = ({me, task}) => {
const theme = useTheme();
const [taskDisplayID, setTaskDisplayID] = React.useState("");
const [responsesSurrounding, setResponsesSurrounding] = React.useState(5);
const responsesSurrounding = 5;
const [dropdownOpen, setDropdownOpen] = React.useState(false);
const toggleTaskDropdown = React.useCallback( (event, expanded) => {
if(window.getSelection().toString() !== ""){
@@ -34,6 +35,11 @@ export const TaskDisplayInteractiveSearch = ({me, task}) => {
setDropdownOpen(!dropdownOpen);
}, [dropdownOpen]);
const [alloutput, setAllOutput] = React.useState([]);
const terminalOutput = React.useMemo(() => formatInteractiveTerminalEntries({
entries: alloutput,
useASNIColor: true,
showTaskStatus: false,
}), [alloutput]);
useQuery(responsesQuery, {
variables: {task_id: task.parent_task_id, task_processing_time: task?.status_timestamp_submitted || "1970-01-01", responses_surrounding: responsesSurrounding},
onCompleted: (data) => {
@@ -72,19 +78,15 @@ export const TaskDisplayInteractiveSearch = ({me, task}) => {
href={"/new/task/" + taskDisplayID}>{taskDisplayID}</Link>
</MythicStyledTooltip>
</span>
<div style={{
overflowY: "auto", width: "100%", marginBottom: "5px", paddingLeft: "10px", maxHeight: "500px"
}} id={`ptytask${task.id}`}>
<GetOutputFormatAll key={"getoutput"} data={alloutput}
myTask={task.operator.username === (me?.user?.username || "")}
taskID={task.id}
useASNIColor={true}
messagesEndRef={null}
showTaskStatus={false}
wrapText={true}/>
<div style={{width: "100%", marginBottom: "5px", paddingLeft: "10px", maxHeight: "500px"}} id={`ptytask${task.id}`}>
<ResponseDisplayPlaintext
displayType={"console"}
expand={false}
plaintext={terminalOutput}
render_mode={"terminal"}
wrap_text={true}/>
</div>
</Accordion>
</StyledPaper>
)
}
}
@@ -0,0 +1,16 @@
import remarkGfm from 'remark-gfm';
export const markdownPlugins = [remarkGfm];
export const allowedMarkdownLinkSchemes = ["http:", "https:", "mailto:"];
export const isAllowedMarkdownLink = (href) => {
if(!href){
return false;
}
try{
const url = new URL(href, window.location.origin);
return allowedMarkdownLinkSchemes.includes(url.protocol);
}catch(error){
return false;
}
}
+280
View File
@@ -4381,6 +4381,286 @@ tspan {
line-height: 1.35;
padding: 0.35rem 0.5rem;
}
.mythic-response-render-toolbar {
background-color: var(--mythic-global-210);
border-bottom: 1px solid var(--mythic-global-211);
color: var(--mythic-global-212);
display: flex;
flex: 0 0 auto;
flex-direction: column;
min-width: 0;
}
.mythic-response-render-toolbar-toggle {
align-items: center;
background-color: transparent;
border: 0;
color: inherit;
cursor: pointer;
display: inline-flex;
font: inherit;
gap: 0.35rem;
justify-content: flex-start;
min-height: 2rem;
min-width: 0;
padding: 0.28rem 0.55rem;
text-align: left;
width: 100%;
}
.mythic-response-render-toolbar-toggle:hover {
background-color: var(--mythic-global-219);
}
.mythic-response-render-toolbar-title {
font-size: 0.75rem;
font-weight: 850;
line-height: 1;
}
.mythic-response-render-toolbar-mode {
align-items: center;
background-color: var(--mythic-global-213);
border: 1px solid var(--mythic-global-214);
border-radius: var(--mythic-global-008);
display: inline-flex;
flex: 0 0 auto;
font-size: 0.68rem;
font-weight: 800;
line-height: 1;
min-height: 1.35rem;
padding: 0.18rem 0.4rem;
}
.mythic-response-render-toolbar-controls {
align-items: center;
display: flex;
flex-wrap: nowrap;
gap: 0.45rem;
min-width: 0;
min-height: calc(1.8rem + 2px);
overflow-x: auto;
overflow-y: hidden;
padding: 0.08rem 0.55rem 0.45rem;
}
.mythic-response-render-mode-group,
.mythic-response-render-action-group {
align-items: center;
background-color: var(--mythic-global-216);
border: 1px solid var(--mythic-global-211);
border-radius: var(--mythic-global-008);
display: inline-flex;
flex: 0 0 auto;
overflow: hidden;
}
.mythic-response-render-mode-button {
align-items: center;
background-color: transparent;
border: 0;
border-right: 1px solid var(--mythic-global-217);
color: var(--mythic-global-218);
cursor: pointer;
display: inline-flex;
font: inherit;
font-size: 0.72rem;
font-weight: 800;
gap: 0.25rem;
justify-content: center;
min-height: 1.8rem;
padding: 0 0.52rem;
transition: background-color 120ms ease, color 120ms ease;
}
.mythic-response-render-mode-button:last-child {
border-right: 0;
}
.mythic-response-render-mode-button:hover {
background-color: var(--mythic-global-219);
color: var(--mythic-global-220);
}
.mythic-response-render-mode-button.is-selected {
background-color: var(--mythic-global-225);
color: var(--mythic-global-220);
}
.mythic-response-render-action-group {
gap: 0;
}
.mythic-response-render-action-button {
align-items: center;
background-color: transparent;
border: 0;
border-right: 1px solid var(--mythic-global-217);
color: var(--mythic-global-218);
cursor: pointer;
display: inline-flex;
height: 1.8rem;
justify-content: center;
padding: 0;
transition: background-color 120ms ease, color 120ms ease;
width: 1.8rem;
}
.mythic-response-render-action-button:last-child {
border-right: 0;
}
.mythic-response-render-action-button:hover {
background-color: var(--mythic-global-219);
color: var(--mythic-global-220);
}
.mythic-response-render-action-button.is-selected {
background-color: var(--mythic-global-225);
color: var(--mythic-global-220);
}
.mythic-response-syntax-group {
align-items: center;
background-color: var(--mythic-global-216);
border: 1px solid var(--mythic-global-211);
border-radius: var(--mythic-global-008);
color: var(--mythic-global-218);
display: inline-flex;
flex: 0 0 auto;
font-size: 0.72rem;
font-weight: 800;
gap: 0.35rem;
height: 1.8rem;
line-height: 1;
min-width: 0;
overflow: hidden;
padding: 0 0.42rem;
}
.mythic-response-syntax-select {
background-color: transparent;
border: 0;
color: var(--mythic-global-220);
cursor: pointer;
font: inherit;
height: 100%;
min-width: 7.2rem;
outline: none;
}
.mythic-response-syntax-select option {
color: initial;
}
.mythic-response-markdown {
box-sizing: border-box;
color: var(--mythic-global-212);
font-size: 0.86rem;
line-height: 1.45;
min-height: 0;
max-width: 100%;
overflow: auto;
padding: 0.65rem 0.75rem;
width: 100%;
}
.mythic-response-markdown.is-expanded {
height: 100%;
}
.mythic-response-markdown.is-capped {
max-height: 360px;
}
.mythic-response-markdown > :first-child {
margin-top: 0;
}
.mythic-response-markdown > :last-child {
margin-bottom: 0;
}
.mythic-response-markdown p {
margin: 0 0 0.55rem;
overflow-wrap: anywhere;
}
.mythic-response-markdown h1,
.mythic-response-markdown h2,
.mythic-response-markdown h3,
.mythic-response-markdown h4,
.mythic-response-markdown h5,
.mythic-response-markdown h6 {
font-weight: 850;
letter-spacing: 0;
line-height: 1.22;
margin: 0.8rem 0 0.45rem;
}
.mythic-response-markdown h1,
.mythic-response-markdown h2 {
font-size: 1.05rem;
}
.mythic-response-markdown h3,
.mythic-response-markdown h4,
.mythic-response-markdown h5,
.mythic-response-markdown h6 {
font-size: 0.92rem;
}
.mythic-response-markdown ul,
.mythic-response-markdown ol {
margin: 0.35rem 0 0.65rem;
padding-left: 1.35rem;
}
.mythic-response-markdown li {
margin: 0.14rem 0;
}
.mythic-response-markdown blockquote {
background-color: var(--mythic-global-216);
border: 1px solid var(--mythic-global-211);
border-left: 3px solid var(--mythic-global-215);
border-radius: var(--mythic-global-008);
margin: 0.5rem 0;
padding: 0.45rem 0.6rem;
}
.mythic-response-markdown hr {
border: 0;
border-top: 1px solid var(--mythic-global-211);
margin: 0.75rem 0;
}
.mythic-response-markdown a {
color: var(--mythic-global-023);
}
.mythic-response-markdown-inline-code {
background-color: var(--mythic-global-216);
border: 1px solid var(--mythic-global-211);
border-radius: 4px;
font-family: var(--mythic-global-065);
font-size: 0.9em;
padding: 1px 4px;
}
.mythic-response-markdown-code-block {
background-color: var(--mythic-global-216);
border: 1px solid var(--mythic-global-211);
border-radius: var(--mythic-global-008);
font-family: var(--mythic-global-065);
font-size: 0.82rem;
margin: 0.45rem 0 0.65rem;
overflow-x: auto;
padding: 0.65rem;
}
.mythic-response-markdown-code-block code {
font-family: inherit;
}
.mythic-response-markdown.is-wrapped .mythic-response-markdown-code-block code {
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.mythic-response-markdown.is-unwrapped .mythic-response-markdown-code-block code {
white-space: pre;
}
.mythic-response-markdown-table {
border-collapse: collapse;
display: block;
margin: 0.45rem 0 0.65rem;
max-width: 100%;
overflow-x: auto;
width: fit-content;
}
.mythic-response-markdown-table th,
.mythic-response-markdown-table td {
border: 1px solid var(--mythic-global-211);
padding: 0.35rem 0.5rem;
text-align: left;
vertical-align: top;
}
.mythic-response-markdown-table th {
background-color: var(--mythic-global-216);
font-weight: 850;
}
.mythic-response-terminal-shell {
min-width: 0;
width: 100%;
}
.mythic-response-terminal {
min-height: 0;
width: 100%;
}
@media (max-width: 760px) {
.mythic-interactive-terminal-toolbar-row {
flex-wrap: wrap;