mirror of
https://github.com/its-a-feature/Mythic
synced 2026-06-08 14:55:38 +00:00
better markdown/terminal view for task output
This commit is contained in:
+10
-10
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user