Sync + Added dashboard for stats API

This commit is contained in:
Frikky
2025-10-20 11:37:26 +02:00
parent 9a20dcd426
commit 1bff92172b
38 changed files with 3985 additions and 1432 deletions
+557 -78
View File
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useContext, memo } from "react";
import { Context } from "../context/ContextApi.jsx";
import AuthenticationModal from "../components/AuthenticationModal.jsx";
import { useNavigate, Link, useLocation } from "react-router-dom";
import { getTheme } from "../theme.jsx";
import { toast } from "react-toastify"
@@ -21,12 +22,16 @@ import {
import {
CheckCircle as CheckCircleIcon,
Check as CheckIcon,
HourglassDisabled as HourglassDisabledIcon,
RestartAlt as RestartAltIcon,
ExpandMore as ExpandMoreIcon,
ExpandLess as ExpandLessIcon,
Send as SendIcon,
Error as ErrorIcon,
Close as CloseIcon,
OpenInNew as OpenInNewIcon,
Refresh as RefreshIcon,
} from '@mui/icons-material'
import {
@@ -43,21 +48,26 @@ const AgentUI = (props) => {
const [data, setData] = useState({})
const [openIndexes, setOpenIndexes] = useState([])
const [disableButtons, setDisableButtons] = useState(false)
const [apps, setApps] = useState([])
const [appAuth, setAppAuth] = useState([])
const [originalStartTime, setOriginalStartTime] = useState(0)
const [latestEndTime, setLatestEndTime] = useState(0)
const [showAgentStarter, setShowAgentStarter] = useState(false)
const [actionInput, setActionInput] = useState("")
const [questionAnswers, setQuestionAnswers] = useState({})
const {themeMode} = useContext(Context)
const theme = getTheme(themeMode)
const navigate = useNavigate();
document.title = "Shuffle AI Agents"
const agentWrapperStyle = {
width: 1000,
height: 1000,
margin: "auto",
paddingTop: 100,
paddingBottom: 1000,
backgroundColor: theme.palette.backgroundColor,
}
if (data.input === undefined || data.input === null) {
@@ -75,7 +85,22 @@ const AgentUI = (props) => {
}
if (node_id === undefined || node_id === null || node_id === "") {
return
// Look for AI agent
/*
for (var key in execution_data.results) {
const item = execution_data.results[key]
if (item?.action?.app_name !== "AI Agent") {
continue
}
node_id = item?.action?.id
break
}
*/
if (node_id === undefined || node_id === null || node_id === "") {
return
}
}
var found = false
@@ -150,15 +175,22 @@ const AgentUI = (props) => {
if (responseJson.success !== false) {
if (responseJson.status === "EXECUTING") {
// Recursively looking for updates until it's not executing anymore
setTimeout(() => {
GetExecution(execution_id, node_id, authorization)
}, 3000)
//setTimeout(() => {
// GetExecution(execution_id, node_id, authorization)
//}, 3000)
} else {
setDisableButtons(false)
setDisableButtons(false)
}
setExecution(responseJson)
try {
if (JSON.stringify(responseJson) !== JSON.stringify(execution)) {
setExecution(responseJson)
}
} catch(e) {
console.log("Error comparing executions: ", e)
setExecution(responseJson)
}
findNodeData(responseJson, node_id)
} else {
setDisableButtons(false)
@@ -216,12 +248,53 @@ const AgentUI = (props) => {
}
GetExecution(execution.execution_id, agentActionResult.action.id, execution.authorization)
setTimeout(() => {
GetExecution(execution.execution_id, agentActionResult.action.id, execution.authorization)
}, 10000)
})
.catch((error) => {
toast.error("Error: " + error)
})
}
const getAppAuth = () => {
const url = `${globalUrl}/api/v1/apps/authentication`
fetch(url, {
method: "GET",
credentials: "include",
})
.then((response) => {
return response.json()
})
.then((responseJson) => {
if (responseJson.success !== false) {
setAppAuth(responseJson)
}
})
.catch((error) => {
toast.error("Error in auth load: " + error)
})
}
const getApps = () => {
const url = `${globalUrl}/api/v1/apps`
fetch(url, {
method: "GET",
credentials: "include",
})
.then((response) => {
return response.json()
})
.then((responseJson) => {
if (responseJson.success !== false) {
setApps(responseJson)
}
})
.catch((error) => {
toast.error("Error in app load: " + error)
})
}
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const executionId = params.get("execution_id")
@@ -233,9 +306,15 @@ const AgentUI = (props) => {
setShowAgentStarter(true)
//toast.warn("No execution ID or node ID provided. Please provide execution_id and node_id in the URL.")
}
getApps()
getAppAuth()
}, [])
const maxTimelineWidth = 150
const maxTimelineWidth = 300
var latestEndTime = 0
var originalStartTime = 0
const TimelineItem = (props) => {
const { item, index } = props;
const [hovered, setHovered] = useState(false);
@@ -258,12 +337,96 @@ const AgentUI = (props) => {
</Tooltip>
const categoryStyle = {
width: 20,
height: 20,
width: 25,
height: 25,
marginRight: 10,
borderRadius: 5,
}
const parsedCategory = item.category === "singul" ?
const validate = validateJson(item.details)
const itemStartTime = item.start_time
var itemEndTime = item.end_time
if (item.category === "agent" && itemStartTime !== undefined && itemStartTime !== originalStartTime && (itemStartTime < originalStartTime || originalStartTime === 0)) {
console.log("Rerender 1: ", itemStartTime, originalStartTime)
originalStartTime = itemStartTime
}
if (itemEndTime !== undefined && itemEndTime > latestEndTime) {
console.log("Rerender 2")
latestEndTime = itemEndTime
}
if (itemEndTime === undefined || itemEndTime === null) {
// Set it to now
itemEndTime = latestEndTime
}
if (item.category == "agent" && itemEndTime === 0) {
// Right now -> .toLocaleString() support
itemEndTime = Date.now() / 1000
//<Tooltip title={`Time taken: ${currentDuration} seconds. Started: ${new Date(item.start_time * 1000).toLocaleString()}\nFinished: ${new Date(item.end_time * 1000).toLocaleString()}`} placement="right">
if (itemEndTime > latestEndTime) {
latestEndTime = itemEndTime
}
}
const totalDuration = latestEndTime - originalStartTime
var currentDuration = itemStartTime - itemEndTime
var timelineMarginLeft = ((itemStartTime - originalStartTime) / totalDuration) * maxTimelineWidth
//var timelineMarginLeft = 0
// Calculate how long the div should be
var timelineWidth = ((itemEndTime - itemStartTime) / totalDuration) * maxTimelineWidth
//console.log("CURRENT DURATION (1): ", currentDuration, itemStartTime, itemEndTime, originalStartTime, latestEndTime, totalDuration, timelineMarginLeft, timelineWidth)
if (totalDuration === currentDuration) {
timelineMarginLeft = 0
timelineWidth = maxTimelineWidth
}
// Just for simplicity's sake
if (currentDuration < -1000000 || currentDuration > 1000000) {
currentDuration = 0
}
if (currentDuration < 0) {
currentDuration = currentDuration * -1
}
const defaultTopPadding = 10
const open = openIndexes.includes(index)
var questions = []
if (item?.details?.action === "finish" || item.category == "finish" || item?.details?.action == "finalise") {
item.type = "finalise"
item.category = "finalise"
item.label = item?.details?.reason || item.label
} else if (item?.category === "ask" || item?.details?.action === "ask") {
item.type = "question"
item.category = "ask"
item.label = item?.details?.reason || item.label
for (var fieldKey in item?.details?.fields) {
const field = item?.details?.fields[fieldKey]
if (field?.key !== "question") {
continue
}
questions.push({
"question": field?.value,
"index": questions.length + 1,
})
}
} else if (item?.details?.action === "api" && item?.details?.tool?.length > 0) {
item.label = item?.details?.reason || item.label
}
var parsedCategory = item.category === "singul" ?
<Tooltip title="Singul" placement="top">
<img src="/images/logos/singul.svg" style={categoryStyle} />
</Tooltip>
@@ -273,38 +436,167 @@ const AgentUI = (props) => {
</Tooltip>
:
<div style={categoryStyle} />
const validate = validateJson(item.details)
const itemStartTime = item.start_time
var itemEndTime = item.end_time
if (itemStartTime !== undefined && itemStartTime !== originalStartTime && (itemStartTime < originalStartTime || originalStartTime === 0)) {
console.log("Rerender 1")
//setOriginalStartTime(itemStartTime)
var showAuthentication = false
var selectedApp = {}
if (item?.details?.tool !== undefined && item?.details?.tool !== null && item?.details?.tool?.length > 0 && item?.details?.tool !== "singul" && item?.details?.tool !== item?.details?.action) {
// Find the app and inject the image
const toolName = item.details.tool.toLowerCase().replaceAll(" ", "_").replaceAll("-", "_")
for (var appKey in apps) {
const app = apps[appKey]
const appname = app.name.toLowerCase().replaceAll(" ", "_").replaceAll("-", "_")
if (appname !== toolName) {
continue
}
if (app.large_image === undefined || app.large_image === null || app.large_image.length === 0) {
break
}
selectedApp = app
// Override the category
//item.category = app.name
//item.label = item?.details?.reason || item.label
parsedCategory =
<Tooltip title={app.name} placement="top">
<img src={app.large_image} style={categoryStyle} />
</Tooltip>
break
}
}
if (itemEndTime !== undefined && itemEndTime > latestEndTime) {
console.log("Rerender 2")
setLatestEndTime(itemEndTime)
if (!showAuthentication) {
if (item?.details?.run_details?.raw_response !== undefined && item?.details?.run_details?.raw_response !== null && item?.details?.run_details?.raw_response?.includes("app_authentication")) {
showAuthentication = true
}
}
if (itemEndTime === undefined || itemEndTime === null) {
// Set it to now
itemEndTime = latestEndTime
var questionSubmitDisabled = questions.length === 0 ? true : false
for (var qKey in questions) {
const q = questions[qKey]
if (questionAnswers[q.question] === undefined || questionAnswers[q.question] === null || questionAnswers[q.question] === "") {
//console.log("EMPTY QUESTION: ", q)
questionSubmitDisabled = true
break
} else {
questionSubmitDisabled = false
}
}
const totalDuration = latestEndTime - originalStartTime
const currentDuration = itemStartTime - itemEndTime
var timelineMarginLeft = ((itemStartTime - originalStartTime) / totalDuration) * maxTimelineWidth
var timelineWidth = ((itemEndTime - itemStartTime) / totalDuration) * maxTimelineWidth
const barColor = item.status === "FINISHED" ? green :
item.status === "FAILURE" || item.status == "ABORTED" ? red :
item.status === "RUNNING" || item.status === "" ? theme.palette.main :
theme.palette.surfaceColor
if (totalDuration === currentDuration) {
timelineMarginLeft = 0
timelineWidth = maxTimelineWidth
const rerunAgentButton =
<Tooltip title="Rerun from the start with the same input" placement="right">
<span>
<IconButton
style={{marginLeft: 20, }}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
toast.info("Attempting to rerun everything.")
setDisableButtons(true)
if (item?.details === undefined || item?.details === null || item?.details?.input === undefined || item?.details?.input === null) {
toast.error("No decision details found to rerun. Cannot proceed. Please go back to your workflow or /agents to start over.")
} else {
//console.log("DETAILS: ", item?.details)
for (var messagekey in item?.details?.input?.messages) {
const message = item?.details?.input?.messages[messagekey]
if (message.role === "user") {
setActionInput(message.content)
setDisableButtons(true)
submitInput(message.content)
//toast.info("Rerun started. Please wait a few seconds and this page should refresh automatically.")
break
}
}
}
}}
>
<RestartAltIcon />
</IconButton>
</span>
</Tooltip>
const rerunButton =
<Tooltip title="Rerun JUST this decision. This can be used if an agent decision action somehow stopped and didn't get a result." placement="right">
<span>
<IconButton
disabled={item.type !== "decision" || disableButtons}
style={{marginLeft: 20, }}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
//toast.info("Attempting to rerun this decision by itself.")
setDisableButtons(true)
RerunDecision(item.details)
}}
>
<RestartAltIcon />
</IconButton>
</span>
</Tooltip>
const submitQuestions = (decisionId, questionAnswers) => {
console.log("Submitting questions: ", decisionId, questionAnswers)
if (decisionId === undefined || decisionId === null || decisionId === "") {
toast.error("No decision ID provided. Cannot submit answers.")
return
}
if (Object.keys(questionAnswers).length === 0) {
toast.error("No answers provided. Cannot submit empty answers.")
return
}
// Loop qu
var newArgument = {}
for (var key in questionAnswers) {
const answer = questionAnswers[key]
newArgument["question_"+(answer.index)] = answer.value
}
const params = new URLSearchParams(window.location.search)
const executionId = params.get("execution_id")
const nodeId = params.get("node_id")
const authorization = params.get("authorization")
const url = `${globalUrl}/api/v1/workflows/${executionId}/run?reference_execution=${executionId}&authorization=${authorization}&answer=true&note=${encodeURIComponent(JSON.stringify(newArgument))}&agentic=true&decision_id=${decisionId}`
console.log("PARSED URL: ", url)
fetch(url, {
method: "GET",
credentials: "include",
})
.then((response) => {
return response.json()
})
.then((responseJson) => {
if (responseJson.success !== false) {
setTimeout(() => {
GetExecution(execution.execution_id, agentActionResult.action.id, execution.authorization)
}, 500)
toast.success("Successfully submitted answers! The agent should continue shortly.")
} else {
toast.warn("Failed to submit answers. Please try again or contact support@shuffler.io if this persists..")
}
})
.catch((error) => {
toast.error("Problem with submitting: " + error)
})
}
const defaultTopPadding = 10
const open = openIndexes.includes(index)
return (
<div
style={{
@@ -315,7 +607,7 @@ const AgentUI = (props) => {
}}
onMouseEnter={() => {
if (!hovered) {
console.log("HOVER")
//console.log("HOVER")
setHovered(true)
}
}}
@@ -364,41 +656,50 @@ const AgentUI = (props) => {
<div style={{minWidth: 50, maxWidth: 50, paddingTop: defaultTopPadding, }}>
{parsedCategory}
</div>
{/*
<div style={{minWidth: 200, maxWidth: 200, paddingTop: defaultTopPadding, }}>
{/* To ISO string from unix time */}
{new Date(item.start_time * 1000).toLocaleString()}
{item?.start_time !== undefined && item?.start_time !== null && item?.start_time !== 0 ?
new Date(item.start_time * 1000).toLocaleString()
:
null
}
</div>
*/}
<div style={{minWidth: 100, maxWidth: 100, paddingTop: defaultTopPadding-5, }}>
<Chip
label={item.type}
/>
</div>
<div style={{
minWidth: 200,
maxWidth: 200,
minWidth: 300,
maxWidth: 300,
paddingTop: defaultTopPadding,
}}>
{item.label}
</div>
<Tooltip title={`Time taken: ${currentDuration*-1} seconds. Started: ${new Date(item.start_time * 1000).toLocaleString()}\nFinished: ${new Date(item.end_time * 1000).toLocaleString()}`} placement="right">
<Tooltip title={`Time taken: ${currentDuration} seconds. Started: ${new Date(itemStartTime * 1000).toLocaleString()}\nFinished: ${new Date(itemEndTime * 1000).toLocaleString()}`} placement="right">
<div style={{
minWidth: maxTimelineWidth,
maxWidth: maxTimelineWidth,
paddingTop: defaultTopPadding*1.3,
paddingTop: defaultTopPadding*1.5,
}}>
{currentDuration !== 0 && !isNaN(timelineMarginLeft) && !isNaN(timelineWidth) && timelineWidth > 0 ?
{currentDuration != 0 && !isNaN(timelineMarginLeft) && !isNaN(timelineWidth) && timelineWidth > 0 ?
<div style={{
backgroundColor: item.status === "FINISHED" ?
green : item.status === "RUNNING" || item.status === "" ?
theme.palette.main : theme.palette.surfaceColor,
backgroundColor: barColor,
marginLeft: timelineMarginLeft,
minWidth: timelineWidth,
maxWidth: timelineWidth,
height: 10,
}} />
: null}
minHeight: 10,
maxHeight: 10,
borderRadius: theme.palette.borderRadius,
}}>
</div>
:
<Typography variant="body2" color="textSecondary">
</Typography>
}
</div>
</Tooltip>
@@ -407,24 +708,68 @@ const AgentUI = (props) => {
maxWidth: 100,
display: "flex",
}}>
<Tooltip title="Rerun JUST this decision. This can be used if an agent decision action somehow stopped and didn't get a result." placement="left">
<span>
<IconButton
disabled={item.type !== "decision" || disableButtons}
style={{marginLeft: 20, }}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
{item.category === "ask" ?
<span style={{display: "flex", }}>
{rerunButton}
{/*
<Tooltip title="Approve" placement="left">
<span>
<IconButton
disabled={disableButtons}
style={{marginLeft: 20, }}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
toast.info("Attempting to rerun this decision by itself.")
setDisableButtons(true)
RerunDecision(item.details)
}}
>
<RestartAltIcon />
</IconButton>
toast.info("Approving this step.")
}}
>
<CheckIcon style={{color: green, }} />
</IconButton>
</span>
</Tooltip>
<Tooltip title="Deny" placement="left">
<span>
<IconButton
disabled={item.type !== "decision" || disableButtons}
style={{marginLeft: 0, }}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
toast.info("Stopping on this step.")
}}
>
<CloseIcon style={{color: red, }} />
</IconButton>
</span>
</Tooltip>
*/}
<Tooltip title="See in another window" placement="left">
<span>
<IconButton
style={{marginLeft: 0, }}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
//http://localhost:3002/forms/aadfe022-fe93-431c-8634-de42dd7440ac?authorization=9357f6a6-7d59-44be-ad66-be27657369ac&reference_execution=0726378d-b501-470f-b850-f7fb48cd8ca4&source_node=de446bcf-ad37-4337-9f72-e069c7425fac&backend_url=https://ec4245cd2941.ngrok-free.app
const newurl = `/forms/${execution?.workflow?.id}?authorization=${execution.authorization}&reference_execution=${execution.execution_id}&source_node=${agentActionResult?.action?.id}&decision_id=${item.details.run_details.id}&backend_url=${globalUrl}`
window.open(newurl, '_blank', 'noopener,noreferrer');
}}
>
<OpenInNewIcon />
</IconButton>
</span>
</Tooltip>
</span>
</Tooltip>
:
item.category === "agent" ?
rerunAgentButton
:
rerunButton
}
<Tooltip title="Explore results" placement="right">
<span>
<IconButton
@@ -442,6 +787,69 @@ const AgentUI = (props) => {
</Tooltip>
</div>
</div>
{showAuthentication && selectedApp.id !== undefined ?
<div style={{minWidth: 300, maxWidth: 300, margin: "auto", marginTop: 25, }}>
<AuthenticationModal
globalUrl={globalUrl}
userdata={userdata}
setAppAuthentication={setAppAuth}
selectedAppData={selectedApp}
/>
</div>
: null}
{questions?.length > 0 && item?.status === "RUNNING" ?
<div>
{questions.map((q, questionIndex) => {
return (
<div style={{marginTop: 25, }}>
<Typography variant="body2">
{`${q.question}`}
</Typography>
<TextField
label={`Question ${q.index}`}
placeholder="No question found"
variant="outlined"
style={{width: 800, marginTop: 20, }}
multiline
minRows={2}
defaultValue={questionAnswers[q.question]?.value || ""}
onBlur={(e) => {
console.log("Change: ", e.target.value)
try {
questionAnswers[q.question] = {
"index": questionIndex,
"value": e.target.value,
}
setQuestionAnswers({...questionAnswers, })
} catch (e) {
toast.warn("Something went wrong. Please contact support@shuffler.io. Details: " + e)
}
}}
/>
</div>
)
})}
<Button
variant="contained"
style={{marginTop: 10, }}
disabled={questionSubmitDisabled}
onClick={() => {
submitQuestions(item?.details?.run_details?.id, questionAnswers)
}}
>
Submit
</Button>
</div>
: null}
{open ?
<div style={{marginTop: 10, marginBottom: 10, }}>
@@ -479,7 +887,12 @@ const AgentUI = (props) => {
const TimelineRender = (props) => {
const { agent_data } = props;
const actionResult = execution?.results?.length > 0 ? execution.results[0] : execution
var actionResult = execution?.results?.length > 0 ? execution.results[0] : execution
const validate = validateJson(actionResult?.result)
if (validate.valid === true) {
actionResult.result = validate.result
}
var timelineItems = [
{
"label": "AI Agent 2",
@@ -493,6 +906,27 @@ const AgentUI = (props) => {
},
]
// Setting up the initial item
if (agent_data?.started_at === undefined && execution?.started_at !== undefined) {
timelineItems[0].start_time = execution?.started_at
}
if (agent_data?.completed_at === undefined && execution?.completed_at !== undefined) {
timelineItems[0].end_time = execution?.completed_at
}
// Always prioritise the execution status first
// agent (RUNNING) = workflow (EXECUTING)
if (execution?.status !== undefined) {
timelineItems[0].status = execution?.status
}
if (actionResult?.result?.status !== undefined && actionResult?.result?.status !== null && actionResult?.result?.status?.length > 0) {
if (timelineItems[0].status !== "FINISHED" && timelineItems[0].status !== "ABORTED" && timelineItems[0].status !== "FAILURE") {
timelineItems[0].status = actionResult?.result?.status
}
}
// Autofixer for result lol
if ((agent_data?.decisions === undefined || agent_data?.decisions === null)) {
const verifiedInput = validateJson(actionResult?.result)
@@ -500,6 +934,7 @@ const AgentUI = (props) => {
agent_data.decisions = verifiedInput.result?.decisions
setAgentActionResult(actionResult)
}
}
@@ -516,13 +951,13 @@ const AgentUI = (props) => {
}
var newTimelineItem = {
"label": item.action,
"label": item?.action,
"type": "decision",
"category": item.category,
"category": item?.category,
"status": item.run_details.status,
"start_time": item.run_details.started_at,
"end_time": item.run_details.completed_at,
"status": item?.run_details?.status,
"start_time": item?.run_details?.started_at,
"end_time": item?.run_details?.completed_at,
}
newTimelineItem.details = item
@@ -577,6 +1012,14 @@ const AgentUI = (props) => {
setAgentRequestLoading(true)
//setShowAgentStarter(false);
//GetExecution(execution?.execution_id, execution?.node_id, execution?.authorization);
//
setData({})
setExecution(null)
setAgentRequestLoading(true)
setShowAgentStarter(true)
setActionInput(inputText)
setAgentActionResult(null)
if (inputText === undefined || inputText === null || inputText === "") {
toast.error("Please provide a valid input for the AI Agent.")
@@ -606,7 +1049,7 @@ const AgentUI = (props) => {
},
{
"name":"action",
"value":"list_tickets"
"value":"list_tickets,API"
}
]}
@@ -637,18 +1080,38 @@ const AgentUI = (props) => {
}
const handleKeyDown = (e) => {
const isCmdEnter = e.metaKey && e.key === "Enter"; // macOS
const isCtrlEnter = e.ctrlKey && e.key === "Enter"; // Windows/Linux
if (isCmdEnter || isCtrlEnter) {
e.preventDefault()
submitInput(actionInput)
}
}
return (
<div style={agentWrapperStyle}>
<TextField
id="copy_element_shuffle"
style={{ display: "none" }}
/>
{showAgentStarter ?
<Box component="form" style={{textAlign: "center", }} onSubmit={(e) => {
e.preventDefault();
submitInput(actionInput);
}}>
<Box
component="form"
style={{textAlign: "center", }}
onKeyDown={handleKeyDown}
onSubmit={(e) => {
e.preventDefault();
submitInput(actionInput);
}}
>
<img src="/images/logos/agent.svg" style={{
width: 200,
height: 200,
borderRadius: theme.palette.borderRadius,
}} />
<div />
<Typography variant="h5" style={{marginTop: 30, }}>
@@ -661,7 +1124,7 @@ const AgentUI = (props) => {
style={{width: 450, marginRight: 20, marginTop: 30, }}
multiline
minRows={2}
defaultValue={execution?.execution_id || ""}
defaultValue={actionInput || ""}
onChange={(e) => {
setActionInput(e.target.value)
}}
@@ -704,6 +1167,22 @@ const AgentUI = (props) => {
</Button>
</ButtonGroup>
<Tooltip title="Reload the agent data" placement="top">
<span>
<Button
disabled={execution === null || Object.keys(execution).length === 0}
style={{marginLeft: 25, }}
variant={"outlined"}
color="secondary"
onClick={() => {
GetExecution(execution.execution_id, agentActionResult.action.id, execution.authorization)
}}
>
<RefreshIcon />
</Button>
</span>
</Tooltip>
{buttonState === "timeline" ?
<TimelineRender agent_data={data} />
:
+53 -14
View File
@@ -22,6 +22,7 @@ import { CodeHandler, Img, OuterLink, } from "../views/Docs.jsx";
import { InstantSearch, Configure, connectSearchBox, connectHits, Index } from 'react-instantsearch-dom';
import algoliasearch from 'algoliasearch/lite';
import useDebouncedCallback from "../utils/useDebouncedCallback.js";
import {
Zoom,
Fade,
@@ -275,7 +276,7 @@ export const triggers = [
{
"name": "alertinfo",
"example": "",
"value": "Do you want to continue the workflow? Start parameters: $exec",
"value": "## Stop or continue?\n\nDetails: $exec",
},
{
"name": "options",
@@ -1259,6 +1260,24 @@ const AngularWorkflow = (defaultprops) => {
"multiline": true,
}]
},
/*
// An attempt at handling APIs directly. This ~kind of works
{
"name": "API",
"description": "Attempts to take your fields and run an API call with them, whatever they are",
"label": "Custom Action",
"example": "{\"source_data\": \"{\\\"event\\\": \\\"login\\\", \\\"user\\\": \\\"john_doe\\\", \\\"timestamp\\\": \\\"2023-10-01T12:00:00Z\\\"}\", \"standard\": \"OCSF\"}",
"parameters": [
{
"name": "fields",
"value": "",
"description": "A JSON object with the fields to send to the API. Example: {\"url\": \"hello\", \"key2\": \"value2\"}",
"required": true,
"multiline": true,
}
]
},
*/
{
"name": "Translate standard",
"description": "Translates your JSON data into a standard formats, then stores it in the Shuffle Datastore",
@@ -11589,10 +11608,16 @@ const AngularWorkflow = (defaultprops) => {
};
const handleDragStop = (e, app) => {
if (cy === undefined || cy == null) {
console.log("Cytoscape not initialized")
return
}
var currentnode = cy.getElementById(newNodeId);
if (currentnode === undefined || currentnode === null || currentnode.length === 0) {
return;
console.log("No current node found")
return
}
if (parsedApp === undefined || parsedApp === null || parsedApp.data === undefined || parsedApp.data === null) {
@@ -12411,11 +12436,20 @@ const AngularWorkflow = (defaultprops) => {
};
const SearchBox = ({ currentRefinement, refine, isSearchStalled, }) => {
const debouncedRefine = useDebouncedCallback(refine, 500)
const lastRefinedRef = useRef(currentRefinement)
const safeRefine = (value) => {
if (value === lastRefinedRef.current) return
lastRefinedRef.current = value
debouncedRefine(value)
}
if (document !== undefined) {
const appsearchValue = document.getElementById("appsearch")
if (appsearchValue !== undefined && appsearchValue !== null) {
if (appsearchValue.value !== undefined && appsearchValue.value !== null && appsearchValue.value.length > 0) {
refine(appsearchValue.value)
safeRefine(appsearchValue.value)
}
}
}
@@ -12448,8 +12482,7 @@ const AngularWorkflow = (defaultprops) => {
//if (event.currentTarget.value.length > 0 && !searchOpen) {
// setSearchOpen(true)
//}
refine(event.currentTarget.value)
safeRefine(event.currentTarget.value)
}}
limit={5}
/>
@@ -14725,7 +14758,7 @@ const AngularWorkflow = (defaultprops) => {
zIndex: 10000,
}}
>
Conditions can't be used for loops [ .# ]{" "}
<b>PS: Conditions can't be used for loops [ .# ]. Use the filters list action.{" "}</b>
<a
rel="noopener noreferrer"
target="_blank"
@@ -20236,17 +20269,23 @@ const AngularWorkflow = (defaultprops) => {
const shownErrors = !isMobile && workflow.errors !== undefined && workflow.errors !== null && workflow.errors.length > 0 && showErrors && (!workflow.public || userdata.support === true) ?
<div
style={{
border: theme.palette.DialogStyle.border,
position: "absolute",
bottom: 100,
left: leftSideBarOpenByClick ? leftBarSize + 270 : leftBarSize + 115,
style={{
border: theme.palette.DialogStyle.border,
position: "absolute",
bottom: 100,
left: leftSideBarOpenByClick ? leftBarSize + 270 : leftBarSize + 115,
width: "fit-content",
maxWidth: "45vw",
minWidth: 300,
color: theme.palette.DialogStyle.color,
padding: 10,
borderRadius: theme.palette?.borderRadius,
transition: "left 0.3s ease, top 0.3s ease",
}}
overflowWrap: "anywhere",
wordBreak: "break-word",
whiteSpace: "pre-wrap",
}}
>
<Tooltip
@@ -22726,12 +22765,12 @@ const AngularWorkflow = (defaultprops) => {
style={{ float: "right", marginTop: 20, }}
// Max 5 days in the past
disabled={userdata.region_url !== "https://shuffler.io" || executionData.started_at < (Math.floor(Date.now() / 1000) - 432000)}
disabled={executionData.started_at < (Math.floor(Date.now() / 1000) - 432000)}
onClick={() => {
toast("Opening logs in a new tab")
setTimeout(() => {
window.open(`/api/v1/workflows/search/${executionData.execution_id}`, "_blank")
window.open(`${globalUrl}/api/v1/workflows/search/${executionData.execution_id}`, "_blank")
}, 250)
}}
>
+7 -7
View File
@@ -1494,13 +1494,13 @@ const ApiExplorerWrapper = (props) => {
/>
<div
style={{
backgroundColor: theme.palette.inputColor,
padding: 15,
borderRadius: theme.palette?.borderRadius,
marginBottom: 30,
}}
>
style={{
backgroundColor: theme.palette.inputColor,
padding: 15,
borderRadius: theme.palette?.borderRadius,
marginBottom: 30,
}}
>
<Typography variant="h6" style={{marginBottom: 25, }}>
There is no Shuffle-specific documentation for this app yet outside of the general description above. Documentation is written for each api, and is a community effort. We hope to see your contribution!
</Typography>
+103 -107
View File
@@ -8,6 +8,7 @@ import {
Typography,
FormControlLabel,
Button,
ButtonGroup,
Divider,
Select,
MenuItem,
@@ -2680,11 +2681,11 @@ const AppCreator = (defaultprops) => {
setErrorCode(responseJson.reason);
if (responseJson?.details !== undefined && responseJson?.details !== null) {
toast.error("Failed to build - contact support@shuffler.io: " + responseJson.details, {
toast.error("Failed to build - contact support@shuffler.io:\n\n" + responseJson.details, {
autoClose: 60000
})
} else {
toast.error("Failed to build: " + responseJson.reason, {
toast.error("Failed to build: \n\n" + responseJson?.reason, {
autoClose: 10000
})
}
@@ -2930,7 +2931,7 @@ const AppCreator = (defaultprops) => {
Query
</MenuItem>
</Select>
<div style={{ display: "flex", width: 100 }}>
<ButtonGroup style={{ display: "flex", width: 100 }}>
{index === extraAuth.length - 1 ? (
<Button
color="primary"
@@ -2963,7 +2964,7 @@ const AppCreator = (defaultprops) => {
>
<RemoveIcon style={{}} />
</Button>
</div>
</ButtonGroup>
</span>
);
})}
@@ -3431,13 +3432,13 @@ const AppCreator = (defaultprops) => {
const ActionPaper = (props) => {
const { data, index } = props
const [updater, setUpdater] = useState("tmp");
const [actionsModalOpen, setActionsModalOpen] = useState(false);
const [urlPath, setUrlPath] = useState("");
const [fileUploadEnabled, setFileUploadEnabled] = useState(false);
const [currentActionMethod, setCurrentActionMethod] = useState(actionNonBodyRequest[0])
const [extraBodyFields, setExtraBodyFields] = useState([]);
const [urlPathQueries, setUrlPathQueries] = useState([]);
const [updater, setUpdater] = useState("tmp");
const [actionsModalOpen, setActionsModalOpen] = useState(false);
const [urlPath, setUrlPath] = useState("");
const [fileUploadEnabled, setFileUploadEnabled] = useState(false);
const [currentActionMethod, setCurrentActionMethod] = useState(actionNonBodyRequest[0])
const [extraBodyFields, setExtraBodyFields] = useState([]);
const [urlPathQueries, setUrlPathQueries] = useState([]);
const [currentAction, setCurrentAction] = useState({
name: "",
file_field: "",
@@ -3454,6 +3455,10 @@ const AppCreator = (defaultprops) => {
required_bodyfields: [],
});
useEffect(() => {
console.log("Queries: ", urlPathQueries)
}, [urlPathQueries])
const findBodyParams = (body) => {
const regex = /\${(\w+)}/g;
const found = body.match(regex);
@@ -3462,7 +3467,7 @@ const AppCreator = (defaultprops) => {
} else {
setExtraBodyFields(found);
}
};
};
const UrlPathParameters = () => {
const values = getCurrentPaths(urlPath);
@@ -3495,28 +3500,27 @@ const AppCreator = (defaultprops) => {
) : null;
};
const HandleIndividualChip = (props) => {
const { chipData, index } = props;
const [chipRequired, setChipRequired] = useState(currentAction.required_bodyfields !== undefined ? currentAction.required_bodyfields.includes(chipData) : false);
const { chipData, index } = props;
const [chipRequired, setChipRequired] = useState(currentAction.required_bodyfields !== undefined ? currentAction.required_bodyfields.includes(chipData) : false);
const parsedChip = chipData.startsWith("${") && chipData.endsWith("}") ? chipData.substring(2, chipData.length - 1) : chipData
return (
<Tooltip title={chipRequired ? "Make not required" : "Make required"}>
<Chip
style={{
backgroundColor: chipRequired ? "#f86a3e" : theme.palette.chipStyle.backgroundColor,
height: 30,
margin: 3,
paddingLeft: 5,
paddingRight: 5,
cursor: "pointer",
borderColor: theme.palette.chipStyle.borderColor,
color: theme.palette.chipStyle.color,
}}
label={parsedChip}
onClick={() => {
return (
<Tooltip title={chipRequired ? "Make not required" : "Make required"}>
<Chip
style={{
backgroundColor: chipRequired ? "#f86a3e" : theme.palette.chipStyle.backgroundColor,
height: 30,
margin: 3,
paddingLeft: 5,
paddingRight: 5,
cursor: "pointer",
borderColor: theme.palette.chipStyle.borderColor,
color: theme.palette.chipStyle.color,
}}
label={parsedChip}
onClick={() => {
if (chipRequired) {
currentAction["required_bodyfields"].splice(currentAction["required_bodyfields"].indexOf(chipData), 1)
} else {
@@ -3524,27 +3528,28 @@ const AppCreator = (defaultprops) => {
}
setCurrentAction(currentAction);
setChipRequired(!chipRequired);
}}
/>
</Tooltip>
);
};
const setActionField = (field, value) => {
currentAction[field] = value
setCurrentAction(currentAction)
//setUrlPathQueries(currentAction.queries)
setChipRequired(!chipRequired);
}}
/>
</Tooltip>
);
};
const addPathQuery = () => {
const setActionField = (field, value) => {
currentAction[field] = value
setCurrentAction(currentAction)
//setUrlPathQueries(currentAction.queries)
};
const addPathQuery = () => {
urlPathQueries.push({ name: "", required: true, example: "", });
if (updater === "addupdater") {
setUpdater("updater");
} else {
setUpdater("addupdater");
}
setUrlPathQueries(urlPathQueries);
};
@@ -3555,6 +3560,7 @@ const AppCreator = (defaultprops) => {
} else {
setUpdater("flipupdater");
}
setUrlPathQueries(urlPathQueries);
};
@@ -3573,7 +3579,7 @@ const AppCreator = (defaultprops) => {
}
};
const loopQueries = urlPathQueries.length === 0 ? null : (
const loopQueries = urlPathQueries.length === 0 ? null : (
<div>
<Divider
style={{
@@ -3591,51 +3597,42 @@ const AppCreator = (defaultprops) => {
return (
<Paper key={queryIndex} style={actionListStyle}>
<div style={{ marginLeft: "5px", width: "100%" }}>
<div style={{display: "flex"}}>
<TextField
required
fullWidth={true}
defaultValue={query.name}
placeholder={"Query name (key)"}
label={"Query Key"}
helperText={
<span style={{ color: theme.palette.text.primary, marginBottom: "2px" }}>
Click required to flip
</span>
}
onBlur={(e) => {
console.log("IN BLUR: ", e.target.value);
urlPathQueries[queryIndex].name = e.target.value.replaceAll("=", "");
setUrlPathQueries(urlPathQueries);
}}
style={{flex: 3}}
InputProps={{
style: {
color: theme.palette.text.primary,
},
}}
/>
<TextField
fullWidth={true}
defaultValue={query.example}
placeholder={"Default value"}
label={"Example"}
onBlur={(e) => {
urlPathQueries[queryIndex].example = e.target.value.replaceAll(
"=",
""
)
setUrlPathQueries(urlPathQueries)
}}
style={{flex: 2}}
InputProps={{
style: {
color: theme.palette.text.primary,
},
}}
/>
</div>
<div style={{display: "flex"}}>
<TextField
required
fullWidth={true}
defaultValue={query.name}
placeholder={"Query name (key)"}
label={"Query Key"}
onBlur={(e) => {
urlPathQueries[queryIndex].name = e.target.value.replaceAll("=", "")
setUrlPathQueries(urlPathQueries)
}}
style={{flex: 3}}
InputProps={{
style: {
color: theme.palette.text.primary,
},
}}
/>
<TextField
fullWidth={true}
defaultValue={query.example}
placeholder={"Default value"}
label={"Example"}
onBlur={(e) => {
// E.g. for Jira -> JQL -> requires = in param
urlPathQueries[queryIndex].example = e.target.value.replaceAll("=","=")
setUrlPathQueries(urlPathQueries)
}}
style={{flex: 2}}
InputProps={{
style: {
color: theme.palette.text.primary,
},
}}
/>
</div>
<div
style={{ cursor: "pointer" }}
onClick={() => {
@@ -3654,7 +3651,7 @@ const AppCreator = (defaultprops) => {
deletePathQuery(queryIndex);
}}
>
<DeleteIcon />
<DeleteIcon />
</div>
</Paper>
);
@@ -4106,22 +4103,22 @@ const AppCreator = (defaultprops) => {
if (request.header !== undefined && request.header !== null) {
var headers = [];
for (let [key, value] of Object.entries(request.header)) {
if (value === undefined) {
if (key.includes(":")) {
const keysplit = key.split(":")
key = keysplit[0].trim()
value = keysplit[1].trim()
if (value === undefined) {
if (key.includes(":")) {
const keysplit = key.split(":")
key = keysplit[0].trim()
value = keysplit[1].trim()
} else if (key.includes("=")) {
const keysplit = key.split("=")
key = keysplit[0].trim()
value = keysplit[1].trim()
} else if (key.includes("=")) {
const keysplit = key.split("=")
key = keysplit[0].trim()
value = keysplit[1].trim()
} else {
toast("Removed key: ", key)
continue
}
}
} else {
toast("Removed key: ", key)
continue
}
}
if (
parameterName !== undefined &&
@@ -4392,9 +4389,8 @@ const AppCreator = (defaultprops) => {
variant={urlPath.length > 0 ? "contained" : "outlined"}
style={{ }}
onClick={() => {
//console.log(urlPathQueries)
//console.log(urlPath)
console.log(currentAction);
const errors = getActionErrors();
addActionToView(errors);
setActionsModalOpen(false);
@@ -4460,7 +4456,7 @@ const AppCreator = (defaultprops) => {
return (
<Paper key={index} style={actionListStyle}>
{newActionModal}
{newActionModal}
{error}
<Tooltip title="Edit action" placement="bottom">
+4 -4
View File
@@ -3075,7 +3075,7 @@ const buttonBackground = "linear-gradient(to right, #f86a3e, #f34079)";
const appEnding = app?.public === true ? app?.app_version : app?.id
return `curl -L \ \\\n "${globalUrl}/api/v1/download_docker_image?image=frikky/shuffle:${app?.name.toLowerCase().replaceAll(' ', '_')}_${appEnding}" \\\n -H \"Authorization: Bearer APIKEY" \\\n -o image.zip; \\\n docker load -i image.zip`
return `curl -L \ \\\n "${globalUrl}/api/v1/download_docker_image?image=frikky/shuffle:${app?.name.toLowerCase().replaceAll(' ', '_')}_${appEnding}" \\\n -H \"Authorization: Bearer APIKEY" \\\n -o image.zip; \\\n docker load -i image.zip${!app?.public ? ` \\\n docker tag frikky/shuffle:${app?.name.toLowerCase().replaceAll(' ', '_')}_${appEnding} frikky/shuffle:${app?.name.toLowerCase().replaceAll(' ', '_')}_${app.app_version}` : ``}`
}
const renderedActionOptions = deduplicateByName((
@@ -3405,7 +3405,7 @@ const buttonBackground = "linear-gradient(to right, #f86a3e, #f34079)";
<div style={{ textAlign: "center", marginTop: 25 }}>
<Link
rel="noopener noreferrer"
to={`/register?app_one=${app.name}&app_two=${secondaryApp.name}&message=You need to login first to connect ${app.name} and ${secondaryApp.name}`}
to={`/register?app_one=${app.name}&app_two=${secondaryApp.name}&message=You need to login first to connect ${app.name} and ${secondaryApp.name}&view=/apps/${params.appid}/integrations/${secondaryApp.name}`}
style={{ textDecoration: "none" }}
>
<Button
@@ -4300,8 +4300,8 @@ const buttonBackground = "linear-gradient(to right, #f86a3e, #f34079)";
color="primary"
onClick={() => {
if (!isLoggedIn) {
//navigate("/login?message=You must be logged in to activate this app&view=/apps/" + params.appid);
toast("You must be logged in to activate apps! Go to /login first.")
navigate("/login?message=You must be logged in to activate this app&view=/apps/" + params.appid);
// toast("You must be logged in to activate apps! Go to /login first.")
return;
}
+53
View File
@@ -42,6 +42,7 @@ import { debounce } from "lodash";
import AppSelection from "../components/AppSelection.jsx";
import AppModal from "../components/AppModal.jsx";
import AppCreationModal from "../components/AppCreationModal.jsx";
import Dropzone from "../components/Dropzone.jsx";
const searchClient = algoliasearch(
@@ -1136,6 +1137,7 @@ const Apps2 = (props) => {
const [field2, setField2] = useState("");
const [validation, setValidation] = useState(null);
const [createAppModalOpen, setCreateAppModalOpen] = useState(false);
const [openApiData, setOpenApiData] = useState("");
const {themeMode, brandColor} = useContext(Context);
const theme = getTheme(themeMode, brandColor);
@@ -1736,6 +1738,31 @@ const Apps2 = (props) => {
// setOpenModal(true);
};
const uploadFile = (e) => {
const isFromDropzone = e.dataTransfer === undefined ? false : e.dataTransfer.files.length > 0;
const files = isFromDropzone ? e.dataTransfer.files : e.target.files;
const reader = new FileReader();
try {
reader.addEventListener("load", (ev) => {
const content = ev.target.result;
setOpenApiData(content);
setCreateAppModalOpen(true);
});
} catch (err) {
console.log("Error in dropzone: ", err);
}
try {
reader.readAsText(files[0]);
} catch (error) {
toast("Failed to read file");
}
};
// Validation and redirect are handled inside AppCreationModal
useEffect(() => {
const apps = currTab === 1 ? userApps : orgApps;
const filteredUserAppdata = filterApps(apps, searchQuery, selectedCategory, selectedLabel);
@@ -1853,6 +1880,10 @@ const Apps2 = (props) => {
}
return (
<Dropzone
style={{ width: "100%", height: "100vh" }}
onDrop={uploadFile}
>
<div style={{ paddingTop: 70, paddingLeft: leftSideBarOpenByClick ? 200 : 0, transition: "padding-left 0.3s ease", backgroundColor: theme.palette.backgroundColor, fontFamily: theme?.typography?.fontFamily, zoom: 0.7, }}>
<InstantSearch searchClient={searchClient} indexName="appsearch">
<AppModal
@@ -1869,6 +1900,8 @@ const Apps2 = (props) => {
theme={theme}
globalUrl={globalUrl}
isCloud={isCloud}
startOpenApi={openApiData?.length > 0}
prefillOpenApiData={openApiData}
/>
{appsModalLoad}
<div style={boxStyle}>
@@ -2198,6 +2231,24 @@ const Apps2 = (props) => {
</>
)}
</div>
<Tooltip
title="Create an app with different options or Just drop a YAML/JSON file here"
placement="top"
componentsProps={{
tooltip: {
sx: {
backgroundColor: "rgba(33, 33, 33, 1)",
color: "rgba(241, 241, 241, 1)",
fontSize: 12,
width: 240,
lineHeight: 1.5,
border: "1px solid rgba(73, 73, 73, 1)",
fontFamily: theme?.typography?.fontFamily,
}
},
}}
arrow
>
<div style={{
width: "25%",
minWidth: "25%",
@@ -2222,6 +2273,7 @@ const Apps2 = (props) => {
Create an App
</Button>
</div>
</Tooltip>
</div>
<div>
@@ -2365,6 +2417,7 @@ const Apps2 = (props) => {
<Configure clickAnalytics />
</InstantSearch>
</div>
</Dropzone>
);
};
+9 -4
View File
@@ -8,7 +8,7 @@ import { useNavigate, Link, useParams } from "react-router-dom";
import { ToastContainer, toast } from "react-toastify"
import Draggable from "react-draggable";
import DashboardBarchart, { LoadStats } from '../components/DashboardBarchart.jsx';
import { LoadStats } from '../components/LineChartWrapper.jsx';
import {
Autocomplete,
@@ -828,9 +828,14 @@ const Dashboard = (props) => {
}
</div>
<DashboardBarchart
timelineData={data}
height={50}
<LineChartWrapper
inputname={"heyo"}
keys={data}
height={100}
width={100}
border={false}
color={"#808080"}
/>
</Paper>
+6 -1
View File
@@ -400,6 +400,10 @@ const Docs = (defaultprops) => {
if (propkey === "app_creation") {
navigate('/docs/apps#app-creation-introduction')
}
if (propkey === "api") {
navigate('/docs/API')
}
}
@@ -690,7 +694,8 @@ const Docs = (defaultprops) => {
const Heading = (props) => {
const [hover, setHover] = useState(false);
var id = props.children[0].toLowerCase().toString()
var id = (props.children?.[0] ?? props.children ?? '').toString().toLowerCase();
if (props.level <= 3) {
id = props.children[0].toLowerCase().toString().replaceAll(" ", "-");
}
+266
View File
@@ -0,0 +1,266 @@
import React, { useEffect, useState, useContext, useRef, useCallback } from 'react';
import {
Typography,
Grid,
Paper,
Box,
Stack,
Chip,
Avatar,
Divider,
Select,
MenuItem,
} from '@mui/material';
import TrendingUpIcon from '@mui/icons-material/TrendingUp';
import TrendingDownIcon from '@mui/icons-material/TrendingDown';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import TaskAltIcon from '@mui/icons-material/TaskAlt';
import SuccessFailedRunsWidget from '../components/SuccessFailedRunsWidget.jsx';
import RunsOverTimeWidget from '../components/RunsOverTimeWidget.jsx';
import { Context } from '../context/ContextApi.jsx';
import CircularProgress from '@mui/material/CircularProgress';
import { useNavigate } from 'react-router-dom';
import DashboardOnboarding from '../components/DashboardOnboarding.jsx';
const NewDashboard = (props) => {
const { globalUrl, userdata } = props;
// const [workflows, setWorkflows] = useState([]);
const { leftSideBarOpenByClick } = useContext(Context);
const [sfwControls, setSfwControls] = useState(null);
const [loadingSfw, setLoadingSfw] = useState(true);
const [loadingRot, setLoadingRot] = useState(true);
const [loadingNoti, setLoadingNoti] = useState(true);
const [showOverlay, setShowOverlay] = useState(true);
const [totals, setTotals] = useState({ days: 30, mode: 'workflows', totalRuns: 0, successRuns: 0, failedRuns: 0, activeDays: 0, timeSavedMinutes: 0, moneySavedDollars: 0 });
const [notifications, setNotifications] = useState([]);
const [onboardingOpen, setOnboardingOpen] = useState(() => {
try {
return localStorage.getItem("dashboard_onboarding_complete") === "true" ? false : true;
} catch {
return true;
}
});
const [overrideDays, setOverrideDays] = useState(undefined);
const [rotMonthOverride, setRotMonthOverride] = useState(undefined);
const navigate = useNavigate();
const handleSfwControls = useCallback((node) => {
setSfwControls(node);
}, []);
const formatCurrencyCompact = (value) => {
const n = Math.max(0, Number(value) || 0);
const abs = Math.abs(n);
const fmt = (x, suffix) => `${(Math.round(x * 10) / 10).toString().replace(/\.0$/, '')}${suffix}`;
if (abs >= 1e9) return `$${fmt(n / 1e9, 'B')}`;
if (abs >= 1e6) return `$${fmt(n / 1e6, 'M')}`;
if (abs >= 1e3) return `$${fmt(n / 1e3, 'k')}`;
return `$${Math.round(n).toLocaleString()}`;
};
const formatTimeDisplay = (mins) => {
const totalMins = Math.max(0, Math.round(mins || 0));
if (totalMins < 60) return { display: `${totalMins}m`, title: `${totalMins} minutes` };
const totalHours = Math.floor(totalMins / 60);
if (totalHours >= 24) {
const days = Math.floor(totalHours / 24);
return { display: `${days}d`, title: `${totalHours} hours` };
}
return { display: `${totalHours}h`, title: `${totalHours} hours` };
};
const timeFmt = formatTimeDisplay(totals.timeSavedMinutes);
const STATIC_TIME_PERCENT = '62%';
const STATIC_MONEY_PERCENT = '46%';
const unreadCount = notifications.filter(n => n && n.read === false).length;
const readCount = notifications.filter(n => n && n.read === true).length;
// Current values
// 1 Workflow run = 15 minutes
// 1 Workflow run = $25
const kpis = [
{ value: timeFmt.display, title: timeFmt.title, label: 'Time saved', icon: <TrendingUpIcon sx={{ color: '#5cc879', fontSize: 34 }} />, percentage: STATIC_TIME_PERCENT, color: '#5cc879' },
{ value: formatCurrencyCompact(totals.moneySavedDollars), label: 'Money saved', icon: <TrendingUpIcon sx={{ color: '#5cc879', fontSize: 34 }} />, percentage: STATIC_MONEY_PERCENT, color: '#5cc879' },
{ value: String(unreadCount), label: 'Total errors', icon: <ErrorOutlineIcon sx={{ color: '#f87171', fontSize: 34, opacity: 0.9 }} />, percentage: "", color: '#f87171' },
{ value: String(readCount), label: 'Errors resolved', icon: <TaskAltIcon sx={{ color: '#5cc879', fontSize: 34, opacity: 0.9 }} />, percentage: "", color: '#5cc879' },
];
const getGreeting = () => {
try {
const hour = new Date().getHours();
if (hour < 5) return 'Good night';
if (hour < 12) return 'Good morning';
if (hour < 18) return 'Good afternoon';
return 'Good evening';
} catch {
return 'Hey';
}
};
const displayName = userdata !== undefined && userdata?.username !== undefined ? userdata?.username?.split('@')[0]?.charAt(0)?.toUpperCase() + userdata?.username?.split('@')[0]?.slice(1) : 'User';
useEffect(() => {
let t;
const anyLoading = loadingSfw || loadingRot || loadingNoti;
if (anyLoading) {
t = setShowOverlay(true);
} else {
setShowOverlay(false);
}
return () => { if (t) clearTimeout(t); };
}, [loadingSfw, loadingRot, loadingNoti]);
// Auto-open onboarding when there aren't enough active days of stats
useEffect(() => {
try {
const alreadyDone = localStorage.getItem("dashboard_onboarding_complete") === "true";
if (alreadyDone) {
setOnboardingOpen(false);
return;
}
const active = Number(totals?.activeDays || 0);
setOnboardingOpen(active < 5);
} catch {
setOnboardingOpen(true);
}
}, [totals?.activeDays]);
// Load notifications
useEffect(() => {
const loadNotifications = async () => {
try {
const resp = await fetch(`${globalUrl}/api/v1/notifications`, {
method: 'GET',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
});
if (resp.status !== 200) {
setNotifications([]);
return;
}
const data = await resp.json();
const list = Array.isArray(data?.notifications) ? data.notifications : (Array.isArray(data) ? data : []);
setNotifications(list.filter(Boolean));
} catch (e) {
setNotifications([]);
} finally {
setLoadingNoti(false);
}
};
loadNotifications();
}, [globalUrl]);
// useEffect(() => {
// // Lightweight workflows list for selector in success/failed widget
// const loadWorkflows = async () => {
// try {
// const resp = await fetch(`${globalUrl}/api/v1/workflows`, {
// method: 'GET',
// credentials: 'include',
// headers: { 'Content-Type': 'application/json' },
// });
// if (resp.status !== 200) {
// return;
// }
// const data = await resp.json();
// const list = Array.isArray(data?.workflows) ? data.workflows : (Array.isArray(data) ? data : []);
// const normalized = list.filter(Boolean).map((w, idx) => ({ id: w?.id || w?.ID || `${idx}`, name: w?.name || w?.Name || `Workflow ${idx+1}` }));
// setWorkflows(normalized);
// } catch (e) {
// // ignore
// }
// };
// loadWorkflows();
// }, [globalUrl]);
return (
<div style={{ maxWidth: 1366, margin: '0 auto', padding: 16, paddingTop: 50, paddingBottom: 30, paddingLeft: leftSideBarOpenByClick ? 270 : 80, transition: 'padding-left 0.3s ease', position: 'relative' }}>
<DashboardOnboarding
open={onboardingOpen}
globalUrl={globalUrl}
onClose={() => setOnboardingOpen(false)}
onExplore={() => {
// Ensure overrides are set before closing modal
setOverrideDays(5);
setRotMonthOverride(new Date(new Date().getFullYear(), new Date().getMonth(), 1));
// Close modal immediately to trigger data fetching
setOnboardingOpen(false);
}}
headerTitle="Unlock your Dashboard"
headerSubtitle="Complete these steps to start seeing insights."
/>
{showOverlay && (
<div style={{ position: 'absolute', inset: 0, background: 'rgba(17,17,17,0.6)', backdropFilter: 'blur(2px)', zIndex: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 12 }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12 }}>
<CircularProgress size={36} thickness={4} />
<Typography variant="body2" color="textSecondary">Loading dashboard</Typography>
</div>
</div>
)}
{/* Header / Greeting */}
<Box style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', margin: '8px 0 16px 0' }}>
<Typography variant="h5">{`${getGreeting()}, ${displayName ?? 'User'}!`}</Typography>
<>
{sfwControls}
</>
</Box>
{/* KPI cards */}
<Grid container spacing={2}>
{kpis.map((kpi) => (
<Grid item xs={12} sm={6} md={3} key={kpi.label}>
<Paper style={{ padding: 16, background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 12
,cursor: kpi.label.toLowerCase().includes('total errors') ? 'pointer' : 'default'
}}
onClick={() => {
if (kpi.label.toLowerCase().includes('total errors')) {
// navigate to notifications page
navigate('/admin?admin_tab=notifications');
}
}}
>
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Stack sx={{py:2, paddingLeft: 1}}>
<Typography variant="h4" title={kpi.title || ''}>{kpi.value}</Typography>
<Typography sx={{fontSize: 13}} color="textSecondary">{kpi.label}</Typography>
</Stack>
<Stack sx={{py: 2, paddingRight: 1, marginTop: kpi.label.toLowerCase().includes('errors') ? -1 : 0}}>
{kpi.icon}
<Typography variant="body2" color={kpi.color}>{kpi.percentage}</Typography>
</Stack>
</Stack>
</Paper>
</Grid>
))}
</Grid>
{/* Success/Failed widget uses its own internal sub-cards; make wrapper transparent */}
<Paper elevation={0} style={{ padding: 0, marginTop: 5, background: 'transparent', boxShadow: 'none', border: 'none' }}>
<SuccessFailedRunsWidget
globalUrl={globalUrl}
overrideDays={overrideDays}
dummyMode={onboardingOpen}
onControlsChange={handleSfwControls}
onLoadingChange={setLoadingSfw}
onTotalsChange={setTotals}
/>
</Paper>
{/* Runs over time section */}
<Paper style={{ padding: 16, marginTop: 19, background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 12 }}>
<RunsOverTimeWidget globalUrl={globalUrl} onLoadingChange={setLoadingRot} monthOverride={rotMonthOverride} dummyMode={onboardingOpen} />
</Paper>
</div>
);
};
export default NewDashboard;
+165 -52
View File
@@ -72,6 +72,7 @@ const RunWorkflow = (defaultprops) => {
const [executionLoading, setExecutionLoading] = useState(false);
const [executionData, setExecutionData] = React.useState({});
const [executionRunning, setExecutionRunning] = useState(false);
const [disableButtons, setDisableButtons] = useState(false);
const [workflowQuestion, setWorkflowQuestion] = useState("");
const [selectedOrganization, setSelectedOrganization] = React.useState(undefined);
const [apps, setApps] = React.useState([]);
@@ -84,12 +85,14 @@ const RunWorkflow = (defaultprops) => {
const [workflows, setWorkflows] = React.useState([])
const [boxWidth, setBoxWidth] = React.useState(500)
const [inputQuestions, setInputQuestions] = React.useState([])
const [agentic, setAgentic] = React.useState(false)
const searchParams = new URLSearchParams(window.location.search)
const answer = searchParams.get("answer")
const execution_id = searchParams.get("reference_execution")
const authorization = searchParams.get("authorization")
const sourceNode = searchParams.get("source_node")
const decisionId = searchParams.get("decision_id") // ONLY for agentic workflows
const backendUrl = searchParams.get("backend_url") || globalUrl
useEffect(() => {
@@ -162,11 +165,8 @@ const RunWorkflow = (defaultprops) => {
}
}
// Used to swap from login to register. True = login, false = register
// Error messages etc
const [executionInfo, setExecutionInfo] = useState("");
const handleValidateForm = (executionArgument) => {
// Check if every field exists
if (executionArgument === undefined || executionArgument === null) {
@@ -184,9 +184,12 @@ const RunWorkflow = (defaultprops) => {
}
}
//console.log("EXEC: ", executionArgument)
// FIXME: Error with User Input + Required arg (?)
// Somehow validation is not happening as it should, and it just checks all
// questions if none are selected
for (var key in executionArgument) {
if (executionArgument[key] === undefined || executionArgument[key] === null || executionArgument[key] === "") {
console.log("Unanswered, required question: ", key)
return false
}
}
@@ -334,17 +337,18 @@ const RunWorkflow = (defaultprops) => {
}
const validate = validateJson(executionData.result)
return (
<div style={{marginTop: executionMargin, }}>
{workflowQuestion !== "" ? null :
<Divider style={{marginTop: 20, marginBottom: 20, }}/>
<div style={{marginTop: 20, marginBottom: 20, }}/>
}
{workflowQuestion !== "" ? null :
validate.valid === false ?
<div style={{marginTop: 20, }}>
<Divider />
{validate?.result !== undefined && validate?.result !== null && validate?.result.length > 0 ?
<Divider />
: null }
<Markdown
components={{
img: Img,
@@ -397,10 +401,13 @@ const RunWorkflow = (defaultprops) => {
stop()
setMessage("")
setExecutionLoading(true)
setExecutionData({})
setExecutionInfo("")
setTimeout(() => {
setExecutionLoading(true)
}, 2500)
var data = {
"execution_argument": executionArgument,
"execution_source": "form",
@@ -462,6 +469,14 @@ const RunWorkflow = (defaultprops) => {
fetchBody.body = JSON.stringify(data)
}
if (agentic === true) {
if (url.includes("?")) {
url += `&agentic=true&decision_id=${decisionId}`
} else {
url += `?agentic=true&decision_id=${decisionId}`
}
}
// IF there is an execution argument, we should use it
fetch(url, fetchBody)
.then((response) => {
@@ -480,25 +495,30 @@ const RunWorkflow = (defaultprops) => {
}
}
if ((response.status === 401 || response.status === 403) && authorization === undefined || authorization === null || authorization.length === 0) {
toast(`This form is not available for you to run. If you this is an error, contact ${supportEmail} with a link to this form`)
}
//if ((response.status === 401 || response.status === 403) && authorization === undefined || authorization === null || authorization?.length === 0) {
// toast(`This form is not available for you to run. If you this is an error, contact ${supportEmail} with a link to this form (2)`)
//}
return response.json()
})
.then(responseJson => {
//if (responseJson.success === true) {
// setDisableButtons(true)
//}
setExecutionLoading(false)
if (responseJson.execution_id !== undefined && responseJson.execution_id !== null && responseJson.execution_id.length > 0) {
if (responseJson?.execution_id !== undefined && responseJson?.execution_id !== null && responseJson?.execution_id?.length > 0) {
navigate(`?execution_id=${responseJson.execution_id}`)
}
if (responseJson.success === false) {
console.log("Failed sending execution request")
if (responseJson.reason !== undefined && responseJson.reason !== null) {
if (responseJson?.reason !== undefined && responseJson?.reason !== null) {
if (responseJson?.reason?.toLowerCase().includes("already clicked")) {
setMessage("Already answered. You may close this window (2).")
setMessage("This form has been answered. You may close this window.")
} else {
toast.warn(responseJson.reason)
toast.warn(responseJson?.reason)
}
}
@@ -520,11 +540,17 @@ const RunWorkflow = (defaultprops) => {
setExecutionRequest(responseJson)
start()
}
// If execution_id or authorization, add them to the URL
if (responseJson?.execution_id !== undefined && responseJson?.execution_id !== null && responseJson?.execution_id?.length > 0 && responseJson?.authorization !== undefined && responseJson?.authorization !== null && responseJson?.authorization?.length > 0) {
navigate(`?execution_id=${responseJson.execution_id}&authorization=${responseJson.authorization}`)
}
}
})
.catch(error => {
//setExecutionInfo("Error in workflow startup: " + error)
toast.warn("Error submitting form. Please try again.")
console.log("Error starting workflow: ", error)
toast.warn(`Error submitting form. Please try again: ${error}`)
stop()
setMessage("")
@@ -597,8 +623,8 @@ const RunWorkflow = (defaultprops) => {
if (realtimeMarkdown !== undefined && realtimeMarkdown !== null && realtimeMarkdown.length > 0) {
const newmarkdown = realtimeMarkdown.replace(`{{ ${workflow_id} }}`, "", -1)
setRealtimeMarkdown(newmarkdown)
} else if (inputWorkflow.form_control.input_markdown !== undefined && inputWorkflow.form_control.input_markdown !== null && inputWorkflow.form_control.input_markdown.length > 0) {
const newmarkdown = inputWorkflow.form_control.input_markdown.replace(`{{ ${workflow_id} }}`, "", -1)
} else if (inputWorkflow?.form_control?.input_markdown !== undefined && inputWorkflow?.form_control?.input_markdown !== null && inputWorkflow?.form_control?.input_markdown.length > 0) {
const newmarkdown = inputWorkflow?.form_control?.input_markdown.replace(`{{ ${workflow_id} }}`, "", -1)
setRealtimeMarkdown(newmarkdown)
}
}
@@ -608,10 +634,10 @@ const RunWorkflow = (defaultprops) => {
console.log("Get workflow error: ", error.toString())
if (realtimeMarkdown !== undefined && realtimeMarkdown !== null && realtimeMarkdown.length > 0) {
const newmarkdown = inputWorkflow.form_control.input_markdown.replace(`{{ ${workflow_id} }}`, "", -1)
const newmarkdown = inputWorkflow?.form_control?.input_markdown.replace(`{{ ${workflow_id} }}`, "", -1)
setRealtimeMarkdown(newmarkdown)
} else if (inputWorkflow.form_control.input_markdown !== undefined && inputWorkflow.form_control.input_markdown !== null && inputWorkflow.form_control.input_markdown.length > 0) {
const newmarkdown = inputWorkflow.form_control.input_markdown.replace(`{{ ${workflow_id} }}`, "", -1)
} else if (inputWorkflow?.form_control?.input_markdown !== undefined && inputWorkflow?.form_control?.input_markdown !== null && inputWorkflow?.form_control?.input_markdown.length > 0) {
const newmarkdown = inputWorkflow?.form_control?.input_markdown.replace(`{{ ${workflow_id} }}`, "", -1)
setRealtimeMarkdown(newmarkdown)
}
})
@@ -646,6 +672,7 @@ const RunWorkflow = (defaultprops) => {
trig.parameters = []
}
newexec = {}
for (var paramkey in trig.parameters) {
const param = trig.parameters[paramkey]
if (param.name !== "input_questions") {
@@ -683,6 +710,7 @@ const RunWorkflow = (defaultprops) => {
}
}
console.log("Setting exec arg: ", newexec)
setExecutionArgument(newexec)
}
@@ -733,10 +761,10 @@ const RunWorkflow = (defaultprops) => {
setInputQuestions(workflow.input_questions)
}
if (workflow.form_control.input_markdown !== undefined && workflow.form_control.input_markdown !== null && workflow.form_control.input_markdown.length > 0) {
if (workflow?.form_control?.input_markdown !== undefined && workflow?.form_control?.input_markdown !== null && workflow?.form_control?.input_markdown.length > 0) {
// Look for {{ uuid }} format, and try to run that workflow with their account
// This is a hack, but a fun one.
var newmarkdown = workflow.form_control.input_markdown.replace("", "")
var newmarkdown = workflow?.form_control?.input_markdown.replace("", "")
const uuidRegex = /{{\s[a-f0-9-]+\s}}/g
const found = newmarkdown.match(uuidRegex)
@@ -784,8 +812,8 @@ const RunWorkflow = (defaultprops) => {
}
}
if (workflow.status !== "WAITING") {
setMessage("Already answered. You may close this window (3).")
if (workflow.status === "EXECUTING" || workflow.status === "SUCCESS" || workflow.status === "ABORTED" || workflow.status === "STOPPED" || workflow.status === "FAILURE" || workflow.status === "FINISHED") {
setMessage("Already handled. You may close this window.")
}
}
@@ -806,13 +834,17 @@ const RunWorkflow = (defaultprops) => {
console.log("Status not 200 for workflows :O!");
}
if ((response.status === 401 || response.status === 403) && authorization === undefined || authorization === null || authorization.length === 0) {
toast(`This form is not available to you. If you think this is an error, please contact ${supportEmail} with the URL.`)
}
//if (response.status >= 400 && authorization === undefined || authorization === null || authorization.length === 0) {
// toast.warn(`This form may not be available to you. If you think this is an error, please contact ${supportEmail} with the URL.`)
//}
return response.json()
})
.then((responseJson) => {
if (responseJson.success === false) {
return
}
// Not sure why this is necessary.
if (responseJson.isValid === undefined) {
responseJson.isValid = true;
@@ -1008,14 +1040,78 @@ const RunWorkflow = (defaultprops) => {
return response.json();
})
.then((responseJson) => {
if (responseJson.success == false) {
if (responseJson?.success == false) {
return
}
if (execution_id !== undefined && execution_id !== null && authorization !== undefined && authorization !== null && execution_id.length > 0 && authorization.length > 0 && (workflow.id === undefined || workflow.id === null || workflow.id.length === 0) && responseJson.workflow !== undefined && responseJson.workflow !== null) {
if (execution_id !== undefined && execution_id !== null && authorization !== undefined && authorization !== null && execution_id.length > 0 && authorization.length > 0 && disableButtons === false && responseJson?.status !== "" && responseJson?.status !== "WAITING") {
setDisableButtons(true)
}
//if (execution_id !== undefined && execution_id !== null && authorization !== undefined && authorization !== null && execution_id.length > 0 && authorization.length > 0 && (workflow.id === undefined || workflow.id === null || workflow.id.length === 0) && responseJson.workflow !== undefined && responseJson.workflow !== null) {
if (execution_id !== undefined && execution_id !== null && authorization !== undefined && authorization !== null && execution_id.length > 0 && authorization.length > 0 && responseJson.workflow !== undefined && responseJson.workflow !== null) {
setupSourcenode(responseJson.workflow, sourceNode)
setWorkflow(responseJson.workflow)
//const decisionId = searchParams.get("decision_id") // ONLY for agentic workflows
// Check for decision_id in url
if (decisionId?.length > 0 && responseJson?.workflow?.actions?.length > 0 && sourceNode?.length > 0 && responseJson?.results?.length > 0) {
console.log("Setting workflow: ", responseJson.workflow, ", EXEC RESULTS: ", responseJson.results)
setAgentic(true)
for (var resultkey in responseJson.results) {
const result = responseJson.results[resultkey]
if (result.action.id !== sourceNode) {
continue
}
const validated = validateJson(result.result)
if (!validated.valid) {
console.log("Error parsing result: ", validated.error)
continue
}
var parsedresult = validated.result
console.log("PARSED RES: ", parsedresult)
if (parsedresult?.decisions?.length > 0) {
var newexec = executionArgument
if (newexec === undefined || newexec === null || Object.keys(newexec).length === 0) {
newexec = {}
}
for (var decisionkey in parsedresult?.decisions) {
const decision = parsedresult.decisions[decisionkey]
if (decision?.run_details?.id !== decisionId) {
continue
}
for (var fieldkey in decision?.fields) {
const field = decision.fields[fieldkey]
if (field.key === "question" && !inputQuestions.find(q => q.name=== field.value)) {
console.log("QUESTION: ", field)
const newquestion = {
"name": field.value,
"value": field.key+"_"+fieldkey,
}
inputQuestions.push(newquestion)
newexec[newquestion.value] = ""
}
}
}
setInputQuestions([...inputQuestions] )
console.log("EXEC: ", newexec)
setExecutionArgument(newexec)
responseJson.workflow.input_questions = inputQuestions
setWorkflow(responseJson?.workflow)
setDisableButtons(false)
}
}
}
}
@@ -1031,12 +1127,12 @@ const RunWorkflow = (defaultprops) => {
localStorage.setItem(storageKey, JSON.stringify(value))
}
if (realtimeMarkdown !== undefined && realtimeMarkdown !== null && realtimeMarkdown.length > 0) {
if (realtimeMarkdown !== undefined && realtimeMarkdown !== null && realtimeMarkdown?.length > 0) {
const newmarkdown = realtimeMarkdown.replace(`{{ ${responseJson.workflow.id} }}`, responseJson.result, -1)
setRealtimeMarkdown(newmarkdown)
} else if (workflow.form_control.input_markdown !== undefined && workflow.form_control.input_markdown !== null && workflow.form_control.input_markdown.length > 0) {
const newmarkdown = workflow.form_control.input_markdown.replace(`{{ ${responseJson.workflow.id} }}`, responseJson.result, -1)
} else if (workflow?.form_control?.input_markdown !== undefined && workflow?.form_control?.input_markdown !== null && workflow?.form_control?.input_markdown.length > 0) {
const newmarkdown = workflow?.form_control?.input_markdown.replace(`{{ ${responseJson.workflow.id} }}`, responseJson.result, -1)
setRealtimeMarkdown(newmarkdown)
}
@@ -1072,7 +1168,6 @@ const RunWorkflow = (defaultprops) => {
getWorkflow(props.match.params.key, sourceNode)
if (execution_id !== undefined && execution_id !== null && authorization !== undefined && authorization !== null) {
console.log("Get execution: ", execution_id)
fetchUpdates(execution_id, authorization, true)
}
@@ -1136,13 +1231,13 @@ const RunWorkflow = (defaultprops) => {
const buttonStyle = {borderRadius: 25, height: 50, fontSize: 18, backgroundImage: handleValidateForm(executionArgument) || executionLoading ? buttonBackground : "grey", color: "white"}
// Check if all fields are filled in?
var disabledButtons = executionLoading || executionRunning || message.length > 0
var disabledButtons = executionLoading || executionRunning || message.length > 0 || disableButtons
if (disabledButtons === false && workflow.input_questions !== undefined && workflow.input_questions !== null && workflow.input_questions.length > 0) {
// Check field values
//disabledButtons = handleValidateForm(executionArgument)
}
const organization = selectedOrganization !== undefined && selectedOrganization !== null ? selectedOrganization.name : "Unknown"
const organization = selectedOrganization !== undefined && selectedOrganization !== null ? selectedOrganization.name : ""
const contact = selectedOrganization !== undefined && selectedOrganization !== null && selectedOrganization.org !== undefined && selectedOrganization.org !== null? selectedOrganization.org : "support@shuffler.io"
//const contact = selectedOrganization !== undefined && selectedOrganization !== null && selectedOrganization.contact !== undefined && selectedOrganization.contact !== null? selectedOrganization.contact : "support@shuffler.io"
@@ -1321,12 +1416,12 @@ const RunWorkflow = (defaultprops) => {
<div style={{paddingTop: 150, marginTop: 150, width: 250, itemAlign: "center", textAlign: "center", margin: "auto", }}>
<CircularProgress />
<Typography variant="body1" style={{marginTop: 20, }}>
Loading Form Details...
Loading Details...
</Typography>
</div>
:
<div>
{workflowQuestion !== "" || (workflow.form_control.input_markdown !== undefined && workflow.form_control.input_markdown !== null && workflow.form_control.input_markdown.length > 0) ?
{workflowQuestion !== "" || (workflow?.form_control?.input_markdown !== undefined && workflow?.form_control?.input_markdown !== null && workflow?.form_control?.input_markdown.length > 0) ?
<div style={{marginBottom: 20, }}>
<Markdown
components={{
@@ -1342,13 +1437,13 @@ const RunWorkflow = (defaultprops) => {
}}
rehypePlugins={[rehypeRaw]}
>
{workflowQuestion !== "" ? workflowQuestion : realtimeMarkdown !== undefined && realtimeMarkdown !== null && realtimeMarkdown.length > 0 ? realtimeMarkdown : workflow.form_control.input_markdown}
{workflowQuestion !== "" ? workflowQuestion : realtimeMarkdown !== undefined && realtimeMarkdown !== null && realtimeMarkdown.length > 0 ? realtimeMarkdown : workflow?.form_control?.input_markdown}
</Markdown>
</div>
: null}
<form onSubmit={(e) => {onSubmit(e)}} style={{margin: "25px 0px 15px 0px",}}>
{workflowQuestion !== "" || (workflow.form_control.input_markdown !== undefined && workflow.form_control.input_markdown !== null && workflow.form_control.input_markdown.length > 0) ? null :
{workflowQuestion !== "" || (workflow?.form_control?.input_markdown !== undefined && workflow?.form_control?.input_markdown !== null && workflow?.form_control?.input_markdown.length > 0) ? null :
<div>
{/*
<img
@@ -1370,10 +1465,12 @@ const RunWorkflow = (defaultprops) => {
<Typography variant="h6" style={{marginBottom: 10, marginTop: 50, textAlign: "center", }}>
{organization}
</Typography>
<Divider style={{marginTop: 20, marginBottom: 20, }}/>
{organization?.length > 0 &&
<Divider style={{marginTop: 20, marginBottom: 20, }}/>
}
{disabledButtons && message.length > 0 ? null :
<Typography color="textSecondary" style={{textAlign: "center", }}>
<Typography color="textSecondary" style={{textAlign: "center", marginTop: 15, }}>
{message}
</Typography>
}
@@ -1412,6 +1509,11 @@ const RunWorkflow = (defaultprops) => {
executionArgument[multiChoiceOptions[0]] = multiChoiceOptions[1]
}
const parsedLabel = question?.value?.startsWith("question_") ?
""
:
question?.value?.charAt(0)?.toUpperCase() + question?.value?.slice(1)
return (
<div style={{marginBottom: 10}} key={index}>
@@ -1457,7 +1559,7 @@ const RunWorkflow = (defaultprops) => {
backgroundColor: theme.palette.inputColor,
marginTop: 5,
}}
label={question?.value?.charAt(0)?.toUpperCase() + question?.value?.slice(1)}
label={parsedLabel}
required
disabled={disabledButtons}
@@ -1542,7 +1644,7 @@ const RunWorkflow = (defaultprops) => {
:
<Fade in={true} timeout={2500}>
<Typography variant="body1" style={{textAlign: "center", marginTop: 30, marginBottom: 20, }}>
{disabledButtons ? "Already answered. You may close this window." : ""}
{disabledButtons ? "Question answered. You may close this window." : ""}
</Typography>
</Fade>
}
@@ -1565,10 +1667,13 @@ const RunWorkflow = (defaultprops) => {
textTransform: "none",
}}
onClick={() => {
setButtonClicked("FINISHED")
setExecutionData({
status: "FINISHED",
})
// Timeout 2500 just in case
setTimeout(() => {
setButtonClicked("FINISHED")
setExecutionData({
status: "FINISHED",
})
}, 2500)
onSubmit(null, execution_id, authorization, true)
}}>
@@ -1586,16 +1691,24 @@ const RunWorkflow = (defaultprops) => {
flex: 1,
textTransform: "none",
}} onClick={() => {
setButtonClicked("ABORTED")
setExecutionData({
status: "ABORTED",
})
setTimeout(() => {
setButtonClicked("ABORTED")
setExecutionData({
status: "ABORTED",
})
}, 2500)
onSubmit(null, execution_id, authorization, false)
}}>
Stop
</Button>
</div>
{handleValidateForm(executionArgument) === false && disabledButtons === false ?
<Typography variant="body2" color="textSecondary" style={{textAlign: "center", marginTop: 10, underline: "1px solid grey", }}>
All required questions have not been answered yet.
</Typography>
: null}
</span>
:
<div style={{display: "flex", marginTop: "15px"}}>
+2 -2
View File
@@ -477,7 +477,7 @@ export const HandleJsonCopy = (base, copy, base_node_name) => {
//var newitem = JSON.parse(base);
var newitem = validateJson(base).result
var to_be_copied = "$" + base_node_name.toLowerCase().replaceAll(" ", "_");
var to_be_copied = "$" + base_node_name?.toLowerCase()?.replaceAll(" ", "_");
for (let copykey in copy.namespace) {
if (copy.namespace[copykey].includes("Results for")) {
continue;
@@ -742,7 +742,7 @@ const DropzoneWrapper = memo(({ onDrop, WorkflowView }) => {
const Workflows = (props) => {
const { globalUrl, isLoggedIn, isLoaded, userdata, checkLogin } = props;
document.title = "Shuffle - Workflows";
document.title = "Workflows - Shuffle";
let navigate = useNavigate();
const classes = useStyles(theme)
+443 -330
View File
@@ -4,16 +4,6 @@ import { useLocation, useNavigate, Link } from "react-router-dom";
import ReactDOM from "react-dom"
import { getTheme } from "../theme.jsx";
// Material UI Icons
import Add from '@mui/icons-material/Add';
import Search from '@mui/icons-material/Search';
import ClearIcon from '@mui/icons-material/Clear';
import QueryStatsIcon from '@mui/icons-material/QueryStats';
import GridOnIcon from '@mui/icons-material/GridOn';
import ListIcon from '@mui/icons-material/List';
import PublishIcon from '@mui/icons-material/Publish';
import GetAppIcon from '@mui/icons-material/GetApp';
// Material UI & Components
import { makeStyles } from "@mui/styles";
import { Navigate } from "react-router-dom";
@@ -67,6 +57,7 @@ import {
// Material UI Icons
import {
ContentCopy as ContentCopyIcon,
Close as CloseIcon,
Compare as CompareIcon,
Maximize as MaximizeIcon,
@@ -105,6 +96,12 @@ import {
AutoAwesome as AutoAwesomeIcon,
BarChart as BarChartIcon,
Lock as LockIcon,
Clear as ClearIcon,
QueryStats as QueryStatsIcon,
GridOn as GridOnIcon,
List as ListIcon,
Publish as PublishIcon,
GetApp as GetAppIcon,
} from "@mui/icons-material";
// Additional Components
@@ -209,10 +206,10 @@ export const GetIconInfo = (action) => {
key: "compare",
values: ["compare", "convert", "to", "filter", "translate", "parse"],
},
{ key: "assets", values: ["cmdb", "assets", "asset", "cmdb", "inventory", "host", "hosts", "device", "devices"] },
{ key: "assets", values: ["cmdb", "assets", "asset", "cmdb", "inventory", "host", "hosts", "device", "devices", "app",] },
{ key: "close", values: ["close", "stop", "cancel", "block"] },
{ key: "communication", values: ["communication", "comms", "email", "mail",] },
{ key: "eradication", values: ["eradication", "edr", "xdr"] },
{ key: "eradication", values: ["eradication", "edr", "xdr", "sigma", "yara",] },
{ key: "iam", values: ["iam", "identity", "access", "auth", "authentication", "authorization", "oauth", "sso", "openid"] },
{ key: "intel", values: ["intel", "feed", "threat intel", "threat intelligence", "ti", "t.i.", "t.i", "ti.", "rule", "technique", "tactic", "techniques", "tactics", "ioc", "indicator",] },
{ key: "network", values: ["network", "net", "networking", "firewall", "proxy", "vpn", "sdwan", "sd-wan"] },
@@ -235,6 +232,7 @@ export const GetIconInfo = (action) => {
values: [
"api",
"password",
"passwd",
"protect",
],
}
@@ -835,7 +833,9 @@ const Workflows2 = (props) => {
setCurrTab(1);
} else if (tabParam === 'all_workflows' && currTab !== 2) {
setCurrTab(2);
}
} else if (tabParam === 'background_processes' && currTab !== 4) {
setCurrTab(4);
}
}
}, [location.search]);
@@ -853,10 +853,15 @@ const Workflows2 = (props) => {
1: 'my_workflows',
2: 'all_workflows',
3: 'backup_apps',
4: 'background_processes',
};
const queryParams = new URLSearchParams(location.search);
queryParams.set('tab', tabMapping[newValue]);
if (newValue === 4) {
setShowExecutionStats(true)
setView("grid")
}
navigate(`${location.pathname}?${queryParams.toString()}`);
};
@@ -1553,7 +1558,7 @@ const Workflows2 = (props) => {
sx: {
borderRadius: theme?.palette?.DialogStyle?.borderRadius,
border: theme?.palette?.DialogStyle?.border,
minWidth: '440px',
minWidth: 440,
fontFamily: theme?.typography?.fontFamily,
backgroundColor: theme?.palette?.DialogStyle?.backgroundColor,
zIndex: 1000,
@@ -1566,11 +1571,11 @@ const Workflows2 = (props) => {
}
}}
>
<DialogTitle>
<DialogTitle style={{padding: 50, }}>
<div style={{ textAlign: "center", color: theme.palette.DialogStyle?.color }}>
Are you sure you want to delete {selectedWorkflowId.length > 0 ? filteredWorkflows.find((w) => w.id === selectedWorkflowId)?.name : `${selectedWorkflowIndexes.length} workflow${selectedWorkflowIndexes.length === 1 ? '' : 's'}`}? <div />
Other workflows relying on {selectedWorkflowIndexes.length > 0 ? "them" : "it"} one will stop working
Other workflows relying on {selectedWorkflowIndexes.length > 0 ? "them" : "it"} one will stop working.
</div>
</DialogTitle>
<DialogContent
@@ -1819,6 +1824,7 @@ const Workflows2 = (props) => {
credentials: "include",
})
.then((response) => {
setIsLoadingWorkflow(false)
if (response.status !== 200) {
console.log("Status not 200 for workflows :O!: ", response.status);
@@ -1956,6 +1962,7 @@ const Workflows2 = (props) => {
}
})
.catch((error) => {
setIsLoadingWorkflow(false)
toast(error.toString());
});
}
@@ -2949,6 +2956,8 @@ const Workflows2 = (props) => {
triggerfound = true
image = wfTriggers[0].large_image
trigger.status = trigger?.status?.toLowerCase()
relevantTrigger = trigger
if (trigger?.status === "running") {
imageStyle.border = `3px solid ${green}`
@@ -2962,6 +2971,8 @@ const Workflows2 = (props) => {
triggerfound = true
image = wfTriggers[1].large_image
trigger.status = trigger?.status?.toLowerCase()
relevantTrigger = trigger
if (trigger?.status === "running") {
imageStyle.border = `3px solid ${green}`
@@ -3034,10 +3045,11 @@ const Workflows2 = (props) => {
const foundTimeline = workflowTimelines.find((timeline) => timeline.id === data.id)
return (
<div style={{ width: "100%", minWidth: 320, position: "relative", border: highlightIds.includes(data.id) ? "2px solid #f85a3e" : isDistributed || hasSuborgs ? `2px solid ${theme.palette.distributionColor}` : "inherit", borderRadius: theme.palette?.borderRadius, backgroundColor: "#212121", fontFamily: theme.typography?.fontFamily }}>
<div
id={`workflowbox-${data.id}`}
style={{ width: "100%", minWidth: 320, position: "relative", border: highlightIds.includes(data.id) ? "2px solid #f85a3e" : isDistributed || hasSuborgs ? `2px solid ${theme.palette.distributionColor}` : "inherit", borderRadius: theme.palette?.borderRadius, backgroundColor: "#212121", fontFamily: theme.typography?.fontFamily }}>
<Paper square style={paperAppStyle}>
{selectedCategory !== "" ?
<Tooltip title={`Usecase Category: ${selectedCategory}`} placement="bottom">
<div
@@ -3058,7 +3070,7 @@ const Workflows2 = (props) => {
}}
/>
</Tooltip>
: null}
: null}
<Grid
item
@@ -3067,7 +3079,6 @@ const Workflows2 = (props) => {
<Grid item style={{ display: "flex", maxHeight: 34 }}>
{currTab === 2 ? null :
<Tooltip title={`${relevantTrigger?.name}: ${relevantTrigger?.status}`} placement="bottom">
<div
style={{ cursor: "" }}
onClick={() => {
@@ -3183,6 +3194,7 @@ const Workflows2 = (props) => {
</Typography>
</Tooltip>
</Grid>
<Grid item style={workflowActionStyle}>
{appGroup.length > 0 ?
<div style={{ display: "flex", marginTop: 8, }}>
@@ -3437,7 +3449,7 @@ const Workflows2 = (props) => {
</Grid>
{showExecutionStats === true && foundTimeline !== undefined && foundTimeline?.timeline?.length > 0 &&
<div style={{ margin: "40px 10px 0px 10px", paddingTop: 0, borderTop: "1px solid rgba(255,255,255,0.3)", }}>
<div style={{ margin: "40px 10px 0px 10px", paddingTop: 0, borderTop: `1px solid ${theme.palette.text.secondary}`, zoom: 1.4 }}>
<LineChartWrapper
inputname={""}
keys={foundTimeline?.timeline}
@@ -4974,331 +4986,334 @@ const Workflows2 = (props) => {
</div>
<div style={{ display: "flex", justifyContent: "space-between", gap: 10, marginBottom: 20, paddingRight: 25, minHeight: 47 }}>
{currTab === 4 ? null :
<div style={{ display: "flex", justifyContent: "space-between", gap: 10, marginBottom: 20, paddingRight: 25, minHeight: 47 }}>
{currTab === 2 ? (
<CustomSearchBox
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
/>
) : (
<MuiChipsInput
style={{
width: "25%",
maxWidth: "25%",
minWidth: "25%",
height: 43,
maxHeight: "fit-content",
backgroundColor: theme.palette.textFieldStyle.backgroundColor,
zIndex: 1000,
color: theme.palette.textFieldStyle.color
}}
disabled={currTab === 2}
InputProps={{
style: {
height: "fit-content",
maxHeight: "fit-content",
backgroundColor: theme.palette.textFieldStyle.backgroundColor,
color: theme.palette.textFieldStyle.color
},
placeholder: "Filter Workflows",
// endAdornment: (
// <InputAdornment position="end">
// <SearchIcon style={{ color: 'white', paddingRight: 5 }} />
// </InputAdornment>
// ),
onKeyDown: (e) => {
// Prevent default behavior for Enter and Backspace
if (e.key === 'Enter' || e.key === 'Backspace') {
e.preventDefault();
e.stopPropagation();
e.target.focus();
}
},
}}
clearInputOnBlur={false}
sx={{
// Container styling
'& .MuiOutlinedInput-root': {
height: "fit-content",
borderRadius: '4px',
color: theme.palette.textFieldStyle.color,
backgroundColor: theme.palette.textFieldStyle.backgroundColor,
'& fieldset': {
borderColor: 'rgba(255, 255, 255, 0.23)',
},
'&:hover fieldset': {
borderColor: 'rgba(255, 255, 255, 0.4)',
},
},
{currTab === 2 ? (
<CustomSearchBox
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
/>
) :
(
<MuiChipsInput
style={{
width: "25%",
maxWidth: "25%",
minWidth: "25%",
height: 43,
maxHeight: "fit-content",
backgroundColor: theme.palette.textFieldStyle.backgroundColor,
zIndex: 1000,
color: theme.palette.textFieldStyle.color
}}
disabled={currTab === 2}
InputProps={{
style: {
height: "fit-content",
maxHeight: "fit-content",
backgroundColor: theme.palette.textFieldStyle.backgroundColor,
color: theme.palette.textFieldStyle.color
},
placeholder: "Filter Workflows",
// endAdornment: (
// <InputAdornment position="end">
// <SearchIcon style={{ color: 'white', paddingRight: 5 }} />
// </InputAdornment>
// ),
onKeyDown: (e) => {
// Prevent default behavior for Enter and Backspace
if (e.key === 'Enter' || e.key === 'Backspace') {
e.preventDefault();
e.stopPropagation();
e.target.focus();
}
},
}}
clearInputOnBlur={false}
sx={{
// Container styling
'& .MuiOutlinedInput-root': {
height: "fit-content",
borderRadius: '4px',
color: theme.palette.textFieldStyle.color,
backgroundColor: theme.palette.textFieldStyle.backgroundColor,
'& fieldset': {
borderColor: 'rgba(255, 255, 255, 0.23)',
},
'&:hover fieldset': {
borderColor: 'rgba(255, 255, 255, 0.4)',
},
},
// Adjust chip container to center vertically
'& .MuiInputBase-root': {
display: 'flex',
flexWrap: 'wrap',
gap: '4px',
fontSize: 18,
padding: '4px 8px',
alignItems: 'center',
height: "fit-content", // Match height
backgroundColor: theme.palette.textFieldStyle.backgroundColor,
color: theme.palette.textFieldStyle.color
},
// Adjust chip container to center vertically
'& .MuiInputBase-root': {
display: 'flex',
flexWrap: 'wrap',
gap: '4px',
fontSize: 18,
padding: '4px 8px',
alignItems: 'center',
height: "fit-content", // Match height
backgroundColor: theme.palette.textFieldStyle.backgroundColor,
color: theme.palette.textFieldStyle.color
},
// Rest of the styling remains the same...
}}
value={filters}
onChange={(chips) => {
setFilters(chips);
const remainingCategories = chips.map(chip => {
const match = chip.match(/\d+\.\s+(\w+)/i);
return match ? match[1] : chip;
}).filter(category => {
return usecases.some(usecase =>
usecase.name.toLowerCase().includes(category.toLowerCase())
);
});
// Rest of the styling remains the same...
}}
value={filters}
onChange={(chips) => {
setFilters(chips);
const remainingCategories = chips.map(chip => {
const match = chip.match(/\d+\.\s+(\w+)/i);
return match ? match[1] : chip;
}).filter(category => {
return usecases.some(usecase =>
usecase.name.toLowerCase().includes(category.toLowerCase())
);
});
setSelectedCategory(remainingCategories);
findWorkflow(chips);
setSelectedCategory(remainingCategories);
findWorkflow(chips);
}}
//onAdd={(chip) => {
// console.log("ADd: ", chip);
// addFilter(chip);
//}}
//onDelete={(_, index) => {
// console.log("Remove: ", index);
// removeFilter(index);
//}}
/>
)}
}}
//onAdd={(chip) => {
// console.log("ADd: ", chip);
// addFilter(chip);
//}}
//onDelete={(_, index) => {
// console.log("Remove: ", index);
// removeFilter(index);
//}}
/>
)}
{
currTab !== 2 && (
<Select
fullWidth
variant="outlined"
value={selectedCategory}
onChange={handleCategoryChange}
displayEmpty
disabled={currTab === 2}
multiple
style={{
width: "25%",
minWidth: "25%",
maxWidth: "25%",
height: 47,
borderRadius: 4,
fontSize: 18,
backgroundColor: theme.palette.textFieldStyle.backgroundColor,
fontFamily: theme.typography?.fontFamily,
}}
sx={{
'& .MuiOutlinedInput-root': {
'& fieldset': {
borderColor: 'rgba(255, 255, 255, 0.23)',
},
},
}}
renderValue={(selected) => selected.length ? selected.join(', ') : 'All Categories'}
>
<MenuItem disabled value="">
All Categories
</MenuItem>
{usecases.map((usecase, index) => {
if (usecase?.name === "5. Verify") {
return null;
}
{
currTab !== 2 && (
<Select
fullWidth
variant="outlined"
value={selectedCategory}
onChange={handleCategoryChange}
displayEmpty
disabled={currTab === 2}
multiple
style={{
width: "25%",
minWidth: "25%",
maxWidth: "25%",
height: 47,
borderRadius: 4,
fontSize: 18,
backgroundColor: theme.palette.textFieldStyle.backgroundColor,
fontFamily: theme.typography?.fontFamily,
}}
sx={{
'& .MuiOutlinedInput-root': {
'& fieldset': {
borderColor: 'rgba(255, 255, 255, 0.23)',
},
},
}}
renderValue={(selected) => selected.length ? selected.join(', ') : 'All Categories'}
>
<MenuItem disabled value="">
All Categories
</MenuItem>
{usecases.map((usecase, index) => {
if (usecase?.name === "5. Verify") {
return null;
}
const percentDone = usecase.matches.length > 0 ? parseInt(usecase.matches.length / usecase.list.length * 100) : 0
if (percentDone === 0) {
usecase = findMatches(usecase, workflows)
}
const percentDone = usecase.matches.length > 0 ? parseInt(usecase.matches.length / usecase.list.length * 100) : 0
if (percentDone === 0) {
usecase = findMatches(usecase, workflows)
}
const category = usecase?.name.split(" ")[1]
return (
<MenuItem
value={category}
onClick={() => {
if (!filters.includes(usecase?.name.toLowerCase())) {
addFilter(usecase.name)
} else {
removeFilter(filters.indexOf(usecase?.name.toLowerCase()))
}
}}
sx={{
padding: "12px 16px",
borderBottom: index === usecases.length - 2 ? "none" : "1px solid rgba(255,255,255,0.05)",
"&:hover": {
backgroundColor: "rgba(255,255,255,0.1)"
},
}}
>
<div style={{
display: "flex",
alignItems: "center",
width: "100%",
gap: "12px"
}}>
<Checkbox
checked={selectedCategory.includes(category)}
style={{
padding: 0,
marginRight: 8,
color: theme.palette.textFieldStyle.color,
}}
/>
<div style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
width: "100%"
}}>
<Typography
variant="body1"
style={{
color: theme.palette.textFieldStyle.color,
fontWeight: selectedCategory.includes(category) ? 500 : 400
}}
>
{category}
</Typography>
<Typography
variant="body2"
style={{
color: theme.palette.textFieldStyle.color,
backgroundColor: theme.palette.textFieldStyle.backgroundColor,
padding: "2px 8px",
borderRadius: "12px",
fontSize: "0.75rem"
}}
>
{usecase?.matches.length}/{usecase?.list.length}
</Typography>
</div>
</div>
</MenuItem>
)
})}
</Select>
)
}
{
currTab === 2 && (
<CustomCategoryDropdown attribute="usecase_ids" limit={20} />
)
}
const category = usecase?.name.split(" ")[1]
return (
<MenuItem
value={category}
onClick={() => {
if (!filters.includes(usecase?.name.toLowerCase())) {
addFilter(usecase.name)
} else {
removeFilter(filters.indexOf(usecase?.name.toLowerCase()))
}
}}
sx={{
padding: "12px 16px",
borderBottom: index === usecases.length - 2 ? "none" : "1px solid rgba(255,255,255,0.05)",
"&:hover": {
backgroundColor: "rgba(255,255,255,0.1)"
},
}}
>
<div style={{
display: "flex",
alignItems: "center",
width: "100%",
gap: "12px"
}}>
<Checkbox
checked={selectedCategory.includes(category)}
style={{
padding: 0,
marginRight: 8,
color: theme.palette.textFieldStyle.color,
}}
/>
<div style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
width: "100%"
}}>
<Typography
variant="body1"
style={{
color: theme.palette.textFieldStyle.color,
fontWeight: selectedCategory.includes(category) ? 500 : 400
}}
>
{category}
</Typography>
<Typography
variant="body2"
style={{
color: theme.palette.textFieldStyle.color,
backgroundColor: theme.palette.textFieldStyle.backgroundColor,
padding: "2px 8px",
borderRadius: "12px",
fontSize: "0.75rem"
}}
>
{usecase?.matches.length}/{usecase?.list.length}
</Typography>
</div>
</div>
</MenuItem>
)
})}
</Select>
)
}
{
currTab === 2 && (
<CustomCategoryDropdown attribute="usecase_ids" limit={20} />
)
}
<div style={{ width: "50%", minWidth: "50%", maxWidth: "50%", height: 47, display: "flex", gap: 5 }}>
<div style={{
display: "flex",
height: "100%",
justifyContent: "space-around",
flex: 0.7,
paddingLeft: 1,
paddingRight: 1,
gap: 4
}}>
<div style={{ width: "50%", minWidth: "50%", maxWidth: "50%", height: 47, display: "flex", gap: 5 }}>
<div style={{
display: "flex",
height: "100%",
justifyContent: "space-around",
flex: 0.7,
paddingLeft: 1,
paddingRight: 1,
gap: 4
}}>
<Tooltip title="Show/Hide Workflow Runs for top workflows" placement="top">
<IconButton
style={currTab === 2 ? iconButtonDisabledStyle : {...iconButtonStyle, color: showExecutionStats ? "#1a1a1a" : theme.palette.text.primary, background: showExecutionStats ? theme.palette.primary.main : theme.palette.platformColor}}
onClick={() => {
<Tooltip title="Show/Hide Workflow Runs for top workflows" placement="top">
<IconButton
style={currTab === 2 ? iconButtonDisabledStyle : {...iconButtonStyle, color: showExecutionStats ? "#1a1a1a" : theme.palette.text.primary, background: showExecutionStats ? theme.palette.primary.main : theme.palette.platformColor}}
onClick={() => {
const newView = !showExecutionStats
localStorage.setItem("showExecutionStats", newView)
setShowExecutionStats(!showExecutionStats)
}}
disabled={currTab === 2}
>
<BarChartIcon />
</IconButton>
</Tooltip>
const newView = !showExecutionStats
localStorage.setItem("showExecutionStats", newView)
setShowExecutionStats(!showExecutionStats)
}}
disabled={currTab === 2}
>
<BarChartIcon />
</IconButton>
</Tooltip>
<Tooltip title="Explore Workflow Runs (debugger)" placement="top">
<IconButton
style={currTab === 2 ? iconButtonDisabledStyle : iconButtonStyle}
onClick={() => navigate("/workflows/debug")}
disabled={currTab === 2}
>
<QueryStatsIcon style={{ color: theme.palette.text.primary, opacity: currTab === 2 ? 0.5 : 1 }} />
</IconButton>
</Tooltip>
<Tooltip title="Explore Workflow Runs (debugger)" placement="top">
<IconButton
style={currTab === 2 ? iconButtonDisabledStyle : iconButtonStyle}
onClick={() => navigate("/workflows/debug")}
disabled={currTab === 2}
>
<QueryStatsIcon style={{ color: theme.palette.text.primary, opacity: currTab === 2 ? 0.5 : 1 }} />
</IconButton>
</Tooltip>
<Tooltip title={view === "grid" ? "List view (Org Workflows only)" : "Grid view"} placement="top">
<IconButton
style={currTab === 2 ? iconButtonDisabledStyle : iconButtonStyle}
onClick={() => {
const newView = view === "grid" ? "list" : "grid";
localStorage.setItem("workflowView", newView);
setView(newView);
<Tooltip title={view === "grid" ? "List view (Org Workflows only)" : "Grid view"} placement="top">
<IconButton
style={currTab === 2 ? iconButtonDisabledStyle : iconButtonStyle}
onClick={() => {
const newView = view === "grid" ? "list" : "grid";
localStorage.setItem("workflowView", newView);
setView(newView);
if (view === "grid") {
setCurrTab(0)
}
}}
disabled={currTab === 2}
>
{view === "grid" ?
<ListIcon style={{ color: theme.palette.text.primary, opacity: currTab === 2 ? 0.5 : 1 }} /> :
<GridOnIcon style={{ color: theme.palette.text.primary, opacity: currTab === 2 ? 0.5 : 1 }} />
}
</IconButton>
</Tooltip>
if (view === "grid") {
setCurrTab(0)
}
}}
disabled={currTab === 2}
>
{view === "grid" ?
<ListIcon style={{ color: theme.palette.text.primary, opacity: currTab === 2 ? 0.5 : 1 }} /> :
<GridOnIcon style={{ color: theme.palette.text.primary, opacity: currTab === 2 ? 0.5 : 1 }} />
}
</IconButton>
</Tooltip>
<Tooltip title="Import workflows" placement="top">
<IconButton
style={currTab === 2 ? iconButtonDisabledStyle : iconButtonStyle}
onClick={() => upload.click()}
disabled={currTab === 2}
>
{submitLoading ?
<CircularProgress color="secondary" /> :
<PublishIcon style={{ color: theme.palette.text.primary, opacity: currTab === 2 ? 0.5 : 1}} />
}
</IconButton>
</Tooltip>
<Tooltip title="Import workflows" placement="top">
<IconButton
style={currTab === 2 ? iconButtonDisabledStyle : iconButtonStyle}
onClick={() => upload.click()}
disabled={currTab === 2}
>
{submitLoading ?
<CircularProgress color="secondary" /> :
<PublishIcon style={{ color: theme.palette.text.primary, opacity: currTab === 2 ? 0.5 : 1}} />
}
</IconButton>
</Tooltip>
<input
hidden
type="file"
multiple="multiple"
ref={(ref) => (upload = ref)}
onChange={importFiles}
/>
<input
hidden
type="file"
multiple="multiple"
ref={(ref) => (upload = ref)}
onChange={importFiles}
/>
<Tooltip title={`Download ALL workflows (${workflows.length})`} placement="top">
<IconButton
style={(isCloud || currTab === 2) ? iconButtonDisabledStyle : { ...iconButtonStyle, cursor: "pointer" }}
disabled={isCloud || currTab === 2}
onClick={() => exportAllWorkflows(workflows)}
>
<GetAppIcon style={{ color: theme.palette.text.primary, opacity: currTab === 2 ? 0.5 : 1}} />
</IconButton>
</Tooltip>
</div>
<Button
variant="contained"
color="primary"
onClick={handleCreateWorkflow}
id="create_workflow_button"
style={{
borderRadius: 4,
flex: 0.8,
textTransform: 'none',
fontFamily: theme.typography?.fontFamily,
fontSize: 16,
fontWeight: 500
}}
startIcon={<Add/>}
>
Create Workflow
</Button>
</div>
<Tooltip title={`Download ALL workflows (${workflows.length})`} placement="top">
<IconButton
style={(isCloud || currTab === 2) ? iconButtonDisabledStyle : { ...iconButtonStyle, cursor: "pointer" }}
disabled={isCloud || currTab === 2}
onClick={() => exportAllWorkflows(workflows)}
>
<GetAppIcon style={{ color: theme.palette.text.primary, opacity: currTab === 2 ? 0.5 : 1}} />
</IconButton>
</Tooltip>
</div>
<Button
variant="contained"
color="primary"
onClick={handleCreateWorkflow}
id="create_workflow_button"
style={{
borderRadius: 4,
flex: 0.8,
textTransform: 'none',
fontFamily: theme.typography?.fontFamily,
fontSize: 16,
fontWeight: 500
}}
startIcon={<AddIcon />}
>
Create Workflow
</Button>
</div>
</div>
}
</div>
<div style={{
width: "100%",
position: "relative",
@@ -5310,8 +5325,106 @@ const Workflows2 = (props) => {
) : (
view === "grid" && currTab !== 2 ? (
<>
<div style={{
marginTop: 16,
{currTab === 4 && backgroundWorkflows.map((data, index) => {
if (data.triggers.length === 0) {
return null
}
var foundWebhook = ""
var foundtrigger = {}
for (var triggerKey in data.triggers) {
if (data.triggers[triggerKey].trigger_type === "WEBHOOK") {
foundWebhook = `${globalUrl}/api/v1/hooks/webhook_${data.triggers[triggerKey].id}`
foundtrigger = data.triggers[triggerKey]
break
}
}
if (foundWebhook === "") {
return null
}
var webhookName = ``
if (data?.name?.toLowerCase().includes("ingest tickets")) {
webhookName = "Send your Tickets, Alerts, Cases and Detections here. This will ingest them into Shuffle."
}
return (
<div style={{padding: 50, }}
onMouseEnter={() => {
// Find the relevant workflow paper and highlight it
const foundElement = document.getElementById(`workflowbox-${data.id}`)
if (foundElement) {
foundElement.style.border = `3px solid ${theme.palette.primary.main}`
}
}}
onMouseLeave={() => {
const foundElement = document.getElementById(`workflowbox-${data.id}`)
if (foundElement) {
foundElement.style.border = null
}
}}
>
<Typography variant="body1" style={{ marginBottom: 10, fontFamily: theme.typography?.fontFamily, display: "flex", alignItems: "center", gap: 10 }}>
{webhookName}
</Typography>
<TextField
value={foundWebhook}
readOnly
fullWidth
disabled
// Add start adornment with webhook icon
InputProps={{
startAdornment: (
<InputAdornment position="start">
<img
alt="webhook"
src={wfTriggers[0].large_image}
style={{
width: 40,
height: 40,
marginRight: 20,
border: foundtrigger.status === "Running" || foundtrigger.status === "running" ? `2px solid ${green}` : `2px solid ${red}`,
borderRadius: theme.palette.borderRadius,
}}
/>
<Tooltip title={"Copy to clipboard"} placement="top">
<IconButton
onClick={() => {
if (navigator.clipboard === undefined) {
toast("Your browser doesn't support clipboard copying, please copy manually.", { type: "error" });
} else {
navigator.clipboard.writeText(foundWebhook);
}
}}
style={{
color: theme.palette.textFieldStyle.color,
backgroundColor: theme.palette.platformColor,
marginRight: 10,
borderRadius: 4,
}}
id="copy_webhook_url_button"
>
<ContentCopyIcon style={{ color: theme.palette.textFieldStyle.color }} />
</IconButton>
</Tooltip>
</InputAdornment>
),
style: {
color: theme.palette.textFieldStyle.color,
backgroundColor: theme.palette.textFieldStyle.backgroundColor,
}
}}
/>
</div>
)
})}
<div style={{
marginTop: 32,
width: "100%",
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(365px, 1fr))",