From 410a61af1c39984ca613af263d41fdfbde1b88d8 Mon Sep 17 00:00:00 2001 From: Frikky Date: Mon, 3 Mar 2025 10:52:41 +0100 Subject: [PATCH] Workflow syncs --- frontend/src/components/AppModal.jsx | 82 ++- frontend/src/components/ParsedAction.jsx | 6 + .../src/components/ShuffleCodeEditor1.jsx | 1 + frontend/src/views/AngularWorkflow.jsx | 581 +++++++++++++++--- frontend/src/views/Apps2.jsx | 3 +- 5 files changed, 569 insertions(+), 104 deletions(-) diff --git a/frontend/src/components/AppModal.jsx b/frontend/src/components/AppModal.jsx index c3deb1ae..e9364913 100644 --- a/frontend/src/components/AppModal.jsx +++ b/frontend/src/components/AppModal.jsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { memo, useCallback, useEffect, useState } from 'react'; import { useNavigate } from 'react-router'; import { @@ -29,8 +29,15 @@ import theme from "../theme.jsx"; import YAML from 'yaml'; import { toast } from 'react-toastify'; import { Link } from 'react-router-dom'; +import { InstantSearch, connectHits, connectSearchBox } from 'react-instantsearch-dom'; +import algoliasearch from "algoliasearch/lite"; -const AppModal = ({ open, onClose, app, globalUrl, getApps }) => { +const searchClient = algoliasearch( + "JNSS5CFDZZ", + "db08e40265e2941b9a7d8f644b6e5240" +);; + +const AppModal = ({ open, onClose, app, globalUrl, getApps}) => { const [frameworkData, setFrameworkData] = useState({}) const [userdata, setUserdata] = useState({}) @@ -79,8 +86,7 @@ const AppModal = ({ open, onClose, app, globalUrl, getApps }) => { }); }, [app]); - - const getFramework = () => { + const getFramework = () => { fetch(globalUrl + "/api/v1/apps/frameworkConfiguration", { method: "GET", headers: { @@ -295,8 +301,6 @@ const AppModal = ({ open, onClose, app, globalUrl, getApps }) => { }) } - - useEffect(() => { setUsecaseLoading(true) getAvailableWorkflows() @@ -329,6 +333,7 @@ const AppModal = ({ open, onClose, app, globalUrl, getApps }) => { //delete apps from local storage localStorage.removeItem("apps"); getApps(); + onClose(); }, 1000); } else { toast("Failed deleting app. Does it still exist?"); @@ -720,18 +725,7 @@ const AppModal = ({ open, onClose, app, globalUrl, getApps }) => { textAlign: "start", flex: 1, }}> - - 20 - + { }; export default AppModal; + + +const WorkflowCard = memo(({ app }) => { + const [name, setName] = useState(""); + const [relatedWorkflows, setRelatedWorkflows] = useState(0); + + const WorkflowHits = ({ hits }) => { + useEffect(() => { + setRelatedWorkflows(hits?.length || 0); + }, [hits]); + + return null; + }; + + const CustomWorkflowHits = connectHits(WorkflowHits); + + const SearchBox = ({ refine, currentRefinement }) => { + useEffect(() => { + if (name.length > 0 && currentRefinement !== name) { + refine(name?.split(" ")[0]); + } + }, [name, refine, currentRefinement]); + + return null; + }; + + const CustomSearchBox = connectSearchBox(SearchBox); + + useEffect(() => { + if (app?.name && app.name.trim().length > 0 && name !== app.name) { + const formattedName = app.name + .charAt(0) + .toUpperCase() + app.name.substring(1) + .replaceAll("_", " "); + setName(formattedName); + } + }, [app?.name]); + + return ( + + {name.length > 0 ? ( + + + + {relatedWorkflows} + + ) : ( + relatedWorkflows + )} + + ); +}); diff --git a/frontend/src/components/ParsedAction.jsx b/frontend/src/components/ParsedAction.jsx index 4732efc2..75cfc743 100755 --- a/frontend/src/components/ParsedAction.jsx +++ b/frontend/src/components/ParsedAction.jsx @@ -1160,6 +1160,12 @@ const ParsedAction = (props) => { selectedActionParameters[1].value = splitparsed[1] selectedAction.parameters[1].value = splitparsed[1] + + if (splitparsed.length > 2) { + toast.warn("Filter list only supports filtering at the first level. If you want multi-level filtering, please use the 'execute python' action with the 'filter a list' function in the code editor.") + } else if (selectedAction.parameters[1].value.includes(".#")) { + toast.warn("This filter may not work due to using .# indexing. Please use the 'execute python' action and try the 'filter a list' function in the code editor.") + } } else { // Remove .# and after const splitparsed = parsedvalue.split(".#") diff --git a/frontend/src/components/ShuffleCodeEditor1.jsx b/frontend/src/components/ShuffleCodeEditor1.jsx index 1f713c5a..2601af05 100644 --- a/frontend/src/components/ShuffleCodeEditor1.jsx +++ b/frontend/src/components/ShuffleCodeEditor1.jsx @@ -89,6 +89,7 @@ const liquidFilters = [ const pythonFilters = [ { "name": "Hello World", "value": `print("hello world")`, "example": `` }, { "name": "Using Shuffle variables", "value": `import json\nnodevalue = r\"\"\"$exec\"\"\"\nif not nodevalue:\n nodevalue = r\"\"\"{\"sample\": \"string\", \"int\": 1}\"\"\"\n \njsondata = json.loads(nodevalue)\nprint(jsondata)`, "example": `` }, + { "name": "Filter a list", "value": `import json\nnodevalue = r\"\"\"$exec\"\"\"\nif not nodevalue:\n nodevalue = r\"\"\"[{\"sample\": \"string\", \"int\": 1, "malicious": "no"}, {\"sample\": \"string2\", \"int\": 1, "malicious": "yes"}]\"\"\"\n \njsondata = json.loads(nodevalue)\nfiltered = []\nfor item in jsondata:\n try:\n if item[\"malicious\"] == \"yes\":\n filtered.append(item)\n except:\n pass\nprint(json.dumps(filtered))`, "example": `` }, { "name": "Print Execution ID", "value": `print(self.current_execution_id)`, "example": `` }, { "name": "Get full execution details", "value": `print(self.full_execution)`, "example": `` }, { "name": "Use files", "value": `# Create a sample file\nfiles = [{\n \"filename\": \"test.txt\",\n \"data\": \"Testdata\"\n}]\nret = self.set_files(files)\n\n# Get the content of the file from Shuffle storage\n# Originally a byte string in the \"data\" key\nfile_content = (self.get_file(ret[0])[\"data\"]).decode()\nprint(file_content)`, "example": `` }, diff --git a/frontend/src/views/AngularWorkflow.jsx b/frontend/src/views/AngularWorkflow.jsx index bdbaf1c2..16d5e9d0 100755 --- a/frontend/src/views/AngularWorkflow.jsx +++ b/frontend/src/views/AngularWorkflow.jsx @@ -74,6 +74,7 @@ import { import { + Storage as StorageIcon, Code as CodeIcon, Folder as FolderIcon, VerifiedUser as VerifiedUserIcon, @@ -503,7 +504,7 @@ const AngularWorkflow = (defaultprops) => { const [allRevisions, setAllRevisions] = useState([]) const [menuPosition, setMenuPosition] = useState(null); const [showDropdown, setShowDropdown] = React.useState(false); - const [subflowActionList, setSubflowActionList] = React.useState([]); + const [triggerActionList, setTriggerActionList] = React.useState([]); const [apps, setApps] = React.useState([]); const [filteredApps, setFilteredApps] = React.useState([]); @@ -574,11 +575,12 @@ const AngularWorkflow = (defaultprops) => { }, [editWorkflowModalOpen]) useEffect(() => { - if (selectedTrigger !== undefined && selectedTrigger?.parameters !== undefined && selectedTrigger?.parameters !== null && selectedTrigger?.parameters?.length > 1) { - // Right now just setting for the subflow - setSelectedTriggerValue(selectedTrigger?.parameters[1]?.value) - } - }, [selectedTrigger]) + if(selectedTrigger?.trigger_type === "SUBFLOW"){ + setSelectedTriggerValue(workflow.triggers[selectedTriggerIndex].parameters[1].value || "") + } else if(selectedTrigger?.trigger_type === "USERINPUT"){ + setSelectedTriggerValue(workflow.triggers[selectedTriggerIndex].parameters[0].value || "Do you want to continue the workflow? Start parameters: $exec") + } + }, [selectedTriggerIndex, selectedTrigger, workflow]) useEffect(() => { if(selectedEdge && Object.keys(selectedEdge).length > 0){ @@ -1136,23 +1138,66 @@ const AngularWorkflow = (defaultprops) => { useEffect(() => { // if (subflowActionList.length === 0) { const newActionList = []; - // FIXME: Have previous execution values in here - newActionList.push({ - type: "Runtime Argument", - name: "Runtime Argument", - value: "$exec", - highlight: "exec", - autocomplete: "exec", - example: "hello", - }) - newActionList.push({ - type: "Shuffle Database", - name: "Shuffle Database", + + + if (workflowExecutions.length > 0) { + for (let execution of workflowExecutions) { + const execArg = execution.execution_argument; + if (execArg && execArg.length > 0) { + const valid = validateJson(execArg); + if (valid.valid) { + newActionList.push({ + type: "Runtime Argument", + name: "Runtime Argument", + value: "$exec", + highlight: "exec", + autocomplete: "exec", + example: valid.result, + }) + + break + } + } + } + } + + // Add default Runtime Argument if none were added + if (newActionList.length === 0) { + newActionList.push({ + type: "Runtime Argument", + name: "Runtime Argument", + value: "$exec", + highlight: "exec", + autocomplete: "exec", + example: "", + }) + } + // Add Shuffle DB with cache keys if available + let cacheKey = { + type: "Shuffle DB", + name: "Shuffle DB", value: "$shuffle_cache", - highlight: "shuffle_db", + highlight: "shuffle_cache", autocomplete: "shuffle_cache", - example: "hello", - }) + example: "Hello", + } + + if (listCache?.keys?.length > 0) { + cacheKey.example = {}; + for (let item of listCache.keys) { + if (item.key) { + let itemValue = item.value ?? ""; + if (itemValue.length > 10000) { + itemValue = ""; + } + cacheKey.example[item.key.split(" ").join("_")] = { value: itemValue }; + } + } + } + + newActionList.push(cacheKey); + // FIXME: Have previous execution values in here + if ( workflow.workflow_variables !== null && workflow.workflow_variables !== undefined && @@ -1216,7 +1261,7 @@ const AngularWorkflow = (defaultprops) => { const validated = validateJson(foundResult.result) if (validated.valid) { - exampledata = validateJson.result + exampledata = validated.result break } } @@ -1234,7 +1279,7 @@ const AngularWorkflow = (defaultprops) => { } } - setSubflowActionList(newActionList); + setTriggerActionList(newActionList); }, [workflow.workflow_variables, workflow.execution_variables, workflow, selectedTrigger]); @@ -13779,6 +13824,7 @@ const AngularWorkflow = (defaultprops) => { setSourceValue({}); setConditionValue({}); setDestinationValue({}); + navigate("") }} color="secondary" > @@ -13789,6 +13835,7 @@ const AngularWorkflow = (defaultprops) => { variant="contained" onClick={() => { setSelectedEdge({}); + navigate("") var data = { condition: conditionValue, @@ -14613,43 +14660,62 @@ const AngularWorkflow = (defaultprops) => { }; const handleItemClick = (values) => { - console.log("VALUES: ", values) - if (values === undefined || values === null || values.length === 0) { - return; + console.log("VALUES: ", values) + if (values === undefined || values === null || values.length === 0) { + return; + } + + // Get the base autocomplete value and normalize it + let toComplete = values[0].autocomplete.toLowerCase().replaceAll(" ", "_"); + + // Add $ prefix if not already present + if (!toComplete.startsWith("$")) { + toComplete = "$" + toComplete; + } + + // Add any additional path components + for (let key in values) { + if (key === "0" || !values[key].autocomplete) { + continue; } + toComplete += values[key].autocomplete; + } - /* - workflow.triggers[selectedTriggerIndex].parameters[1].value - .trim() - .endsWith("$") - ? values[0].autocomplete - : "$" + values[0].autocomplete; + var foundField; + // Update the field value + if(selectedTrigger?.trigger_type === "SUBFLOW"){ + foundField = document.getElementById("subflow_exec_field"); + }else if(selectedTrigger?.trigger_type === "USERINPUT"){ + foundField = document.getElementById("userinput_info_field"); + } - for (var key in values) { - if (key === 0 || values[key].autocomplete.length === 0) { - continue; - } + if (foundField) { + // Get current cursor position + const cursorPos = foundField.selectionStart; + const currentValue = foundField.value; - toComplete += values[key].autocomplete - } - */ - - if (selectedTrigger.name === "Shuffle Workflow") { - const toComplete = workflow?.triggers?.[selectedTriggerIndex]?.parameters?.[1]?.value + "$" + values[0]?.autocomplete - // selectedTrigger.parameters[1].value = toComplete - workflow.triggers[selectedTriggerIndex].parameters[1].value = toComplete - const foundfield = document.getElementById("subflow_exec_field") - if (foundfield !== undefined && foundfield !== null) { - foundfield.value = toComplete - } - setWorkflow(workflow) + // Insert the new value at cursor position + const newValue = currentValue.slice(0, cursorPos) + toComplete + currentValue.slice(cursorPos); + foundField.value = newValue; + + // Update state + setSelectedTriggerValue(newValue); + if(selectedTrigger?.trigger_type === "SUBFLOW"){ + workflow.triggers[selectedTriggerIndex].parameters[1].value = newValue + }else if(selectedTrigger?.trigger_type === "USERINPUT"){ + workflow.triggers[selectedTriggerIndex].parameters[0].value = newValue } - setUpdate(Math.random()); - setShowDropdown(false); - setMenuPosition(null); - }; + // Set cursor position after inserted text + foundField.setSelectionRange(cursorPos + toComplete.length, cursorPos + toComplete.length); + } + + setWorkflow(workflow); + setUpdate(Math.random()); + setShowDropdown(false); + setMenuPosition(null); +}; const subflowtypes = [ { @@ -14676,7 +14742,7 @@ const AngularWorkflow = (defaultprops) => { workflow.triggers[triggerIndex].parameters[paramIndex].value = newData; // Update workflow state to trigger re-render - setWorkflow({...workflow}); + setWorkflow(workflow); setSelectedTriggerValue(newData) setLastSaved(false); } @@ -15166,7 +15232,7 @@ const AngularWorkflow = (defaultprops) => { "name": workflow.triggers[selectedTriggerIndex].parameters[1].name, "value": parsedvalue, "field_number": 1, - "actionlist": subflowActionList, + "actionlist": triggerActionList, "field_id": "subflow_exec_field", }) }} @@ -15211,15 +15277,13 @@ const AngularWorkflow = (defaultprops) => { fullWidth color="primary" placeholder="Some execution data" - value={ - selectedTriggerValue - } + value={selectedTriggerValue || ""} onChange={(e) => { setLastSaved(false) setSelectedTriggerValue(e.target.value) }} - onBlur={() => { - workflow.triggers[selectedTriggerIndex].parameters[1].value = selectedTriggerValue + onBlur={(e) => { + workflow.triggers[selectedTriggerIndex].parameters[1].value = e.target.value setWorkflow(workflow) }} /> @@ -15237,16 +15301,17 @@ const AngularWorkflow = (defaultprops) => { marginTop: 2, }} > - {subflowActionList.map((innerdata) => { + {triggerActionList.map((innerdata) => { const icon = innerdata.type === "action" ? ( ) : innerdata.type === "workflow_variable" || innerdata.type === "execution_variable" ? ( - ) : ( - - ); + ) : innerdata.type === "Shuffle DB" ? + + : + const handleExecArgumentHover = (inside) => { var exec_text_field = document.getElementById( @@ -16682,21 +16747,43 @@ const AngularWorkflow = (defaultprops) => { */}
-
-
- Information - - The information you want to show the user. Supports variables. Supports Markdown & HTML. - -
+
+
+ Information + + { + event.preventDefault() + setCodeEditorModalOpen(true) + setActiveDialog("codeeditor") + var parsedvalue = workflow?.triggers[selectedTriggerIndex]?.parameters[0]?.value + + navigate(`?trigger_id=${selectedTrigger.id}&trigger_field=${"alertinfo"}&trigger_name=${selectedTrigger.label}`) + setEditorData({ + "name": workflow.triggers[selectedTriggerIndex].parameters[0].name, + "value": parsedvalue, + "field_number": 0, + "actionlist": triggerActionList, + "field_id": "information_field", + }) + }} + > + + + +
+ + The information you want to show the user. Supports variables. Supports Markdown & HTML. +
{ InputProps={{ style: { }, + endAdornment: ( + + + { + setMenuPosition({ + top: event.pageY + 10, + left: event.pageX + 10, + }); + //setShowDropdownNumber(3) + setShowDropdown(true); + }} + /> + + + ), }} fullWidth rows="4" multiline - defaultValue={ - workflow.triggers !== undefined && workflow.triggers !== null && workflow.triggers[selectedTriggerIndex].parameters !== undefined && workflow.triggers[selectedTriggerIndex].parameters.length > 0 && workflow.triggers[selectedTriggerIndex].parameters[0] !== undefined && workflow.triggers[selectedTriggerIndex].parameters[0].value !== undefined ? workflow.triggers[selectedTriggerIndex].parameters[0].value : "" - } + value={selectedTriggerValue} + onChange={(e) => { + setLastSaved(false) + setSelectedTriggerValue(e.target.value) + }} color="primary" placeholder="" onBlur={(e) => { setTriggerTextInformationWrapper(e.target.value); }} /> + {!showDropdown ? null : + { + handleMenuClose(); + }} + open={!!menuPosition} + style={{ + border: `2px solid #FF8544`, + color: "white", + marginTop: 2, + }} + > + {triggerActionList.map((innerdata) => { + const icon = + innerdata.type === "action" ? ( + + ) : innerdata.type === "workflow_variable" || + innerdata.type === "execution_variable" ? ( + + ) : innerdata.type === "Shuffle DB" ? + + : + + + const handleExecArgumentHover = (inside) => { + var exec_text_field = document.getElementById( + "execution_argument_input_field" + ); + if (exec_text_field !== null) { + if (inside) { + exec_text_field.style.border = "2px solid #FF8544"; + } else { + exec_text_field.style.border = ""; + } + } + + // Also doing arguments + if ( + workflow.triggers !== undefined && + workflow.triggers !== null && + workflow.triggers.length > 0 + ) { + for (let triggerkey in workflow.triggers) { + const item = workflow.triggers[triggerkey]; + + if (cy !== undefined && cy !== null) { + var node = cy.getElementById(item.id); + if (node.length > 0) { + if (inside) { + node.addClass("shuffle-hover-highlight"); + } else { + node.removeClass("shuffle-hover-highlight"); + } + } + } + } + } + } + + const handleActionHover = (inside, actionId) => { + if (cy !== undefined && cy !== null) { + var node = cy.getElementById(actionId); + if (node.length > 0) { + if (inside) { + node.addClass("shuffle-hover-highlight"); + } else { + node.removeClass("shuffle-hover-highlight"); + } + } + } + }; + + const handleMouseover = () => { + if (innerdata.type === "Runtime Argument") { + handleExecArgumentHover(true); + } else if (innerdata.type === "action") { + handleActionHover(true, innerdata.id); + } + }; + + const handleMouseOut = () => { + if (innerdata.type === "Runtime Argument") { + handleExecArgumentHover(false); + } else if (innerdata.type === "action") { + handleActionHover(false, innerdata.id); + } + }; + + var parsedPaths = []; + + if (innerdata.type === "workflow_variable") { + // Try to parse the value if it's a string that could be JSON + if (typeof innerdata.value === "string") { + try { + const parsedValue = JSON.parse(innerdata.value) + if (typeof parsedValue === "object") { + parsedPaths = GetParsedPaths(parsedValue, ""); + } + } catch (e) { + // Not valid JSON, use the value directly + parsedPaths = GetParsedPaths(innerdata.value, ""); + } + } else if (typeof innerdata.value === "object") { + parsedPaths = GetParsedPaths(innerdata.value, ""); + } + } else if (typeof innerdata.example === "object") { + parsedPaths = GetParsedPaths(innerdata.example, ""); + } + + const coverColor = "#82ccc3" + + return parsedPaths.length > 0 ? ( + + {/* + + {icon} {innerdata.name} +
+ } + parentMenuOpen={!!menuPosition} + style={{ + backgroundColor: theme.palette.inputColor, + color: "white", + minWidth: 250, + }} + onClick={() => { + handleItemClick([innerdata]); + }} + > + {parsedPaths.map((pathdata, index) => { + // FIXME: Should be recursive in here + const icon = + pathdata.type === "value" ? ( + + ) : pathdata.type === "list" ? ( + + ) : ( + + ) + + return ( + { }} + onClick={() => { + handleItemClick([innerdata, pathdata]); + }} + > + +
+ {icon} {pathdata.name} +
+
+
+ ); + })} + + */} + + + {icon} {innerdata.name} +
+ } + parentMenuOpen={!!menuPosition} + style={{ + color: "white", + minWidth: 250, + maxWidth: 250, + maxHeight: 50, + overflow: "hidden", + }} + onClick={() => { + console.log(innerdata.example) + handleItemClick([innerdata]); + }} + > + + + { + //console.log("HOVER: ", pathdata); + }} + onClick={() => { + handleItemClick([innerdata]); + }} + > + + {innerdata.name} + + + + {parsedPaths.map((pathdata, index) => { + // FIXME: Should be recursive in here + // + const icon = + pathdata.type === "value" ? ( + + ) : pathdata.type === "list" ? ( + + ) : ( + + ); + // + + const indentation_count = (pathdata.name.match(/\./g) || []).length + 1 + const baseIndent =
+ //const boxPadding = pathdata.type === "object" ? "10px 0px 0px 0px" : 0 + const boxPadding = 0 + const namesplit = pathdata.name.split(".") + const newname = namesplit[namesplit.length - 1] + return ( + { + //console.log("HOVER: ", pathdata); + }} + onClick={() => { + handleItemClick([innerdata, pathdata]); + }} + > + +
+ {Array(indentation_count).fill().map((subdata, subindex) => { + return ( + baseIndent + ) + })} + {icon} {newname} + {pathdata.type === "list" ? { + + }} /> : null} +
+
+
+ ); + })} + + + + ) : ( + handleMouseover()} + onMouseOut={() => { + handleMouseOut(); + }} + onClick={() => { + handleItemClick([innerdata]); + }} + > + +
+ {icon} {innerdata.name} +
+
+
+ ); + })} + + }
{ src={validate.result} theme={theme.palette.jsonTheme} style={theme.palette.reactJsonStyle} - collapsed={false} shouldCollapse={(jsonField) => { return collapseField(jsonField) }} @@ -20186,7 +20600,6 @@ const AngularWorkflow = (defaultprops) => { src={validate.result} theme={theme.palette.jsonTheme} style={theme.palette.reactJsonStyle} - collapsed={parsedCollapse} shouldCollapse={(jsonField) => { return collapseField(jsonField) }} @@ -21562,7 +21975,6 @@ const AngularWorkflow = (defaultprops) => { src={validate.result} theme={theme.palette.jsonTheme} style={theme.palette.reactJsonStyle} - collapsed={false} shouldCollapse={(jsonField) => { return collapseField(jsonField) }} @@ -21724,7 +22136,6 @@ const AngularWorkflow = (defaultprops) => { src={checked.result} theme={theme.palette.jsonTheme} style={theme.palette.reactJsonStyle} - collapsed={data.value.length < 10000 ? false : true} shouldCollapse={(jsonField) => { return collapseField(jsonField) }} @@ -22150,7 +22561,6 @@ const AngularWorkflow = (defaultprops) => { src={validate.result} theme={theme.palette.jsonTheme} style={theme.palette.reactJsonStyle} - collapsed={selectedResult.result.length < 10000 ? false : true} shouldCollapse={(jsonField) => { return collapseField(jsonField) }} @@ -22211,6 +22621,7 @@ const AngularWorkflow = (defaultprops) => {
)} +
{selectedResult.action.parameters !== null && selectedResult.action.parameters !== undefined ? ( diff --git a/frontend/src/views/Apps2.jsx b/frontend/src/views/Apps2.jsx index e910417c..9b347eae 100644 --- a/frontend/src/views/Apps2.jsx +++ b/frontend/src/views/Apps2.jsx @@ -510,7 +510,6 @@ const Hits = ({ fontFamily: theme?.typography?.fontFamily }} onClick={() => { - console.log("App modal", data) handleAppClick(data); }} > @@ -1299,6 +1298,8 @@ const Apps2 = (props) => { //console.log("Failed to get apps from localstorage: ", e) } + setIsLoading(true); + fetch(globalUrl + "/api/v1/apps", { method: "GET", headers: {