diff --git a/frontend/public/images/icons/aws_logo.svg b/frontend/public/images/icons/aws_logo.svg new file mode 100644 index 00000000..f023cba5 --- /dev/null +++ b/frontend/public/images/icons/aws_logo.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/AdminNavBar.jsx b/frontend/src/components/AdminNavBar.jsx index 6a771afd..38aff384 100644 --- a/frontend/src/components/AdminNavBar.jsx +++ b/frontend/src/components/AdminNavBar.jsx @@ -142,6 +142,10 @@ const AdminNavBar = (props) => { // navigate(`?tab=organization`, { replace: true }); // } // } + const adminTab = queryParams.get('admin_tab'); + if (adminTab) { + setSelectedItem("Organization"); + } if (partnerTab) { setSelectedItem("Partner"); } diff --git a/frontend/src/components/ApiExplorer.jsx b/frontend/src/components/ApiExplorer.jsx index 3bf6785b..2e5009fa 100644 --- a/frontend/src/components/ApiExplorer.jsx +++ b/frontend/src/components/ApiExplorer.jsx @@ -1977,7 +1977,7 @@ const Action = memo(( const actionId = action.name.replace(/ /g, "-").replace(/_/g, "-"); window.history.pushState(null, "", `#${actionId}`); setExampleBody(action?.example_response); - document.getElementById(`action-list-${nextSelectedActionIndex}`).scrollIntoView({ behavior: "smooth", block: "center" }); + document.getElementById(`action-list-${nextSelectedActionIndex}`)?.scrollIntoView({ behavior: "smooth", block: "center" }); } }, 300); } diff --git a/frontend/src/components/AppAuthTab.jsx b/frontend/src/components/AppAuthTab.jsx index 7c404e1c..af7fbef2 100644 --- a/frontend/src/components/AppAuthTab.jsx +++ b/frontend/src/components/AppAuthTab.jsx @@ -20,6 +20,7 @@ import { isMobile } from "react-device-detect" import PaperComponent from "../components/PaperComponent.jsx"; import { CodeHandler, Img, OuterLink, } from '../views/Docs.jsx' import { v4 as uuidv4} from "uuid"; +import DeleteConfirmDialog from "./DeleteConfirmDialog.jsx"; import { Divider, @@ -63,10 +64,11 @@ import { } from "react-instantsearch-dom"; import aa from "search-insights"; import { Context } from '../context/ContextApi.jsx'; +import SubOrgDistributionDialog from './SubOrgDistributionDialog.jsx'; const searchClient = algoliasearch( "JNSS5CFDZZ", - "c8f882473ff42d41158430be09ec2b4e" + "33e4e3564f4f060e96e0531957bed552" ) const AppAuthTab = memo((props) => { @@ -89,9 +91,13 @@ const AppAuthTab = memo((props) => { const [searchQuery, setSearchQuery] = React.useState(""); const [showAppModal, setShowAppModal] = useState(false) const [selectedAuthId, setSelectedAuthId] = useState(""); + const [selectedAuthName, setSelectedAuthName] = useState(""); const [showDistributionPopup, setShowDistributionPopup] = useState(false); const [showAuthenticationLoader, setShowAuthenticationLoader] = useState(true) const [showAppAuthGroupLoader, setShowAppAuthGroupLoader] = useState(true) + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [deleteConfirmTarget, setDeleteConfirmTarget] = useState(null); + const [distribOrgOrder, setDistribOrgOrder] = useState([]); const { themeMode, supportEmail, brandColor } = useContext(Context) const theme = getTheme(themeMode, brandColor) @@ -183,7 +189,7 @@ const AppAuthTab = memo((props) => { }; const deleteAuthentication = (data) => { - toast("Deleting auth " + data.label); + toast("Deleting auth " + data?.label); // Just use this one? const url = globalUrl + "/api/v1/apps/authentication/" + data.id; @@ -213,33 +219,6 @@ const AppAuthTab = memo((props) => { }); }; - const handleSelectSubOrg = (id, action) => { - if (action === "all") { - const childOrgs = userdata.orgs.filter( - (data) => data.creator_org === userdata.active_org.id - ); - setSelectedSubOrg((prev) => { - if (prev.length === childOrgs.length) { - // If all child orgs are already selected, clear the selection - return []; - } else { - // Otherwise, select all child org IDs - return childOrgs.map((data) => data.id); - } - }); - } else if (action === "none") { - setSelectedSubOrg([]); - } else { - setSelectedSubOrg((prev) => { - if (prev.includes(id)) { - return prev.filter((data) => data !== id); - } else { - return [...prev, id]; - } - }); - } - }; - const editAuthenticationConfig = (id, parentAction, selectedSuborgs) => { const data = { id: id, @@ -284,100 +263,22 @@ const AppAuthTab = memo((props) => { editAuthenticationConfig(id, "suborg_distribute", [...new Set(selectedSubOrg)]) } - - - const cacheDistributionModal = showDistributionPopup ? ( - {setShowDistributionPopup(false);setSelectedAuthId("")}} - PaperProps={{ - sx: { - borderRadius: theme?.palette?.DialogStyle?.borderRadius, - border: theme?.palette?.DialogStyle?.border, - fontFamily: theme?.typography?.fontFamily, - backgroundColor: theme?.palette?.DialogStyle?.backgroundColor, - zIndex: 1000, - minWidth: "600px", - minHeight: "320px", - overflow: "auto", - '& .MuiDialogContent-root': { - backgroundColor: theme?.palette?.DialogStyle?.backgroundColor, - }, - '& .MuiDialogTitle-root': { - backgroundColor: theme?.palette?.DialogStyle?.backgroundColor, - }, - '& .MuiDialogActions-root': { - backgroundColor: theme?.palette?.DialogStyle?.backgroundColor, - }, - }, - }} - > - - - Select sub-org to distribute Datastore key - - - - {handleSelectSubOrg(null, "none")}}>None - {handleSelectSubOrg(null, "all")}}>All - {userdata.orgs.map((data, index) => { - if (data.creator_org !== userdata.active_org.id) { - return null; - } - - const imagesize = 22; - const imageStyle = { - width: imagesize, - height: imagesize, - pointerEvents: "none", - marginRight: 10, - marginLeft: data.id === userdata.active_org.id ? 0 : 20, - }; - - const image = data.image === "" ? ( - {data.name} - ) : ( - {data.name} - ); - return ( - handleSelectSubOrg(data.id)} - style={{ display: "flex", alignItems: "center" }} - > - - {image} - {data.name} - - ); - })} - -
- - -
-
-
- ) : null; + const deleteConfirmDialog = ( + { setDeleteConfirmOpen(false); setDeleteConfirmTarget(null); }} + onConfirm={() => { + deleteAuthentication(deleteConfirmTarget); + setDeleteConfirmOpen(false); + setDeleteConfirmTarget(null); + }} + title="Delete Authentication?" + description={<>Are you sure you want to delete {deleteConfirmTarget?.app?.name} authentication?} + warningText="This cannot be undone. Any workflows using this authentication will lose access." + /> + ); + const editAuthenticationModal = selectedAuthenticationModalOpen ? ( { return (
{appModal} - {cacheDistributionModal} + { setShowDistributionPopup(false); setSelectedAuthId(""); setSelectedAuthName(""); }} + title="Distribute App Auth to Sub-Organizations" + extraInfo={selectedAuthName ? `Selected Auth: ${selectedAuthName}` : null} + orgs={distribOrgOrder.map(id => (userdata?.orgs || []).find(o => o.id === id)).filter(Boolean)} + selectedOrgIds={selectedSubOrg} + onSelectionChange={setSelectedSubOrg} + onSave={(ids) => { changeDistribution(selectedAuthId, ids); }} + /> + {deleteConfirmDialog}
@@ -1362,10 +1273,10 @@ const AppAuthTab = memo((props) => { { - deleteAuthentication(data); + setDeleteConfirmTarget(data); + setDeleteConfirmOpen(true); }} > delete icon @@ -1400,21 +1311,27 @@ const AppAuthTab = memo((props) => { color="secondary" onClick={() => { setShowDistributionPopup(true) - if(data?.suborg_distribution?.length > 0){ - setSelectedSubOrg(data.suborg_distribution) - }else{ - setSelectedSubOrg([]) - } - setSelectedAuthId(data.id) + let initialSelected = []; if (data?.suborg_distributed) { - const allSuborg = userdata?.orgs?.map((data, index) => { - if (data.creator_org !== userdata.active_org.id) { - return null; - } - return data.id; - }) - setSelectedSubOrg(allSuborg.filter((data) => data !== null)) + const allSuborg = userdata?.orgs?.map((d) => { + if (d.creator_org !== userdata.active_org.id) return null; + return d.id; + }); + initialSelected = allSuborg.filter((d) => d !== null); + } else if (data?.suborg_distribution?.length > 0) { + initialSelected = data.suborg_distribution; } + setSelectedSubOrg(initialSelected); + setSelectedAuthId(data.id); + setSelectedAuthName(data?.app?.name); + const suborgs = (userdata?.orgs || []).filter(o => o.creator_org === userdata?.active_org?.id); + const sorted = [...suborgs].sort((a, b) => { + const aS = initialSelected.includes(a.id); + const bS = initialSelected.includes(b.id); + if (aS !== bS) return bS - aS; + return a.name.localeCompare(b.name); + }); + setDistribOrgOrder(sorted.map(o => o.id)); }} /> diff --git a/frontend/src/components/AppCreationModal.jsx b/frontend/src/components/AppCreationModal.jsx index 6a66f0e0..e1e62e28 100644 --- a/frontend/src/components/AppCreationModal.jsx +++ b/frontend/src/components/AppCreationModal.jsx @@ -708,7 +708,7 @@ const AppCreationModal = ({ open, onClose, theme, globalUrl, isCloud, startOpenA fontWeight: 500, fontFamily: theme?.typography?.fontFamily, }}> - Generate an app based on documentation (beta) + Generate an app based on documentation { diff --git a/frontend/src/components/AppGrid.jsx b/frontend/src/components/AppGrid.jsx index cd37fb23..68b4f7db 100644 --- a/frontend/src/components/AppGrid.jsx +++ b/frontend/src/components/AppGrid.jsx @@ -1,10 +1,9 @@ -import React, { useEffect, useState, useRef } from "react"; +import React, { useEffect, useState, useRef, useMemo } from "react"; import theme from "../theme.jsx"; import ReactGA from "react-ga4"; import { Link } from "react-router-dom"; import { removeQuery } from "../components/ScrollToTop.jsx"; -import { useMemo } from "react"; import { Tabs, Tab, Collapse } from "@mui/material"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; @@ -27,7 +26,7 @@ import { InstantSearch, Configure, connectSearchBox, - connectHits, + connectInfiniteHits, connectHitInsights, RefinementList, ClearRefinements, @@ -39,6 +38,7 @@ import aa from "search-insights"; import { useLocation } from 'react-router-dom'; import "./FilterCSS.css"; +import SearchContactForm from "../components/SearchContactForm.jsx"; import { Zoom, @@ -54,7 +54,7 @@ import { const searchClient = algoliasearch( "JNSS5CFDZZ", - "c8f882473ff42d41158430be09ec2b4e" + "eb5fd80aa6ed5ab4730d836cff3ea283" ); //const searchClient = algoliasearch("L55H18ZINA", "a19be455e7e75ee8f20a93d26b9fc6d6") @@ -77,62 +77,13 @@ const AppGrid = (props) => { const xs = parsedXs === undefined || parsedXs === null ? (isMobile ? 6 : 3) : parsedXs; - const [formMail, setFormMail] = React.useState(""); - const [message, setMessage] = React.useState(""); - const [formMessage, setFormMessage] = React.useState(""); const [deactivatedIndexes, setDeactivatedIndexes] = React.useState([]); - const buttonStyle = { - borderRadius: 30, - height: 50, - width: 220, - margin: isMobile ? "15px auto 15px auto" : 20, - fontSize: 18, - }; const innerColor = "rgba(255,255,255,0.65)"; const borderRadius = 3; window.title = "Shuffle | Apps | Find and integrate any app"; const noImage = "/public/no_image.png"; - const submitContact = (email, message) => { - const data = { - firstname: "", - lastname: "", - title: "", - companyname: "", - email: email, - phone: "", - message: message, - }; - - const errorMessage = - "Something went wrong. Please contact frikky@shuffler.io directly."; - - fetch(globalUrl + "/api/v1/contact", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(data), - }) - .then((response) => response.json()) - .then((response) => { - if (response?.success === true) { - setFormMessage(response.reason); - //toast("Thanks for submitting!") - } else { - setFormMessage(errorMessage); - } - - setFormMail(""); - setMessage(""); - }) - .catch((error) => { - setFormMessage(errorMessage); - console.log(error); - }); - }; - const SearchBox = ({ currentRefinement, refine, isSearchStalled, searchQuery, setSearchQuery }) => { var defaultSearch = ""; @@ -148,10 +99,11 @@ const AppGrid = (props) => { const params = Object.fromEntries(urlSearchParams.entries()); const foundQuery = params["q"]; if (foundQuery !== null && foundQuery !== undefined) { - console.log("Got query: ", foundQuery); refine(foundQuery); defaultSearch = foundQuery; - searchQuery = foundQuery + if (searchQuery !== foundQuery) { + setSearchQuery(foundQuery); + } } } //}, []) @@ -234,7 +186,13 @@ const AppGrid = (props) => { onChange={(event) => { const value = event.currentTarget.value; setSearchQuery(value); - removeQuery("q"); + const urlSearchParams = new URLSearchParams(window.location.search); + if (value) { + urlSearchParams.set("q", value); + } else { + urlSearchParams.delete("q"); + } + window.history.replaceState({}, '', `${window.location.pathname}?${urlSearchParams.toString()}`); debouncedRefine(value); }} onKeyDown={(event) => { @@ -252,6 +210,21 @@ const AppGrid = (props) => { const [currTab, setCurrTab] = useState(0); const location = useLocation(); + const conditionalSearchClient = useMemo(() => ({ + ...searchClient, + search(requests) { + if (currTab !== 0) { + return Promise.resolve({ + results: requests.map(() => ({ + hits: [], nbHits: 0, page: 0, nbPages: 0, hitsPerPage: 0, + processingTimeMS: 0, exhaustiveNbHits: true, query: "", params: "", + })), + }); + } + return searchClient.search(requests); + }, + }), [currTab]); + useEffect(() => { const queryParams = new URLSearchParams(location.search); const tabParam = queryParams.get('tab'); @@ -269,7 +242,6 @@ const AppGrid = (props) => { const newQueryParam = newTab === 0 ? 'all_apps' : newTab === 1 ? 'org_apps' : 'my_apps'; const queryParams = new URLSearchParams(location.search); queryParams.set('tab', newQueryParam); - queryParams.delete('q'); window.history.replaceState({}, '', `${location.pathname}?${queryParams.toString()}`); }; @@ -281,6 +253,8 @@ const AppGrid = (props) => { // Component to fetch all public app from the algolia. const Hits = ({ hits, + hasMore, + refineNext, insights, setIsAnyAppActivated, searchQuery @@ -288,6 +262,32 @@ const AppGrid = (props) => { const [mouseHoverIndex, setMouseHoverIndex] = useState(-1); var counted = 0; const [hoverEffect, setHoverEffect] = useState(-1); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const loadMoreRef = useRef(null); + const scrollContainerRef = useRef(null); + const isFetchingMore = useRef(false); + + useEffect(() => { + isFetchingMore.current = false; + setIsLoadingMore(false); + }, [hits.length]); + + useEffect(() => { + return; // infinite scroll disabled + if (!loadMoreRef.current || !scrollContainerRef.current) return; + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasMore && !isFetchingMore.current) { + isFetchingMore.current = true; + setIsLoadingMore(true); + refineNext(); + } + }, + { root: scrollContainerRef.current, rootMargin: "200px" } + ); + observer.observe(loadMoreRef.current); + return () => observer.disconnect(); + }, [hasMore, refineNext]); const normalizedString = (name) => { if (typeof name === 'string') { @@ -359,11 +359,11 @@ const AppGrid = (props) => { } else { //toast.success(`App ${type}d Successfully!`); if (type === 'activate') { - setAllActivatedAppIds(prev => [...prev, data.objectID]); + setAllActivatedAppIds(prev => [...(prev || []), data.objectID]); setIsAnyAppActivated(true); } if (type === 'deactivate') { - const updatedIds = allActivatedAppIds.filter(id => id !== data.objectID); + const updatedIds = (allActivatedAppIds || []).filter(id => id !== data.objectID); setAllActivatedAppIds(updatedIds); } } @@ -373,6 +373,16 @@ const AppGrid = (props) => { }); } + const sortedHits = useMemo(() => { + const list = [...(hits || [])]; + if (!allActivatedAppIds?.length) return list; + return list.sort((a, b) => { + const aActive = allActivatedAppIds.includes(a.objectID) ? 1 : 0; + const bActive = allActivatedAppIds.includes(b.objectID) ? 1 : 0; + return bActive - aActive; + }); + }, [hits, hits?.length, allActivatedAppIds]); + let workflowDelay = 0; const isHeader = true; const paperStyle = { @@ -404,6 +414,7 @@ const AppGrid = (props) => { ) : (
{ scrollbarColor: "#494949 #2f2f2f", }} > - {hits?.map((data, index) => { + {sortedHits.map((data, index) => { const appUrl = isCloud === true ? `/apps/${data.objectID}` @@ -629,6 +640,12 @@ const AppGrid = (props) => { ); }) } +
+ {isLoadingMore && ( +
+ +
+ )}
)} @@ -926,7 +943,7 @@ const AppGrid = (props) => { //Component to display all apps. const AllApps = ({ setIsAnyAppActivated }) => { - var [searchQuery, setSearchQuery] = useState(""); + var [searchQuery, setSearchQuery] = useState(() => new URLSearchParams(window.location.search).get('q') || ""); return (
{ }} onClick={() => { setSearchQuery(''); + removeQuery("q"); }} /> )} @@ -1015,7 +1033,15 @@ const AppGrid = (props) => { placeholder="Search your Activated or self-built apps" id="shuffle_search_field" onChange={(event) => { - setSearchQuery(event.currentTarget.value); + const value = event.currentTarget.value; + setSearchQuery(value); + const urlSearchParams = new URLSearchParams(window.location.search); + if (value) { + urlSearchParams.set("q", value); + } else { + urlSearchParams.delete("q"); + } + window.history.replaceState({}, '', `${window.location.pathname}?${urlSearchParams.toString()}`); }} onKeyDown={(event) => { if(event.key === "Enter") { @@ -1592,7 +1618,7 @@ const AppGrid = (props) => { //Component to fetch all apps created by user and Org const UserAndOrgApps = ({ selectedCategoryForUsersAndOgsApps, selectedTagsForUserAndOrgApps, selectedOptionOfCreatedWith, setselectedCategoryForUsersAndOgsApps, setSelectedTagsForUserAndOrgApps, setSelectedOptionOfCreatedWith }) => { - const [searchQuery, setSearchQuery] = useState(""); + const [searchQuery, setSearchQuery] = useState(() => new URLSearchParams(window.location.search).get('q') || ""); const [appsToShow, setAppsToShow] = useState([]); useEffect(() => { if (currTab === 1) { @@ -2003,7 +2029,7 @@ const AppGrid = (props) => { }; const CustomSearchBox = connectSearchBox(SearchBox); - const CustomHits = connectHits(Hits); + const CustomHits = connectInfiniteHits(Hits); const DisplayAllAppsTab = () => { const [selectedCategoryForUsersAndOgsApps, setselectedCategoryForUsersAndOgsApps] = useState([]); @@ -2012,7 +2038,7 @@ const AppGrid = (props) => { return (
- +
{currTab === 0 ? ( @@ -2037,7 +2063,7 @@ const AppGrid = (props) => { />
- + {currTab === 0 && }
); @@ -2060,80 +2086,7 @@ const AppGrid = (props) => { > {showSuggestion === true ? ( -
- - Can't find what you're looking for? - -
- setFormMail(e.target.value)} - /> - setMessage(e.target.value)} - /> -
- - - {formMessage} - -
+ ) : null}
diff --git a/frontend/src/components/AppModal.jsx b/frontend/src/components/AppModal.jsx index 38c8581f..b0f8728f 100644 --- a/frontend/src/components/AppModal.jsx +++ b/frontend/src/components/AppModal.jsx @@ -35,7 +35,7 @@ import { Context } from '../context/ContextApi.jsx'; const searchClient = algoliasearch( "JNSS5CFDZZ", - "c8f882473ff42d41158430be09ec2b4e" + "33e4e3564f4f060e96e0531957bed552" );; const AppModal = ({ open, onClose, app, globalUrl, getApps}) => { diff --git a/frontend/src/components/AppSearch1.jsx b/frontend/src/components/AppSearch1.jsx index 449ad925..d551841d 100644 --- a/frontend/src/components/AppSearch1.jsx +++ b/frontend/src/components/AppSearch1.jsx @@ -13,7 +13,7 @@ import { InputAdornment, Typography, } from '@mui/material'; -const searchClient = algoliasearch("JNSS5CFDZZ", "c8f882473ff42d41158430be09ec2b4e") +const searchClient = algoliasearch("JNSS5CFDZZ", "33e4e3564f4f060e96e0531957bed552") const Appsearch = props => { const { maxRows, showName, showSuggestion, isMobile, globalUrl, parsedXs, newSelectedApp, setNewSelectedApp, defaultSearch, showSearch, ConfiguredHits, userdata, cy, isCreatorPage, actionImageList, setActionImageList, setUserSpecialzedApp, placeholder, diff --git a/frontend/src/components/Appsearch.jsx b/frontend/src/components/Appsearch.jsx index 904cf7c2..5b529d9d 100644 --- a/frontend/src/components/Appsearch.jsx +++ b/frontend/src/components/Appsearch.jsx @@ -20,7 +20,7 @@ import { } from '@mui/material'; import aa from 'search-insights' -const searchClient = algoliasearch("JNSS5CFDZZ", "c8f882473ff42d41158430be09ec2b4e") +const searchClient = algoliasearch("JNSS5CFDZZ", "33e4e3564f4f060e96e0531957bed552") const Appsearch = props => { const { maxRows, showName, showSuggestion, isMobile, globalUrl, parsedXs, newSelectedApp, setNewSelectedApp, defaultSearch, showSearch, ConfiguredHits, userdata, cy, isCreatorPage, actionImageList, setActionImageList, setUserSpecialzedApp, inputHeight, apps, } = props const { themeMode } = useContext(Context) diff --git a/frontend/src/components/Billing.jsx b/frontend/src/components/Billing.jsx index 1a5d375d..959ce875 100644 --- a/frontend/src/components/Billing.jsx +++ b/frontend/src/components/Billing.jsx @@ -52,6 +52,7 @@ import { Cancel as CancelIcon, Shield as ShieldIcon, Cancel as XCircleIcon, + LockOutlined as LockIcon, FlashOn as ZapIcon, People as UsersIcon, FmdGoodOutlined as FmdGoodOutlinedIcon, @@ -81,7 +82,7 @@ const ProductionStatus = ({ selectedOrganization, userdata, isCloud, theme }) => } const themeMode = theme.palette.mode; - const workflowActive = selectedOrganization?.sync_features?.workflow_executions?.active; + const workflowActive = selectedOrganization?.sync_features?.app_executions?.active; const multiTenantActive = selectedOrganization?.sync_features?.multi_tenant?.active; const multiEnvActive = selectedOrganization?.sync_features?.multi_env?.active; const brandingActive = selectedOrganization?.sync_features?.branding?.active; @@ -104,9 +105,9 @@ const ProductionStatus = ({ selectedOrganization, userdata, isCloud, theme }) => const features = [ { icon: ZapIcon, - label: 'Workflow Executions', - licensed: `${selectedOrganization?.sync_features?.workflow_executions?.limit}/month limit`, - unlicensed: '10,000/month limit', + label: 'App Runs', + licensed: `${selectedOrganization?.sync_features?.app_executions?.limit}/month limit`, + unlicensed: '25,000/month limit', isActive: workflowActive, }, { @@ -203,7 +204,7 @@ const ProductionStatus = ({ selectedOrganization, userdata, isCloud, theme }) => > {isProdStatusOn ? 'Your organization has full access to all enterprise features and capabilities.' - : 'View your current limits and available features. Upgrade to unlock enterprise capabilities.'} + : 'Your organization is running on the open-source plan. Upgrade to Enterprise to remove limits and unlock advanced capabilities.'} {/* Features Grid */} @@ -223,8 +224,8 @@ const ProductionStatus = ({ selectedOrganization, userdata, isCloud, theme }) => }) .map((feature, index) => { const Icon = feature.icon; - const isAvailable = isProdStatusOn && feature.isActive; - const statusColor = isAvailable ? colors.success : colors.disabled; + const isAvailable = isProdStatusOn; + const statusColor = isAvailable ? colors.success : colors.warning; const bgColor = isAvailable ? themeMode === "dark" ? "#212121" : "#ffffff" : colors.disabledBg; return ( @@ -235,19 +236,34 @@ const ProductionStatus = ({ selectedOrganization, userdata, isCloud, theme }) => alignItems: 'center', gap: 14, padding: '14px 16px', + paddingLeft: !isAvailable ? 20 : 16, borderRadius: 10, background: bgColor, border: `1px solid ${isAvailable ? colors.success + '40' : colors.border}`, transition: 'all 0.2s', + position: 'relative', + overflow: 'hidden', }} > + {!isAvailable && ( +
+ )} + {/* Icon */}
fontSize: 15, fontWeight: 600, color: colors.textPrimary, - marginBottom: 4, + marginBottom: 3, }} > {feature.label} @@ -272,7 +288,7 @@ const ProductionStatus = ({ selectedOrganization, userdata, isCloud, theme }) =>
@@ -285,8 +301,8 @@ const ProductionStatus = ({ selectedOrganization, userdata, isCloud, theme }) => style={{ color: colors.success, fontSize: 20, flexShrink: 0 }} /> ) : ( - )}
@@ -304,80 +320,323 @@ const ProductionStatus = ({ selectedOrganization, userdata, isCloud, theme }) => /> {!isProdStatusOn && ( -
-
- -
- - About Shuffle Enterprise - - - Shuffle Enterprise is designed for organizations that require scalability, high - availability, dedicated support, and robust infrastructure to run mission-critical - workflows in production environments. learn more - + overflow: 'hidden', + border: `1px solid ${colors.accent}40`, + marginBottom: 24, + }}> + {/* Header */} +
+
+ +
+
+ + Unlock Shuffle Enterprise + + + Scale your security operations without limits + +
-
-
)} - {/* CTA Section */} - {!isProdStatusOn && ( -
-
- - Ready to upgrade? + {/* Body */} +
+
+ {[ + { icon: ZapIcon, text: 'Higher App Run Limits' }, + { icon: UsersIcon, text: 'Multi-Tenant Support' }, + { icon: ShieldIcon, text: 'Enterprise SLA' }, + { icon: FmdGoodOutlinedIcon, text: 'Multi-Location Deploy' }, + ].map((item, i) => { + const ItemIcon = item.icon; + return ( +
+ + + {item.text} + +
+ ); + })} +
+ + + Purpose-built for security teams that need scalability, high availability, and dedicated + expert support to run mission-critical workflows in production.{' '} + + Learn more + - + + +
-
)}
); }; +const AppRunsQueueCard = memo(({ environment, isAirGapped, isCloudSynching, totalRuns, limit, theme, navigate }) => { + + const usagePct = limit > 0 ? (totalRuns / limit) * 100 : 0; + const hardPauseLimit = limit * 2; + const hardPausePct = hardPauseLimit > 0 ? Math.min((totalRuns / hardPauseLimit) * 100, 100) : 0; + const mainBarPct = Math.min(usagePct, 100); + + const queueSize = environment?.queue !== undefined && environment?.queue !== null + ? Math.max(0, environment.queue) + : 0; + + const isThrottled = isCloudSynching ? false : (isAirGapped ? hardPausePct >= 100 : usagePct >= 100); + + let status, statusColor, statusBg; + if (isThrottled) { + status = 'Throttled'; + statusColor = '#ef4444'; + statusBg = 'rgba(239, 68, 68, 0.12)'; + } else if (!isCloudSynching && usagePct >= 80) { + status = 'Warning'; + statusColor = '#f59e0b'; + statusBg = 'rgba(245, 158, 11, 0.12)'; + } else { + status = 'Healthy'; + statusColor = theme.palette.green; + statusBg = `${theme.palette.green}1f`; + } + + const throttleRate = isThrottled ? '1/min' : '\u2014'; + const estClearTime = isThrottled && queueSize > 0 ? `${queueSize} min` : '\u2014'; + const mainBarColor = isThrottled ? '#ef4444' : usagePct >= 80 && !isCloudSynching ? '#f59e0b' : theme.palette.green; + + const envTypeLabel = environment?.run_type === 'cloud' ? 'Cloud' : 'On-prem'; + const envName = environment?.Name || environment?.name || 'Default'; + + const borderColor = theme.palette.slateGrayColor; + const trackBg = theme.palette.slateGrayColor; + const mutedText = theme.palette.text.secondary; + + return ( +
+ {/* Title row */} +
+ + {envTypeLabel} - {envName} · App runs / month + +
+ + {status} +
+
+ + {/* Main number */} +
+ + {totalRuns.toLocaleString()} + + + / {limit.toLocaleString()} + +
+ + {isAirGapped && !isCloudSynching && ( + + No throttle until {hardPauseLimit.toLocaleString()} runs · 2× your plan limit + + )} + + {/* Main usage bar */} +
+
+ {/* 80% threshold marker */} +
+
+
+ 0 + 80% threshold + {limit.toLocaleString()} +
+ + {/* Throttle limit row */} + {isAirGapped && ( + <> +
+ + Burst throttle threshold (2× limit) · workflows throttle to 1/min above this + + + {totalRuns.toLocaleString()} / {hardPauseLimit.toLocaleString()} + +
+ +
+
+
+ + )} + + {/* Alert box for Warning / Throttled */} + {status !== 'Healthy' && ( +
+ + {isThrottled ? 'Running slow \u2014 workflows are still running' : 'Approaching your monthly limit'} + + + {isThrottled + ? isAirGapped + ? `You've exceeded the burst threshold of ${hardPauseLimit.toLocaleString()} runs (2\u00d7 your plan limit). Your workflows are still running \u2014 there is no hard stop. Executions slow to 1 per minute until next month.` + : `You've exceeded your ${limit.toLocaleString()} monthly limit. Your instance keeps running \u2014 executions slow to 1 per minute until next month. Nothing is lost. You can view or clear the queue from the Locations tab.` + : isAirGapped + ? `You've used ${totalRuns.toLocaleString()} of ${limit.toLocaleString()} app runs. Workflows run normally \u2014 slowdown only begins at ${hardPauseLimit.toLocaleString()} runs (2\u00d7 your plan limit). No action needed.` + : `You've used ${totalRuns.toLocaleString()} of ${limit.toLocaleString()} app runs (${Math.max(0, limit - totalRuns).toLocaleString()} remaining). If you reach 100%, executions continue at a reduced rate of 1 per minute, nothing stops or is lost.` + } + + +
+ )} + + {/* Stats row */} +
+ {[ + { label: 'Queued jobs', value: queueSize }, + { label: 'Throttle rate', value: throttleRate }, + { label: 'Est. clear time', value: estClearTime }, + ].map((stat, i) => ( +
+ + {stat.label} + + + {stat.value} + +
+ ))} +
+
+ ); +}); + const Billing = memo((props) => { const { globalUrl, userdata, serverside, billingInfo, stripeKey,isLoaded, selectedOrganization, handleGetOrg, clickedFromOrgTab, removeCookie} = props; //const alert = useAlert(); @@ -413,7 +672,7 @@ const Billing = memo((props) => { const [statistics, setStatistics] = useState([]) const [monthlyAppRunsParent, setMonthlyAppRunsParent] = useState(0) const [monthlyAllSuborgExecutions, setMonthlyAllSuborgExecutions] = useState(0) - + const [billingEnvironments, setBillingEnvironments] = useState([]) useEffect(() => { if (monthlyAppRunsParent > 0 || monthlyAllSuborgExecutions > 0) { const percentage = ((monthlyAppRunsParent + monthlyAllSuborgExecutions) / userdata.app_execution_limit) * 100; @@ -525,6 +784,29 @@ const Billing = memo((props) => { }, []) + const getBillingEnvironments = () => { + fetch(globalUrl + "/api/v1/getenvironments", { + method: "GET", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + credentials: "include", + }) + .then((response) => { + if (response.status !== 200) return; + return response.json(); + }) + .then((responseJson) => { + if (responseJson && Array.isArray(responseJson)) { + setBillingEnvironments(responseJson); + } + }) + .catch((error) => { + console.log("Error fetching environments for billing:", error); + }); + }; + const getStats = (orgid) => { if (orgid === undefined || orgid === null) { @@ -563,6 +845,9 @@ const Billing = memo((props) => { useEffect(() => { if (selectedOrganization && selectedOrganization?.id?.length > 0) { getStats(selectedOrganization.id); + if (!isCloud) { + getBillingEnvironments(); + } } }, [selectedOrganization]); @@ -2387,6 +2672,17 @@ const Billing = memo((props) => { const isChildOrg = userdata?.active_org?.creator_org !== "" && userdata?.active_org?.creator_org !== undefined && userdata?.active_org?.creator_org !== null + const activeQueueEnvs = Array.isArray(billingEnvironments) ? billingEnvironments.filter(env => env != null && !env.archived && env.Type !== 'cloud') : []; + const totalQueueSize = activeQueueEnvs.reduce((sum, env) => sum + Math.max(0, env?.queue || 0), 0); + const aggregatedQueueEnv = { + Name: `${activeQueueEnvs.length} Runtime Location${activeQueueEnvs.length !== 1 ? 's' : ''}`, + run_type: 'on-prem', + queue: totalQueueSize, + }; + const appExecLimit = selectedOrganization?.sync_features?.app_executions?.limit ?? 0; + const isAirGapped = selectedOrganization != null && (selectedOrganization.cloud_sync_active === true || selectedOrganization.cloud_sync === true) ? false : appExecLimit < 300000 ? false : true; + const isCloudSynching = selectedOrganization != null && selectedOrganization.cloud_sync === true && appExecLimit >= 300000; + useEffect(() => { if (isChildOrg && currentTab === 0) { setCurrentTab(1); @@ -2800,6 +3096,28 @@ const Billing = memo((props) => {
) : null*/} + + {/* Queue Management */} + {!isCloud && activeQueueEnvs.length > 0 && !isChildOrg && ( +
+ + Queue Management + + + Real-time status of your app run usage and workflow queue across all runtime locations. + + +
+ )} + {!isChildOrg && isCloud && (
diff --git a/frontend/src/components/BillingStats.jsx b/frontend/src/components/BillingStats.jsx index d2d2e304..05796cbd 100644 --- a/frontend/src/components/BillingStats.jsx +++ b/frontend/src/components/BillingStats.jsx @@ -92,6 +92,11 @@ const AppStats = (defaultprops) => { const statKey = syncStats === true ? "onprem_stats" : "daily_statistics" const dailyStats = inputdata[statKey] if (dailyStats === undefined || dailyStats === null) { + setAppruns(undefined) + setWorkflowRuns(undefined) + setSubflowRuns(undefined) + setChildOrgsAppRuns(undefined) + setApprunCosts(undefined) return } @@ -378,6 +383,17 @@ const AppStats = (defaultprops) => { return } + if (syncStats && (statistics[statKey] === undefined || statistics[statKey] === null)) { + setOnpremAppRuns(0) + setFilteredStatistics(statistics) + setAppruns(undefined) + setWorkflowRuns(undefined) + setSubflowRuns(undefined) + setChildOrgsAppRuns(undefined) + setApprunCosts(undefined) + return + } + // Calculate month to date cost var mtd_cost = 0 for (let key in statistics[statKey]) { diff --git a/frontend/src/components/CacheView.jsx b/frontend/src/components/CacheView.jsx index ef5719e7..4c9fd46c 100644 --- a/frontend/src/components/CacheView.jsx +++ b/frontend/src/components/CacheView.jsx @@ -1,6 +1,8 @@ import React, { useState, useEffect, useContext, memo } from "react"; import { makeStyles } from "@mui/styles"; import { getTheme } from "../theme.jsx"; +import SubOrgDistributionDialog from "./SubOrgDistributionDialog.jsx"; +import DeleteConfirmDialog from "./DeleteConfirmDialog.jsx"; import { toast } from 'react-toastify'; import ReactJson from "react-json-view-ssr"; @@ -17,8 +19,6 @@ import { Button, Tabs, Tab, - List, - ListItem, ListItemText, IconButton, Dialog, @@ -82,6 +82,7 @@ import { Hub as HubIcon, Key as KeyIcon, FlashOn as FlashOnIcon, + Search as SearchIcon, } from "@mui/icons-material"; import { Context } from "../context/ContextApi.jsx"; @@ -127,6 +128,9 @@ const CacheView = memo((props) => { const [showDistributionPopup, setShowDistributionPopup] = useState(false); const [selectedSubOrg, setSelectedSubOrg] = useState([]); const [selectedCacheKey, setSelectedCacheKey] = useState(""); + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [deleteConfirmTarget, setDeleteConfirmTarget] = useState(null); + const [distribOrgOrder, setDistribOrgOrder] = useState([]); const [totalAmount, setTotalAmount] = useState(0); const [page, setPage] = useState(0); const [pageSize, setPageSize] = useState(50) @@ -233,7 +237,7 @@ const CacheView = memo((props) => { { "name": "Enrich", - "description": "Enriches the data. Only runs on valid JSON data AND if the 'enrichment' field does not exist.", + "description": "Enriches the data. Uses regex keys and runs a workflow in the background. Added to the 'enrichments' key.", "type": "singul", "options": [{ "key": "", @@ -331,6 +335,14 @@ const CacheView = memo((props) => { // In order to make linking weird urls from workflow page work. if (urlParams.get("src") == "workflow") { + if (categoryParam === "OCSF") { + const newParam = "shuffle-security incidents" + + urlParams.set("category", newParam) + window.history.replaceState({}, '', `${window.location.pathname}?${urlParams.toString()}`) + categoryParam = newParam + } + if (categoryParam?.toLowerCase().startsWith("list")) { const newParam = categoryParam.substring(5).replaceAll("%20", "_") @@ -570,17 +582,26 @@ const CacheView = memo((props) => { .then((response) => { if (response.status === 200) { if (refreshList === undefined || refreshList === null || refreshList === true) { - toast.success("Deleted datastore entry"); setTimeout(() => { listOrgCache(orgId, selectedCategory, 0, pageSize, page) }, 1000); } } else { + if (refreshList === undefined || refreshList === null || refreshList === true) { + setTimeout(() => { + listOrgCache(orgId, selectedCategory, 0, pageSize, page) + }, 1000); + } toast.error(`Failed deleting entry ${key} in category ${itemCategory || selectedCategory}. If this persists, please contact support@shuffler.io.`) } }) .catch((error) => { + if (refreshList === undefined || refreshList === null || refreshList === true) { + setTimeout(() => { + listOrgCache(orgId, selectedCategory, 0, pageSize, page) + }, 1000); + } toast(error.toString()); }); }; @@ -831,6 +852,11 @@ const CacheView = memo((props) => { Category: {dataValue.category} : null} + {dataValue?.enrichments !== undefined && dataValue?.enrichments !== null && dataValue.enrichments.length > 0 ? + + Enrichments: {dataValue.enrichments.length} + + : null} {dataValue?.tags !== undefined && dataValue?.tags !== null && dataValue?.tags?.length > 0 ?
@@ -900,33 +926,6 @@ const CacheView = memo((props) => {
); - const handleSelectSubOrg = (id, action) => { - if (action === "all") { - const childOrgs = userdata.orgs.filter( - (data) => data.creator_org === userdata.active_org.id - ); - setSelectedSubOrg((prev) => { - if (prev.length === childOrgs.length) { - // If all child orgs are already selected, clear the selection - return []; - } else { - // Otherwise, select all child org IDs - return childOrgs.map((data) => data.id); - } - }); - } else if (action === "none") { - setSelectedSubOrg([]); - } else { - setSelectedSubOrg((prev) => { - if (prev.includes(id)) { - return prev.filter((data) => data !== id); - } else { - return [...prev, id]; - } - }); - } - }; - const changeDistribution = (id, selectedSubOrg) => { editFileConfig(id, [...new Set(selectedSubOrg)], selectedCategory) @@ -940,8 +939,6 @@ const CacheView = memo((props) => { selected_suborgs: selectedSubOrg, category: category === undefined || category === "" || category === "default" ? "" : category, } - - console.log("data: ", data); const url = `${globalUrl}/api/v1/orgs/${orgId}/cache/config`; @@ -975,98 +972,41 @@ const CacheView = memo((props) => { }; - const cacheDistributionModal = showDistributionPopup ? ( - setShowDistributionPopup(false)} - PaperProps={{ - sx: { - borderRadius: theme?.palette?.DialogStyle?.borderRadius, - border: theme?.palette?.DialogStyle?.border, - fontFamily: theme?.typography?.fontFamily, - backgroundColor: theme?.palette?.DialogStyle?.backgroundColor, - zIndex: 1000, - minWidth: "600px", - minHeight: "320px", - overflow: "auto", - '& .MuiDialogContent-root': { - backgroundColor: theme?.palette?.DialogStyle?.backgroundColor, - }, - '& .MuiDialogTitle-root': { - backgroundColor: theme?.palette?.DialogStyle?.backgroundColor, - }, - '& .MuiDialogActions-root': { - backgroundColor: theme?.palette?.DialogStyle?.backgroundColor, - }, - }, - }} - > - - - Select sub-org to distribute Datastore key - - - - {handleSelectSubOrg(null, "none")}}>None - {handleSelectSubOrg(null, "all")}}>All - {userdata.orgs.map((data, index) => { - if (data.creator_org !== userdata.active_org.id) { - return null; - } - const imagesize = 22; - const imageStyle = { - width: imagesize, - height: imagesize, - pointerEvents: "none", - marginRight: 10, - marginLeft: data.id === userdata.active_org.id ? 0 : 20, - }; - const image = data.image === "" ? ( - {data.name} - ) : ( - {data.name} - ); + const deleteConfirmDialog = ( + { setDeleteConfirmOpen(false); setDeleteConfirmTarget(null); }} + onConfirm={() => { + if (deleteConfirmTarget?.bulk) { + const itemsToDelete = selectedRows.map(rowId => + listCache.find(item => `${item.key}_${item.category || ""}` === rowId) + ).filter(Boolean); - return ( - handleSelectSubOrg(data.id)} - style={{ display: "flex", alignItems: "center" }} - > - - {image} - {data.name} - - ); - })} + const count = itemsToDelete.length; + setSelectedRows([]); + itemsToDelete.forEach(item => deleteEntry(orgId, item.key, item.category, false)); -
- - -
-
-
- ) : null; + setTimeout(() => { + listOrgCache(orgId, selectedCategory, 0, pageSize, page); + toast.success("Deleted " + count + " keys from datastore"); + }, 3000); + } else { + deleteEntry(orgId, deleteConfirmTarget.key, deleteConfirmTarget.category); + } + setDeleteConfirmOpen(false); + setDeleteConfirmTarget(null); + }} + title={deleteConfirmTarget?.bulk ? `Delete ${selectedRows?.length} Key${selectedRows?.length > 1 ? "s" : ""}?` : "Delete Key?"} + description={ + deleteConfirmTarget?.bulk + ? <>Are you sure you want to delete {selectedRows?.length} key{selectedRows?.length > 1 ? "s" : ""}? + : <>Are you sure you want to delete {deleteConfirmTarget?.key}? + } + warningText="This cannot be undone. Any workflows using these keys will lose access." + /> + ); const saveAutomation = (allAutomation, settings) => { // Check if icon is a string. Otherwise make it empty. @@ -1759,6 +1699,10 @@ const CacheView = memo((props) => { enableClipboard={(copy) => { handleReactJsonClipboard(copy) }} + onSelect={(select) => { + //currentParams.set("category", selectedCategory); + //HandleJsonCopy(validate.result, select, "exec"); + }} collapseStringsAfterLength={theme.palette.jsonCollapseStringsAfterLength} iconStyle={theme.palette.jsonIconStyle} displayDataTypes={false} @@ -1935,6 +1879,7 @@ const CacheView = memo((props) => { "workflow_id": data.workflow_id, "category": data.category, "tags": data.tags, + "enrichments": data.enrichments, }) setValue(newvalue) setModalOpen(true) @@ -2019,7 +1964,8 @@ const CacheView = memo((props) => { onClick={(e) => { e.preventDefault() e.stopPropagation() - deleteEntry(orgId, data.key, data.category) + setDeleteConfirmTarget({ key: data.key, category: data.category }) + setDeleteConfirmOpen(true) }} > { style={{ margin: "auto" }} color="secondary" onClick={() => { - setShowDistributionPopup(true) + setShowDistributionPopup(true); + let initialSelected = []; if(data?.suborg_distribution?.length > 0){ - setSelectedSubOrg(data.suborg_distribution) - }else{ - setSelectedSubOrg([]) + initialSelected = data.suborg_distribution; } - setSelectedCacheKey(data.key) + setSelectedSubOrg(initialSelected); + setSelectedCacheKey(data.key); + const suborgs = (userdata?.orgs || []).filter(o => o.creator_org === userdata?.active_org?.id); + const sorted = [...suborgs].sort((a, b) => { + const aS = initialSelected.includes(a.id); + const bS = initialSelected.includes(b.id); + if (aS !== bS) return bS - aS; + return a.name.localeCompare(b.name); + }); + setDistribOrgOrder(sorted.map(o => o.id)); }} /> @@ -2116,6 +2070,7 @@ const CacheView = memo((props) => { var previousgroup = "" const isAutomating = categoryAutomations?.find((automation) => automation.enabled) !== undefined + const isAutomatingAccess = categoryConfig?.settings?.timeout >= 60 || categoryConfig?.settings?.public === true ? true : false return (
{ apps={apps} /> - {cacheDistributionModal} + { setShowDistributionPopup(false); setSelectedCacheKey(""); }} + title="Distribute Datastore Key to Sub-Organizations" + extraInfo={selectedCacheKey ? `Selected Key: ${selectedCacheKey}` : null} + orgs={distribOrgOrder.map(id => (userdata?.orgs || []).find(o => o.id === id)).filter(Boolean)} + selectedOrgIds={selectedSubOrg} + onSelectionChange={setSelectedSubOrg} + onSave={(ids) => { changeDistribution(selectedCacheKey, ids); }} + /> + {deleteConfirmDialog}
@@ -2204,7 +2169,6 @@ const CacheView = memo((props) => { height: 35, textTransform: 'none', - //border: isAutomating ? `1px solid ${theme.palette.primary.main}` : null, }} variant="outlined" color="secondary" @@ -2272,12 +2236,12 @@ const CacheView = memo((props) => { datastoreCategories !== null && datastoreCategories.length > 1 ? ( - + { marginLeft: 3, }} variant="outlined" - color="secondary" + color={isAutomatingAccess ? "primary" : "secondary"} disabled={selectedCategory === undefined || selectedCategory === "" || selectedCategory === "default"} onClick={() => { setShowSettingsMenu(true) }} > - + @@ -2877,27 +2841,8 @@ const CacheView = memo((props) => { - {formMessage} -
- : null + + : null }
) diff --git a/frontend/src/components/DeleteConfirmDialog.jsx b/frontend/src/components/DeleteConfirmDialog.jsx new file mode 100644 index 00000000..459f077b --- /dev/null +++ b/frontend/src/components/DeleteConfirmDialog.jsx @@ -0,0 +1,61 @@ +import React, { memo, useContext } from 'react'; +import { + Button, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Typography, +} from '@mui/material'; +import { getTheme } from '../theme.jsx'; +import { Context } from '../context/ContextApi.jsx'; + +const DeleteConfirmDialog = memo(({ open, onClose, onConfirm, title, description, warningText }) => { + const { themeMode, brandColor } = useContext(Context); + const theme = getTheme(themeMode, brandColor); + + return ( + + + {title} + + + {description} + {warningText && ( + + {warningText} + + )} + + + + + + + ); +}); + +export default DeleteConfirmDialog; diff --git a/frontend/src/components/DiscordChat.jsx b/frontend/src/components/DiscordChat.jsx index 2bbc3b2c..c220d36a 100644 --- a/frontend/src/components/DiscordChat.jsx +++ b/frontend/src/components/DiscordChat.jsx @@ -3,11 +3,8 @@ import algoliasearch from 'algoliasearch'; import theme from '../theme.jsx'; import { InstantSearch, connectSearchBox, connectHits } from 'react-instantsearch-dom'; import { - Grid, - Paper, TextField, Typography, - Button, InputAdornment, Avatar, List, @@ -16,6 +13,7 @@ import { ListItemText, } from '@mui/material'; import { Search as SearchIcon } from '@mui/icons-material'; +import SearchContactForm from '../components/SearchContactForm.jsx'; import useDebouncedCallback from '../utils/useDebouncedCallback.jsx'; @@ -23,52 +21,9 @@ const searchClient = algoliasearch("JNSS5CFDZZ", "1e5f29b1550939855de5915eac3bf5 const DiscordChat = props => { const { isMobile, globalUrl } = props - const [value, setValue] = useState(""); - const [formMail, setFormMail] = React.useState(""); - const [message, setMessage] = React.useState(""); - const [formMessage, setFormMessage] = React.useState(""); - const buttonStyle = {borderRadius: 30, height: 50, width: 220, margin: isMobile ? "15px auto 15px auto" : 20, fontSize: 18,} const borderRadius = 3 - const submitContact = (email, message) => { - const data = { - "firstname": "", - "lastname": "", - "title": "", - "companyname": "", - "email": email, - "phone": "", - "message": message, - } - - const errorMessage = "Something went wrong. Please contact frikky@shuffler.io directly." - - fetch(globalUrl+"/api/v1/contact", { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(data), - }) - .then(response => response.json()) - .then(response => { - if (response.success === true) { - setFormMessage(response.reason) - //toast("Thanks for submitting!") - } else { - setFormMessage(errorMessage) - } - - setFormMail("") - setMessage("") - }) - .catch(error => { - setFormMessage(errorMessage) - console.log(error) - }); - } - const SearchBox = ({ currentRefinement, refine }) => { const [inputValue, setInputValue] = useState(""); const debouncedRefine = useDebouncedCallback((value) => refine(value), 300); @@ -168,61 +123,7 @@ const DiscordChat = props => {
-
- - Can't find what you're looking for? - -
- setFormMail(e.target.value)} - /> - setMessage(e.target.value)} - /> -
- - {formMessage} -
+ {/* Search by diff --git a/frontend/src/components/DocsGrid.jsx b/frontend/src/components/DocsGrid.jsx index 57616bd2..64cbd8c4 100644 --- a/frontend/src/components/DocsGrid.jsx +++ b/frontend/src/components/DocsGrid.jsx @@ -4,6 +4,7 @@ import theme from '../theme.jsx'; import ReactGA from 'react-ga4'; import {Link} from 'react-router-dom'; import { removeQuery } from '../components/ScrollToTop.jsx'; +import SearchContactForm from '../components/SearchContactForm.jsx'; import { Search as SearchIcon, CloudQueue as CloudQueueIcon, Code as CodeIcon, Close as CloseIcon, Folder as FolderIcon, LibraryBooks as LibraryBooksIcon } from '@mui/icons-material'; import aa from 'search-insights' @@ -30,61 +31,19 @@ import { useDebouncedCallback } from "../utils/useDebouncedCallback.jsx"; -const searchClient = algoliasearch("JNSS5CFDZZ", "c8f882473ff42d41158430be09ec2b4e") +const searchClient = algoliasearch("JNSS5CFDZZ", "eb5fd80aa6ed5ab4730d836cff3ea283") const DocsGrid = props => { const { maxRows, showName, showSuggestion, isMobile, globalUrl, parsedXs, userdata, } = props const rowHandler = maxRows === undefined || maxRows === null ? 50 : maxRows const xs = parsedXs === undefined || parsedXs === null ? isMobile ? 6 : 2 : parsedXs //const [apps, setApps] = React.useState([]); //const [filteredApps, setFilteredApps] = React.useState([]); - const [formMail, setFormMail] = React.useState(""); - const [message, setMessage] = React.useState(""); - const [formMessage, setFormMessage] = React.useState(""); - const buttonStyle = {borderRadius: 30, height: 50, width: 220, margin: isMobile ? "15px auto 15px auto" : 20, fontSize: 18,} const innerColor = "rgba(255,255,255,0.65)" const borderRadius = 3 window.title = "Shuffle | Apps | Find and integrate any app" - const submitContact = (email, message) => { - const data = { - "firstname": "", - "lastname": "", - "title": "", - "companyname": "", - "email": email, - "phone": "", - "message": message, - } - - const errorMessage = "Something went wrong. Please contact frikky@shuffler.io directly." - - fetch(globalUrl+"/api/v1/contact", { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(data), - }) - .then(response => response.json()) - .then(response => { - if (response.success === true) { - setFormMessage(response.reason) - //toast("Thanks for submitting!") - } else { - setFormMessage(errorMessage) - } - - setFormMail("") - setMessage("") - }) - .catch(error => { - setFormMessage(errorMessage) - console.log(error) - }); - } - const SearchBox = ({currentRefinement, refine, isSearchStalled} ) => { var defaultSearch = "" if (window !== undefined && window.location !== undefined && window.location.search !== undefined && window.location.search !== null) { @@ -124,8 +83,15 @@ const DocsGrid = props => { placeholder="Search our Documentation..." id="shuffle_search_field" onChange={(event) => { - removeQuery("q") - debouncedRefine(event.currentTarget.value) + const value = event.currentTarget.value + debouncedRefine(value) + const urlSearchParams = new URLSearchParams(window.location.search) + if (value) { + urlSearchParams.set("q", value) + } else { + urlSearchParams.delete("q") + } + window.history.replaceState(null, "", value ? `?${urlSearchParams.toString()}` : window.location.pathname) }} onKeyDown={(event) => { if(event.key === "Enter") { @@ -291,62 +257,8 @@ const DocsGrid = props => { {showSuggestion === true ? -
- - Can't find what you're looking for? - -
- setFormMail(e.target.value)} - /> - setMessage(e.target.value)} - /> -
- - {formMessage} -
- : null + + : null } {/* diff --git a/frontend/src/components/EnvironmentTab.jsx b/frontend/src/components/EnvironmentTab.jsx index 58dd47d5..6d17f383 100644 --- a/frontend/src/components/EnvironmentTab.jsx +++ b/frontend/src/components/EnvironmentTab.jsx @@ -36,6 +36,7 @@ import { ExpandLess as ExpandLessIcon, ExpandMore as ExpandMoreIcon, Delete as DeleteIcon, + Computer as ComputerIcon, } from "@mui/icons-material"; import { toast } from 'react-toastify'; import { Context } from '../context/ContextApi.jsx'; @@ -49,6 +50,7 @@ const EnvironmentTab = memo((props) => { const [modalUser, setModalUser] = React.useState({}); const [loginInfo, setLoginInfo] = React.useState(""); const [modalOpen, setModalOpen] = React.useState(false); + const [sensorGroup, setSensorGroup] = React.useState(false); const [showLoader, setShowLoader] = useState(true) const [commandController, setCommandController] = React.useState({ pipelines: false, @@ -535,13 +537,36 @@ const EnvironmentTab = memo((props) => { }, }, }} + style={{ + }} > - - Add Location + + Add Location + -
- Location Name + + {sensorGroup ? + 'With "Sensor Groups" enabled, Runtime Locations allows you to run a lightweight log-collector and response agent onprem.' + : + 'Runtime Locations are a way to run automation in Shuffle. By default, it runs in Docker/Kubernetes and allows you to run AI Agents and Workflows in your designated datacenter.' + } + +
+ + {sensorGroup ? + "Sensor Group name" + : + "Location Name" + } + { }} required fullWidth={true} - placeholder="datacenter froglantern" + placeholder="automation location 3" id="environment_name" margin="normal" variant="outlined" - onChange={(event) => + onChange={(event) => { changeModalData("environment", event.target.value) - } + setUpdate(Math.random()) + }} />
{loginInfo} @@ -576,12 +602,13 @@ const EnvironmentTab = memo((props) => { @@ -956,7 +983,7 @@ const EnvironmentTab = memo((props) => { { > + + + : environment.run_type === "cloud" || environment.name === "Cloud" ? ( @@ -1275,6 +1307,9 @@ const EnvironmentTab = memo((props) => { + : + environment?.sensor_group === true ? + "N/A" : environment?.data_lake?.enabled && environment?.archived !== true ? ( { /> {
+ {environment?.sensor_group === true ? +
+ + Sensor Group - Host controls available in Shuffle Security + + + Total registered hosts: {environment?.sensor_hosts?.length || 0}.
Host management and response actions is done in Shuffle Security. Click the link above to manage. +
+
+ :
Self-Hosted Orborus instance @@ -1703,6 +1748,7 @@ const EnvironmentTab = memo((props) => { }
+ }
{currentEnvQueue.length === 0 ? null : diff --git a/frontend/src/components/Files.jsx b/frontend/src/components/Files.jsx index f98e8e22..5428a881 100644 --- a/frontend/src/components/Files.jsx +++ b/frontend/src/components/Files.jsx @@ -29,6 +29,8 @@ import { Menu, Pagination, PaginationItem, + Box, + InputAdornment, } from "@mui/material"; import { DataGrid } from "@mui/x-data-grid"; @@ -45,9 +47,12 @@ import { Clear as ClearIcon, Add as AddIcon, SelectAll, + Search as SearchIcon, } from "@mui/icons-material"; import Dropzone from "../components/Dropzone.jsx"; +import SubOrgDistributionDialog from "./SubOrgDistributionDialog.jsx"; +import DeleteConfirmDialog from "./DeleteConfirmDialog.jsx"; import ShuffleCodeEditor from "../components/ShuffleCodeEditor1.jsx"; import {getTheme} from "../theme.jsx"; import { Context } from "../context/ContextApi.jsx"; @@ -82,6 +87,10 @@ const Files = memo((props) => { const [showDistributionPopup, setShowDistributionPopup] = useState(false) const [selectedSubOrg, setSelectedSubOrg] = useState([]) const [fileIdSelectedForDistribution, setFileIdSelectedForDistribution] = useState("") + const [fileNameSelectedForDistribution, setFileNameSelectedForDistribution] = useState("") + const [distribOrgOrder, setDistribOrgOrder] = useState([]) + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false) + const [deleteConfirmTarget, setDeleteConfirmTarget] = useState(null) const [totalAmount, setTotalAmount] = useState(0); const [page, setPage] = useState(0); const [pageSize, setPageSize] = useState(50) @@ -326,7 +335,7 @@ const [filesLoaded, setFilesLoaded] = useState(false); navigator.clipboard.writeText(file.id); document.execCommand("copy"); - toast(file.id + " copied to clipboard"); + toast.info(file.id + " copied to clipboard"); }} > { e.stopPropagation(); e.preventDefault(); - deleteFile(file.id, true); + setDeleteConfirmTarget({ id: file.id, filename: file.filename }); + setDeleteConfirmOpen(true); }} > o.creator_org === userdata.active_org.id) + .map(o => o.id) + ) }} />
@@ -534,7 +550,7 @@ const [filesLoaded, setFilesLoaded] = useState(false); }) .then((responseJson) => { if (responseJson.success === true) { - toast("Successfully updated file"); + toast.success("Successfully updated file"); } }) .catch((error) => { @@ -848,125 +864,39 @@ const [filesLoaded, setFilesLoaded] = useState(false); : null - const handleSelectSubOrg = (id, action) => { - if (action === "all") { - const childOrgs = userdata.orgs.filter( - (data) => data.creator_org === userdata.active_org.id - ); - setSelectedSubOrg((prev) => { - if (prev.length === childOrgs.length) { - // If all child orgs are already selected, clear the selection - return []; - } else { - // Otherwise, select all child org IDs - return childOrgs.map((data) => data.id); - } - }); - } else if (action === "none") { - setSelectedSubOrg([]); - } else { - setSelectedSubOrg((prev) => { - if (prev.includes(id)) { - return prev.filter((data) => data !== id); - } else { - return [...prev, id]; - } - }); - } - }; - - const fileDistributionModal = showDistributionPopup ? ( - setShowDistributionPopup(false)} - PaperProps={{ - sx: { - borderRadius: theme?.palette?.DialogStyle?.borderRadius, - border: theme?.palette?.DialogStyle?.border, - fontFamily: theme?.typography?.fontFamily, - backgroundColor: theme?.palette?.DialogStyle?.backgroundColor, - zIndex: 1000, - minWidth: "600px", - minHeight: "320px", - overflow: "auto", - '& .MuiDialogContent-root': { - backgroundColor: theme?.palette?.DialogStyle?.backgroundColor, - }, - '& .MuiDialogTitle-root': { - backgroundColor: theme?.palette?.DialogStyle?.backgroundColor, - }, - '& .MuiDialogActions-root': { - backgroundColor: theme?.palette?.DialogStyle?.backgroundColor, - }, - }, - }} - > - - - Select sub-org to distribute files - - - - {handleSelectSubOrg(null, "none")}}>None - {handleSelectSubOrg(null, "all")}}>All - {userdata.orgs.map((data, index) => { - if (data.creator_org !== userdata.active_org.id) { - return null; - } - - const imagesize = 22; - const imageStyle = { - width: imagesize, - height: imagesize, - pointerEvents: "none", - marginRight: 10, - marginLeft: data.id === userdata.active_org.id ? 0 : 20, - }; - - const image = data.image === "" ? ( - {data.name} - ) : ( - {data.name} - ); - - return ( - handleSelectSubOrg(data.id)} - style={{ display: "flex", alignItems: "center" }} - > - - {image} - {data.name} - - ); - })} - -
- - -
-
-
- - ): null + const deleteConfirmDialog = ( + { setDeleteConfirmOpen(false); setDeleteConfirmTarget(null); }} + onConfirm={() => { + if (deleteConfirmTarget?.bulk) { + const toDelete = [...selectedRows]; + const count = toDelete.length; + setSelectedRows([]); + toDelete.forEach(fileId => deleteFile(fileId, false)); + setTimeout(() => { + getFiles(selectedCategory); + toast.success('Deleted ' + count + ' file' + (count > 1 ? 's' : '')); + }, 3000); + } else { + deleteFile(deleteConfirmTarget.id, true); + } + setDeleteConfirmOpen(false); + setDeleteConfirmTarget(null); + }} + title={ + deleteConfirmTarget?.bulk + ? 'Delete ' + selectedRows?.length + ' File' + (selectedRows?.length > 1 ? 's' : '') + '?' + : 'Delete File?' + } + description={ + deleteConfirmTarget?.bulk + ? <>Are you sure you want to delete {selectedRows?.length} file{selectedRows?.length > 1 ? 's' : ''}? + : <>Are you sure you want to delete {deleteConfirmTarget?.filename}? + } + warningText="This cannot be undone." + /> + ); const deleteFile = (fileId, showSinglDeleteToast) => { @@ -1295,7 +1225,17 @@ const [filesLoaded, setFilesLoaded] = useState(false); }} onDrop={uploadFile} > - {fileDistributionModal} + { setShowDistributionPopup(false); }} + title="Distribute File to Sub-Organizations" + extraInfo={fileNameSelectedForDistribution ? `Selected File: ${fileNameSelectedForDistribution}` : null} + orgs={distribOrgOrder.map(id => (userdata?.orgs || []).find(o => o.id === id)).filter(Boolean)} + selectedOrgIds={selectedSubOrg} + onSelectionChange={setSelectedSubOrg} + onSave={(ids) => { changeDistribution(fileIdSelectedForDistribution, ids); setShowDistributionPopup(false); }} + /> + {deleteConfirmDialog}
@@ -1559,17 +1499,20 @@ const [filesLoaded, setFilesLoaded] = useState(false); autoFocus />} - + {openEditor === true && fileContent !== undefined && fileContent !== null && fileContent.length > 0 ? + + : null} + {isSelectedFiles?null: { - setSelectedRows([]); - getFiles(selectedCategory); - toast.success( - `Deleted ${selectedRows.length} file${selectedRows.length === 1 ? "" : "s"}` - ); - }, 2500); - } - } - }} + setDeleteConfirmTarget({ bulk: true }); + setDeleteConfirmOpen(true); + }} variant={"outlined"} color="secondary" startIcon={ diff --git a/frontend/src/components/HealthPage.jsx b/frontend/src/components/HealthPage.jsx index b60a46be..8018237f 100644 --- a/frontend/src/components/HealthPage.jsx +++ b/frontend/src/components/HealthPage.jsx @@ -1,7 +1,17 @@ -import React, { useEffect, useState, useCallback } from 'react'; +import React, { useEffect, useState, useCallback, useMemo, useContext } from 'react'; +import { Context } from '../context/ContextApi'; +import { useNavigate } from 'react-router-dom'; import { toast } from "react-toastify"; import { - CheckOutlined as CheckOutlinedIcon, + CheckCircle as CheckCircleIcon, + Error as ErrorIcon, + Warning as WarningIcon, + AccountTree as WorkflowIcon, + Apps as AppsIcon, + Storage as StorageIcon, + FolderOpen as FileIcon, + FindInPage as SearchIcon, + ContentCopy as CopyIcon, } from '@mui/icons-material'; import { @@ -9,52 +19,274 @@ import { ButtonGroup, Typography, LinearProgress, + Chip, + Tooltip, + Select, + MenuItem, + FormControl, + TextField, + Popover, } from "@mui/material"; +import { CalendarMonth as CalendarIcon } from '@mui/icons-material'; + import HealthBarChart from '../components/HealthBarChart.jsx'; -import LiveExecutionsChart from '../components/LiveExecutionsGraph.jsx'; +import LiveExecutionsChart from './LiveExecutionsGraph.jsx'; + + +const STATUS_STYLE = { + operational: { color: '#00F670', label: 'Operational', bg: 'rgba(0, 246, 112, 0.07)' }, + degraded: { color: '#FFD700', label: 'Degraded', bg: 'rgba(255, 215, 0, 0.07)' }, + outage: { color: '#FF354C', label: 'Outage', bg: 'rgba(255, 53, 76, 0.07)' }, +}; + +const REGION_DOMAIN = { + 'london': 'https://shuffler.io', + 'california': 'https://california.shuffler.io', + 'EU': 'https://frankfurt.shuffler.io', + 'canada': 'https://ca.shuffler.io', + 'australia': 'https://au.shuffler.io', +}; + +const RANGE_MILLIS = { + '24hr': 24 * 60 * 60 * 1000, + '7day': 7 * 24 * 60 * 60 * 1000, + '30d': 30 * 24 * 60 * 60 * 1000, + '90d': 90 * 24 * 60 * 60 * 1000, + '180d': 180 * 24 * 60 * 60 * 1000, + '365d': 365 * 24 * 60 * 60 * 1000, +}; + +const SERVICE_CONFIG = [ + { + key: 'workflows', + label: 'Workflows', + Icon: WorkflowIcon, + sloTarget: 99.95, + isHealthy: (item) => item.workflows?.run_finished === true, + getOperations: (item) => [ + { key: 'create', label: 'Create', value: item.workflows?.create }, + { key: 'run', label: 'Execute', value: item.workflows?.run }, + { key: 'run_finished', label: 'Completed', value: item.workflows?.run_finished }, + { key: 'delete', label: 'Delete', value: item.workflows?.delete }, + ], + getExtra: (item) => { + const took = item.workflows?.execution_took; + return (took != null && took > 0) ? `Exec time: ${Number(took).toFixed(2)}s` : null; + }, + getIds: (item) => [ + { label: 'Execution ID', value: item.workflows?.execution_id }, + { label: 'Workflow ID', value: item.workflows?.workflow_id }, + ].filter(id => !!id.value), + getErrors: (item) => { + const e = item.workflows?.error; + if (!e) return []; + return [ + { key: 'create', label: 'Create', msg: e.create }, + { key: 'run', label: 'Execute', msg: e.run }, + { key: 'run_finished', label: 'Completed', msg: e.run_finished }, + { key: 'workflow_validation', label: 'Validation', msg: e.workflow_validation }, + { key: 'delete', label: 'Delete', msg: e.delete }, + ].filter(err => !!err.msg); + }, + }, + { + key: 'apps', + label: 'Apps', + Icon: AppsIcon, + sloTarget: 99.95, + isHealthy: (item) => { const a = item.apps; return !!(a && a.create && a.run && a.delete); }, + getOperations: (item) => [ + { key: 'create', label: 'Create', value: item.apps?.create }, + { key: 'validate', label: 'Validate', value: item.apps?.validate }, + { key: 'run', label: 'Execute', value: item.apps?.run }, + { key: 'read', label: 'Read', value: item.apps?.read }, + { key: 'delete', label: 'Delete', value: item.apps?.delete }, + ], + getExtra: () => null, + getIds: (item) => [ + { label: 'App ID', value: item.apps?.app_id }, + { label: 'Execution ID', value: item.apps?.execution_id }, + ].filter(id => !!id.value), + getErrors: (item) => { + const e = item.apps?.error; + if (!e) return []; + return [ + { key: 'create', label: 'Create', msg: e.create }, + { key: 'validate', label: 'Validate', msg: e.validate }, + { key: 'run', label: 'Execute', msg: e.run }, + { key: 'read', label: 'Read', msg: e.read }, + { key: 'delete', label: 'Delete', msg: e.delete }, + ].filter(err => !!err.msg); + }, + }, + { + key: 'datastore', + label: 'Datastore', + Icon: StorageIcon, + sloTarget: 99.95, + isHealthy: (item) => { const d = item.datastore; return !!(d && d.create && d.read && d.delete); }, + getOperations: (item) => [ + { key: 'create', label: 'Create', value: item.datastore?.create }, + { key: 'read', label: 'Read', value: item.datastore?.read }, + { key: 'delete', label: 'Delete', value: item.datastore?.delete }, + ], + getExtra: () => null, + getIds: () => [], + getErrors: (item) => { + const e = item.datastore?.error; + if (!e) return []; + return [ + { key: 'create', label: 'Create', msg: e.create }, + { key: 'read', label: 'Read', msg: e.read }, + { key: 'delete', label: 'Delete', msg: e.delete }, + ].filter(err => !!err.msg); + }, + }, + { + key: 'fileops', + label: 'File Storage', + Icon: FileIcon, + sloTarget: 99.95, + isHealthy: (item) => { const f = item.fileops; return !!(f && f.create && f.get_file && f.delete); }, + getOperations: (item) => [ + { key: 'create', label: 'Create', value: item.fileops?.create }, + { key: 'get_file', label: 'Upload/Fetch', value: item.fileops?.get_file }, + { key: 'delete', label: 'Delete', value: item.fileops?.delete }, + ], + getExtra: () => null, + getIds: (item) => [ + { label: 'File ID', value: item.fileops?.fileId }, + ].filter(id => !!id.value), + getErrors: (item) => { + const e = item.fileops?.error; + if (!e) return []; + return [ + { key: 'create', label: 'Create', msg: e.create }, + { key: 'upload', label: 'Upload/Fetch', msg: e.upload }, + { key: 'delete', label: 'Delete', msg: e.delete }, + ].filter(err => !!err.msg); + }, + }, +]; + +const OPENSEARCH_CONFIG = { + key: 'opensearch', + label: 'OpenSearch', + Icon: SearchIcon, + sloTarget: 99.95, + isHealthy: (item) => item.opnsearch?.status === 'green', + getOperations: (item) => { + const s = item.opnsearch?.status; + return [{ key: 'cluster', label: 'Cluster', value: s === 'green' ? true : s === 'yellow' ? 'warn' : false }]; + }, + getExtra: (item) => item.opnsearch?.status ? `Cluster: ${item.opnsearch.status}` : null, + getIds: () => [], + getErrors: () => [], +}; + +const computeAvgUptime = (chartData) => { + if (!chartData || chartData.length === 0) return 100; + const withData = chartData.filter(d => d.avgRunFinished !== null); + if (withData.length === 0) return 100; + return parseFloat((withData.reduce((acc, d) => acc + d.avgRunFinished, 0) / withData.length).toFixed(2)); +}; + +const getStatusKey = (uptime, sloTarget) => { + if (uptime >= sloTarget) return 'operational'; + if (uptime >= 95) return 'degraded'; + return 'outage'; +}; + +// --- const HealthPage = (props) => { - const { userdata, globalUrl } = props; + const { userdata, isLoaded } = props; + const navigate = useNavigate(); + const { leftSideBarOpenByClick } = useContext(Context); const [healthData, setHealthData] = useState(null); - const [selectedRange, setSelectedRange] = useState('30d'); + const [selectedRange, setSelectedRange] = useState('24hr'); + const [selectedRegion, setSelectedRegion] = useState('london'); const [liveExecutionsData, setLiveExecutionsData] = useState([]); - const [filteredData, setFilteredData] = useState([]); - const [averageUptime, setAverageUptime] = useState(0); - const [liveExecutionsRange, setLiveExecutionsRange] = useState('1h'); // Default to 1h - const [isHealthLoading, setIsHealthLoading] = useState(false); // Loading state for HealthBarChart - const [isLiveExecutionsLoading, setIsLiveExecutionsLoading] = useState(false); // Loading state for LiveExecutionsChart + const [liveExecutionsRange, setLiveExecutionsRange] = useState('1h'); + const [isHealthLoading, setIsHealthLoading] = useState(false); + const [isLiveExecutionsLoading, setIsLiveExecutionsLoading] = useState(false); const [isFixingOpensearchPrefix, setIsFixingOpensearchPrefix] = useState(false); + const [selectedFailureDetails, setSelectedFailureDetails] = useState(null); + const [calendarAnchor, setCalendarAnchor] = useState(null); + const [customStart, setCustomStart] = useState(''); + const [customEnd, setCustomEnd] = useState(''); + const [customRange, setCustomRange] = useState(null); // { after: unix, before: unix } or null - const isCloud = (window.location.host === "localhost:3002" || window.location.host === "shuffler.io") ? true : (import.meta.env.VITE_IS_SSR === "true"); + const isCloud = window.location.host === "localhost:3002" || window.location.host === "shuffler.io"; const fetchHealthStats = useCallback(async () => { - setIsHealthLoading(true); // Start loading for HealthBarChart + setIsHealthLoading(true); try { - const response = await fetch(`${globalUrl}/api/v1/health/stats`, { - method: "GET", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - credentials: "include", - }); - if (!response.ok) { - throw new Error("Failed to fetch health stats"); + const activeDomain = isCloud ? REGION_DOMAIN[selectedRegion] : window.location.origin; + const nowMs = Date.now(); + const CHUNK_MS = 90 * 24 * 60 * 60 * 1000; // 90-day chunks + + let rangeStartSec, rangeEndSec; + if (customRange) { + rangeStartSec = customRange.after; + rangeEndSec = customRange.before; + } else { + const rangeMs = RANGE_MILLIS[selectedRange] || RANGE_MILLIS['30d']; + rangeStartSec = Math.floor((nowMs - rangeMs) / 1000); + rangeEndSec = Math.floor(nowMs / 1000); } - const data = await response.json(); - setHealthData(data); + + // Split large ranges into 90-day chunks to avoid oversized responses + const chunks = []; + let chunkEndSec = rangeEndSec; + const CHUNK_SEC = Math.floor(CHUNK_MS / 1000); + while (chunkEndSec > rangeStartSec) { + const chunkStartSec = Math.max(chunkEndSec - CHUNK_SEC, rangeStartSec); + chunks.push({ after: chunkStartSec, before: chunkEndSec }); + chunkEndSec = chunkStartSec; + } + + const fetchChunk = async (after, before) => { + const resp = await fetch(`${activeDomain}/api/v1/health/stats?after=${after}&before=${before}`, { + method: "GET", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + credentials: "include", + }); + if (!resp.ok) throw new Error("Failed to fetch health stats"); + return resp.json(); + }; + + let allData = []; + // Fetch chunks sequentially to avoid overwhelming the server + for (const chunk of chunks) { + const chunkData = await fetchChunk(chunk.after, chunk.before); + if (Array.isArray(chunkData)) { + // Filter to only items within this chunk's window to avoid duplicates + const filtered = chunkData.filter( + item => item.updated >= chunk.after && item.updated < chunk.before + ); + allData = allData.concat(filtered); + } + } + + setHealthData(allData); } catch (error) { console.error("Error fetching health stats:", error); toast.error("Failed loading health stats"); } finally { - setIsHealthLoading(false); // Stop loading for HealthBarChart + setIsHealthLoading(false); } - }, [globalUrl]); + }, [selectedRegion, selectedRange, customRange]); const fetchLiveExecutions = useCallback(async (range = '1h') => { - setIsLiveExecutionsLoading(true); // Start loading for LiveExecutionsChart + if (!userdata.support_access) return; + setIsLiveExecutionsLoading(true); try { + const activeDomain = isCloud ? REGION_DOMAIN[selectedRegion] : window.location.origin; const fetchOptions = { method: "GET", credentials: "include", @@ -67,326 +299,217 @@ const HealthPage = (props) => { }; } - const now = Math.floor(Date.now() / 1000); - // let after = now - 3600; // Default to 1h - let mode = "" - - switch (range) { - case '1h': - mode = "1h" - break; - case '7h': - mode = "7h" - break; - case '1d': - mode = "1d" - break; - case '7d': - mode = "7d" - break; - case 'month': - mode = "month" - break; - default: - mode = "1h" - } - - if (!userdata.support_access) { - return - } - const response = await fetch( - `${globalUrl}/api/v1/health/executions/live?mode=${mode}`, + `${activeDomain}/api/v1/health/executions/live?mode=${range}`, fetchOptions ); - if (!response.ok) { - throw new Error("Failed to fetch live executions"); - } + if (!response.ok) throw new Error("Failed to fetch live executions"); const data = await response.json(); - console.log("Raw live executions data:", data); - if (Array.isArray(data)) { const formattedData = data .map(item => ({ ...item, - // failed: Number(item.failed) || 0, executing: Number(item.executing) || 0, finished: Number(item.finished) || 0, aborted: Number(item.aborted) || 0, - created_at: Number(item.created_at) || 0 + created_at: Number(item.created_at) || 0, })) .sort((a, b) => a.created_at - b.created_at); - - console.log("Formatted live executions data:", formattedData); setLiveExecutionsData(formattedData); } else { - console.error("Received invalid data format:", data); setLiveExecutionsData([]); } } catch (error) { console.error("Error fetching live executions:", error); toast.error("Failed loading live executions data"); } finally { - setIsLiveExecutionsLoading(false); // Stop loading for LiveExecutionsChart + setIsLiveExecutionsLoading(false); } - }, [globalUrl]); + }, [userdata.support_access, selectedRegion]); useEffect(() => { fetchHealthStats(); fetchLiveExecutions(liveExecutionsRange); - const interval = setInterval(() => fetchLiveExecutions(liveExecutionsRange), 60000); return () => clearInterval(interval); }, [fetchHealthStats, fetchLiveExecutions, liveExecutionsRange]); - const extractRunFinished = (data, range) => { + // --- Derived data (memoized) --- + + const filteredHealthData = useMemo(() => { + if (!healthData || !Array.isArray(healthData)) return []; + if (customRange) { + return healthData.filter(item => item.updated >= customRange.after && item.updated <= customRange.before); + } + const now = Date.now(); + const ms = RANGE_MILLIS[selectedRange] || RANGE_MILLIS['30d']; + return healthData.filter(item => now - item.updated * 1000 <= ms); + }, [healthData, selectedRange, customRange]); + + const hasOpensearch = useMemo(() => + Array.isArray(healthData) && healthData.some(item => item.opnsearch && item.opnsearch.status), + [healthData]); + + const latestEntry = useMemo(() => { + if (!filteredHealthData.length) return null; + return filteredHealthData.reduce((best, item) => item.updated > (best?.updated || 0) ? item : best, null); + }, [filteredHealthData]); + + const extractServiceChartData = useCallback((config, data, range) => { if (!data || !Array.isArray(data)) return []; + const agg = new Map(); - const currentDate = new Date().getTime(); - const rangeInMillis = { - '24hr': 24 * 60 * 60 * 1000, - '7day': 7 * 24 * 60 * 60 * 1000, - '30d': 30 * 24 * 60 * 60 * 1000, - '90d': 90 * 24 * 60 * 60 * 1000 + // Anchor pre-fill to the most recent data point so the newest bar always + // reflects actual data instead of an empty "today/current-hour" slot. + const mostRecentMs = data.length > 0 + ? Math.max(...data.map(item => item.updated)) * 1000 + : Date.now(); + // Custom range uses UTC keys; all preset ranges use local timezone keys + const isCustom = range === 'custom'; + const getKey = (timestamp) => { + const d = new Date(timestamp); + if (isCustom) { + // UTC bucketing for custom date range (user picks UTC dates) + if (range === '24hr') { + return `${d.toISOString().split('T')[0]} ${String(d.getUTCHours()).padStart(2, '0')}:00`; + } + return d.toISOString().split('T')[0]; + } + // Local timezone bucketing for preset ranges + if (range === '24hr') { + return `${d.toLocaleDateString()} ${String(d.getHours()).padStart(2, '0')}:00`; + } + return d.toLocaleDateString(); }; - const filteredData = data.filter(item => currentDate - item.updated * 1000 <= rangeInMillis[range]); - const aggregatedData = new Map(); - - filteredData.forEach(item => { - const timestamp = item.updated * 1000; // Convert Unix timestamp to milliseconds - let key; - - switch (range) { - case '24hr': - const date = new Date(timestamp); - const hour = date.getHours(); - const formattedDate = `${date.toLocaleDateString()} ${hour}:00`; - key = formattedDate; - break; - case '7day': - const date1 = new Date(timestamp); - const hour1 = date1.getHours(); - const formattedDate1 = `${date1.toLocaleDateString()} ${hour1}:00`; - key = formattedDate1; - break; - default: - key = new Date(timestamp).toLocaleDateString(); + // Pre-fill all slots anchored to the most recent data point (newest -> oldest) + if (range === '24hr') { + for (let h = 0; h <= 23; h++) { + const d = new Date(mostRecentMs - h * 60 * 60 * 1000); + d.setMinutes(0, 0, 0); + agg.set(getKey(d.getTime()), { total: 0, healthyCount: 0, failures: [] }); } - - // Check if date already exists in the map - if (aggregatedData.has(key)) { - // Update aggregated values - const existingData = aggregatedData.get(key); - existingData.totalEntries++; - existingData.totalRunFinished += item.workflows.run_finished ? 1 : 0; - existingData.executionIds.push(item.workflows.execution_id); - } else { - // Add new entry to the map - aggregatedData.set(key, { - totalEntries: 1, - totalRunFinished: item.workflows.run_finished ? 1 : 0, - executionIds: [item.workflows.execution_id] - }); + } else if (range === '7day') { + for (let day = 0; day <= 6; day++) { + const d = new Date(mostRecentMs - day * 86400000); + agg.set(d.toLocaleDateString(), { total: 0, healthyCount: 0, failures: [] }); } + } else if (range === '30d') { + for (let day = 0; day <= 29; day++) { + const d = new Date(mostRecentMs - day * 86400000); + agg.set(d.toLocaleDateString(), { total: 0, healthyCount: 0, failures: [] }); + } + } else if (range === '90d') { + for (let day = 0; day <= 89; day++) { + const d = new Date(mostRecentMs - day * 86400000); + agg.set(d.toLocaleDateString(), { total: 0, healthyCount: 0, failures: [] }); + } + } else if (range === '180d') { + for (let day = 0; day <= 179; day++) { + const d = new Date(mostRecentMs - day * 86400000); + agg.set(d.toLocaleDateString(), { total: 0, healthyCount: 0, failures: [] }); + } + } else if (range === '365d') { + for (let day = 0; day <= 364; day++) { + const d = new Date(mostRecentMs - day * 86400000); + agg.set(d.toLocaleDateString(), { total: 0, healthyCount: 0, failures: [] }); + } + } else if (range === 'custom' && customRange) { + // Pre-fill using original UTC date strings to avoid local TZ rollover + const startDay = new Date(customRange.startLabel + 'T00:00:00Z'); + const endDay = new Date(customRange.endLabel + 'T00:00:00Z'); + const totalDays = Math.round((endDay - startDay) / 86400000) + 1; + for (let day = 0; day < totalDays; day++) { + agg.set(new Date(startDay.getTime() + day * 86400000).toISOString().split('T')[0], { total: 0, healthyCount: 0, failures: [] }); + } + } + + data.forEach(item => { + const key = getKey(item.updated * 1000); + const healthy = config.isHealthy(item); + if (agg.has(key)) { + const ex = agg.get(key); + ex.total++; + if (healthy) ex.healthyCount++; + else ex.failures.push(item); + } + // skip items that fall outside the pre-filled window }); - // Calculate averages and assign colors - const result = Array.from(aggregatedData.entries()).map(([key, { totalEntries, totalRunFinished, executionIds }]) => { - const avg = totalEntries > 0 ? totalRunFinished / totalEntries : 0; - const FinalAvg = avg * 100; - let color; - - if (FinalAvg >= 100) { - color = '#00F670'; - } else if (FinalAvg >= 98.50 && FinalAvg <= 99.99) { - color = '#FFD700'; - } else if (FinalAvg <= 98.49) { - color = '#FF354C'; - } - + return Array.from(agg.entries()) + .map(([date, { total, healthyCount, failures }]) => { + const pct = total === 0 ? null : (healthyCount / total) * 100; + const color = pct === null ? '#2a2a2a' : pct >= 99.5 ? '#00F670' : pct >= 95 ? '#FFD700' : '#FF354C'; return { - date: range === '24hr' ? `${key}:00` : key, - avgRunFinished: FinalAvg, + date, + avgRunFinished: pct === null ? null : parseFloat(pct.toFixed(2)), + total, color, - executionIds + executionIds: failures.map(f => f.workflows?.execution_id || f.id), + failures, }; }); + }, [customRange]); + const serviceCharts = useMemo(() => { + const configs = hasOpensearch ? [...SERVICE_CONFIG, OPENSEARCH_CONFIG] : SERVICE_CONFIG; + const result = {}; + const activeRange = customRange ? 'custom' : selectedRange; + for (const cfg of configs) { + result[cfg.key] = extractServiceChartData(cfg, filteredHealthData, activeRange); + } return result; - }; + }, [filteredHealthData, hasOpensearch, extractServiceChartData, selectedRange, customRange]); + + const systemStatus = useMemo(() => { + const configs = hasOpensearch ? [...SERVICE_CONFIG, OPENSEARCH_CONFIG] : SERVICE_CONFIG; + const statuses = configs.map(cfg => getStatusKey(computeAvgUptime(serviceCharts[cfg.key]), cfg.sloTarget)); + if (statuses.every(s => s === 'operational')) return 'operational'; + if (statuses.some(s => s === 'outage')) return 'outage'; + return 'degraded'; + }, [serviceCharts, hasOpensearch]); + + // Access guard — only redirect after auth state is confirmed loaded useEffect(() => { - if (healthData) { - const newData = extractRunFinished(healthData, selectedRange); - setFilteredData(newData); - - const totalUptime = newData.reduce((acc, curr) => acc + curr.avgRunFinished, 0); - const avgUptime = totalUptime / newData.length; - setAverageUptime(avgUptime); + if (!isLoaded) return; + if (!userdata || !userdata.id) { + navigate('/login?view=health&message=You must be logged in to view this page', { replace: true }); + } else if (userdata.support_access === false) { + navigate('/', { replace: true }); } - }, [selectedRange, healthData]); + }, [userdata, isLoaded, navigate]); - const filterDataByRange = (range) => { - setSelectedRange(range); - }; + // Render nothing only when definitively not authenticated/authorized + if (!isLoaded || !userdata?.id || userdata?.support_access === false) return null; - const updateChartData = () => { - if (!filteredData) { - return { - labels: [], - datasets: [{ - label: "", - data: [], - backgroundColor: [], - borderWidth: 1, - barThickness: 7, // Default bar thickness - }], - }; - } - let barThickness = 7; + const activeServices = hasOpensearch ? [...SERVICE_CONFIG, OPENSEARCH_CONFIG] : SERVICE_CONFIG; + const lastUpdated = latestEntry ? new Date(latestEntry.updated * 1000).toLocaleString() : null; + const backendVersion = latestEntry?.workflows?.backend_version || null; - const labels = filteredData.map((value, i) => { - if (selectedRange === '24hr') { - const [datePart, hourPart] = value.date.split(' '); - const [month, day, year] = datePart.split('/'); - const monthIndex = parseInt(month, 10) - 1; - const date = new Date(year, monthIndex, day); - let formattedDate = `${date.toLocaleString('en-US', { month: 'short', day: '2-digit' })}`; - // Add hour part if available - if (hourPart) { - formattedDate += `, ${hourPart.split(':').slice(0, 2).join(':')}`; - } + // --- Handlers --- - return `${formattedDate} \nUptime: ${value.avgRunFinished.toFixed(2)}%`; - } else if (selectedRange === '7day') { - const [datePart, hourPart] = value.date.split(' '); - const [month, day, year] = datePart.split('/'); - const monthIndex = parseInt(month, 10) - 1; - const date = new Date(year, monthIndex, day); - let formattedDate = `${date.toLocaleString('en-US', { month: 'short', day: '2-digit' })}`; - - // Add hour part if available - if (hourPart) { - formattedDate += `, ${hourPart.split(':').slice(0, 2).join(':')}`; - } - return `${formattedDate} \nUptime: ${value.avgRunFinished.toFixed(2)}%`; - } - else { - const dateParts = value.date.split('/'); // Assuming the date format is "DD/MM/YYYY" - const date = new Date(`${dateParts[2]}-${dateParts[0]}-${dateParts[1]}`); // Reformat the date string to "YYYY-MM-DD" - - return `${date.toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' })} \nUptime: ${value.avgRunFinished.toFixed(2)}%`; - } - }); - - if (selectedRange === '24hr') { - barThickness = 35; - } - else if (selectedRange === '7day') { - barThickness = 5; - } - else if (selectedRange === '30d') { - barThickness = 25; - } - - const datasets = [{ - label: "", - data: filteredData.map(item => 1), - backgroundColor: filteredData.map(item => item.color), - borderWidth: 1, - barThickness: barThickness, - }]; - - return { labels, datasets }; - }; - - const options = { - legend: { - display: false - }, - layout: { - padding: { - top: 0, // Adjust the top padding as needed - bottom: 20, // Adjust the bottom padding as needed - left: 20, // Adjust the left padding as needed - right: 20 // Adjust the right padding as needed - } - }, - scales: { - yAxes: [{ - ticks: { - display: false - } - }], - xAxes: [{ - ticks: { - display: false - } - }] - }, - tooltips: { - callbacks: { - label: function (tooltipItem, data) { - const label = data.labels[tooltipItem.index]; - return label.split('\n')[0]; // Return only the date part - }, - afterLabel: function (tooltipItem, data) { - const label = data.labels[tooltipItem.index]; - const uptime = label.match(/Uptime:\s*(\d+(?:\.\d+)?)/)[1]; // Extract uptime value using regex - return `Test-Workflow Health: ${uptime}%`; // Customize the uptime display - }, - title: function () { - return 'Fully Operational'; // Hide the tooltip title - } - } - } - }; - - const handleBarClick = (event, elements) => { - if (event && event.length > 0) { - const clickedIndex = event[0]._index - const clickedData = filteredData[clickedIndex] - const executionIds = clickedData.executionIds - .filter(executionId => { - const item = healthData.find(dataItem => dataItem.workflows.execution_id === executionId); - return item && item.workflows.run_finished === false; - }); - - // console.log("Filtered Execution IDs:", executionIds); - if (executionIds.length > 0) { - const url = `${globalUrl}/api/v1/health/stats?execution_id=${executionIds.join(',')}`; - window.open(url, '_blank'); - } else { - toast.success("All executions in selected period succeeded"); - } - } + const copyToClipboard = (text) => { + navigator.clipboard.writeText(text) + .then(() => toast.success('Copied to clipboard')) + .catch(() => toast.error('Failed to copy')); }; const handleFixOpensearchPrefix = async () => { - if (isFixingOpensearchPrefix) { - return; - } - + if (isFixingOpensearchPrefix) return; setIsFixingOpensearchPrefix(true); try { - const response = await fetch(`${globalUrl}/api/v1/health/opensearch-prefix`, { + const response = await fetch(`${window.location.origin}/api/v1/health/opensearch-prefix`, { method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, + headers: { "Content-Type": "application/json", Accept: "application/json" }, credentials: "include", }); - const data = await response.json(); if (!response.ok || !data.success) { - const reason = data && data.reason ? data.reason : "Failed to fix opensearch prefix"; - throw new Error(reason); + throw new Error(data?.reason || "Failed to fix opensearch prefix"); } - const reindexed = data.reindexed ? data.reindexed.length : 0; const aliasUpdates = data.alias_updates ? data.alias_updates.length : 0; const deleted = data.deleted_indices ? data.deleted_indices.length : 0; @@ -399,130 +522,513 @@ const HealthPage = (props) => { } }; - const healthBarData = updateChartData() + const handleBarClick = (serviceKey, barData) => { + if (!barData.failures || barData.failures.length === 0) { + toast.success('All checks passed in this period'); + setSelectedFailureDetails(null); + return; + } + setSelectedFailureDetails({ serviceKey, barData }); + }; + // --- Render helpers --- + + const renderOpBadge = (op) => { + const ok = op.value === true; + const warn = op.value === 'warn'; + const dotColor = ok ? '#00F670' : warn ? '#FFD700' : '#FF354C'; + const bg = ok ? 'rgba(0,246,112,0.07)' : warn ? 'rgba(255,215,0,0.07)' : 'rgba(255,53,76,0.07)'; + const border = ok ? 'rgba(0,246,112,0.18)' : warn ? 'rgba(255,215,0,0.18)' : 'rgba(255,53,76,0.18)'; + return ( +
+
+ {op.label} +
+ ); + }; + + const renderServiceCard = (cfg) => { + const chartData = serviceCharts[cfg.key] || []; + const avgUptime = computeAvgUptime(chartData); + const statusKey = getStatusKey(avgUptime, cfg.sloTarget); + const status = STATUS_STYLE[statusKey]; + const sloMet = statusKey === 'operational'; + const latestOps = latestEntry ? cfg.getOperations(latestEntry) : []; + const extra = latestEntry ? cfg.getExtra(latestEntry) : null; + const totalFails = chartData.reduce((acc, d) => acc + (d.failures?.length || 0), 0); + const IconComp = cfg.Icon; + + return ( +
+ {/* LEFT — identity + uptime number */} +
+
+
+ +
+ {cfg.label} + +
+ +
+ + {avgUptime.toFixed(2)} + + % + uptime +
+ +
+ SLO {cfg.sloTarget}% + + {totalFails > 0 && ( + {totalFails} incident{totalFails !== 1 ? 's' : ''} + )} +
+
+ + {/* CENTRE — full-width bar chart + SLO rail */} +
+ {/* SLO progress rail */} +
+
+ +
+ +
+ + {/* History spark bars */} + handleBarClick(cfg.key, barData)} + /> + + + ← newest · oldest → · click bar for details + +
+ + {/* RIGHT — last check operations */} +
+ Last Check +
+ {latestOps.map(renderOpBadge)} +
+ {extra && ( + {extra} + )} +
+
+ ); + }; + + const renderFailureDetails = () => { + if (!selectedFailureDetails) return null; + const { serviceKey, barData } = selectedFailureDetails; + const cfg = activeServices.find(s => s.key === serviceKey); + if (!cfg) return null; + + console.log("bar is: ", barData) + + return ( +
+ {/* Panel header */} +
+
+
+ + {cfg.label} — {barData.date} + + + {barData.value != null ? barData.value.toFixed(2) : '—'}% uptime in this period +
+ +
+ + {/* Failure rows */} +
+ {barData.failures.slice(0, 20).map((item, idx) => { + const ops = cfg.getOperations(item); + const extra = cfg.getExtra(item); + const ids = cfg.getIds ? cfg.getIds(item) : []; + const errors = cfg.getErrors ? cfg.getErrors(item) : []; + return ( +
+ + {/* Row header: index + timestamp + op-status badges */} +
+ #{idx + 1} + + {new Date(item.updated * 1000).toLocaleString()} + +
+ {ops.map(op => { + const ok = op.value === true; + const warn = op.value === 'warn'; + return ( + + ); + })} +
+ {extra && {extra}} +
+ + {/* Error reasons */} + {errors.length > 0 && ( +
+ Details + {errors.map(err => ( +
+ {err.label}: + {err.msg} +
+ ))} +
+ )} + + {/* IDs with copy buttons */} + {ids.length > 0 && ( +
+ {ids.map(id => ( +
+ {id.label} + + {id.value.length > 20 ? id.value.substring(0, 20) + '…' : id.value} + + + + +
+ ))} +
+ )} + +
+ ); + })} + {barData.failures.length > 20 && ( +
+ + +{barData.failures.length - 20} more records not shown + +
+ )} +
+
+ ); + }; + + // --- Render --- + + const rangeLabel = { '24hr': 'last 24h', '7day': 'last 7 days', '30d': 'last 30 days', '90d': 'last 90 days', '180d': 'last 180 days', '365d': 'last 365 days' }; + const activRangeLabel = customRange + ? `${customRange.startLabel} – ${customRange.endLabel}` + : (rangeLabel[selectedRange] || selectedRange); return ( -
- {/* Health Bar Chart Section */} -
- - - - - - - - -
+
+
- {/* Loading Bar for HealthBarChart */} - {isHealthLoading && ( - - )} - -
-
-
- -
- Workflow Health - Operational -
+ {/* === Page header === */} +
+
+
+ Platform Health + {isCloud && ( + + + + )} + {!isCloud && ( + + )}
-
- {averageUptime.toFixed(2)}% - Success Rate + +
+ {backendVersion && Backend v{backendVersion}} + {lastUpdated && Last check: {lastUpdated}} + {isCloud ? 'Cloud' : 'On-Premises'} +
+ + {/* === Legend (Moved to top) === */} +
+ {[{ color: '#00F670', label: '≥ SLO Healthy' }, { color: '#FFD700', label: '95–SLO Degraded' }, { color: '#FF354C', label: '< 95% Outage' }].map(({ color, label }) => ( +
+
+ {label} +
+ ))} + Click any bar to view failure details · White marker = SLO target
- -
- {userdata.support_access && ( -
-
- Live Executions - - - - - - {/* */} +
+
+ + {[['24hr', '24h'], ['7day', '7d'], ['30d', '30d'], ['90d', '90d'], ['180d', '180d'], ['365d', '365d']].map(([key, label]) => { + const isDisabled = isHealthLoading || ['90d', '180d', '365d'].includes(key); + const isActive = !customRange && selectedRange === key; + return ( + + ); + })} + + {/* Calendar range picker button */} + + +
- {/* Loading Bar for LiveExecutionsChart */} - {isLiveExecutionsLoading && ( - + + {/* Show active custom range label */} + {customRange && ( +
+ + {customRange.startLabel} – {customRange.endLabel} + + +
)} - +
+ + {/* Calendar popover */} + setCalendarAnchor(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} + transformOrigin={{ vertical: 'top', horizontal: 'right' }} + PaperProps={{ style: { backgroundColor: '#111', border: '1px solid #252525', borderRadius: 12, minWidth: 310, overflow: 'hidden', boxShadow: '0 12px 40px rgba(0,0,0,0.7)', display: 'flex', flexDirection: 'column' } }} + > + {/* Header */} +
+ + Custom Date Range + Max 60 days +
+ + {/* Fields */} +
+ setCustomStart(e.target.value)} + InputLabelProps={{ shrink: true, style: { color: '#777', fontSize: 12 } }} + inputProps={{ max: customEnd || undefined, style: { color: '#e0e0e0', fontSize: 13, backgroundColor: '#1a1a1a', borderRadius: 6 } }} + sx={{ + '& .MuiOutlinedInput-root': { backgroundColor: '#1a1a1a', borderRadius: '8px' }, + '& .MuiOutlinedInput-notchedOutline': { borderColor: '#2e2e2e' }, + '& .MuiOutlinedInput-root:hover .MuiOutlinedInput-notchedOutline': { borderColor: '#FF844466' }, + '& .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: '#FF8444', borderWidth: 1 }, + '& .MuiInputLabel-root.Mui-focused': { color: '#FF8444' }, + '& input::-webkit-calendar-picker-indicator': { filter: 'invert(0.4)' }, + }} + /> + setCustomEnd(e.target.value)} + InputLabelProps={{ shrink: true, style: { color: '#777', fontSize: 12 } }} + inputProps={{ min: customStart || undefined, style: { color: '#e0e0e0', fontSize: 13, backgroundColor: '#1a1a1a', borderRadius: 6 } }} + sx={{ + '& .MuiOutlinedInput-root': { backgroundColor: '#1a1a1a', borderRadius: '8px' }, + '& .MuiOutlinedInput-notchedOutline': { borderColor: '#2e2e2e' }, + '& .MuiOutlinedInput-root:hover .MuiOutlinedInput-notchedOutline': { borderColor: '#FF844466' }, + '& .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: '#FF8444', borderWidth: 1 }, + '& .MuiInputLabel-root.Mui-focused': { color: '#FF8444' }, + '& input::-webkit-calendar-picker-indicator': { filter: 'invert(0.4)' }, + }} + /> + + {/* Live duration indicator */} + {customStart && customEnd && (() => { + const days = Math.round((new Date(customEnd) - new Date(customStart)) / (1000 * 60 * 60 * 24)) + 1; + const tooLong = days > 60; + const invalid = days <= 0; + const color = invalid ? '#666' : tooLong ? '#FF354C' : '#FF8444'; + const bg = invalid ? 'rgba(255,255,255,0.03)' : tooLong ? 'rgba(255,53,76,0.08)' : 'rgba(255,132,68,0.08)'; + const border = invalid ? '#2a2a2a' : tooLong ? 'rgba(255,53,76,0.25)' : 'rgba(255,132,68,0.25)'; + return ( +
+
+ + {invalid ? 'End date must be after start date' : tooLong ? `${days} days — exceeds 60-day limit` : `${days} day${days !== 1 ? 's' : ''} selected`} + +
+ ); + })()} +
+ + {/* Footer */} +
+ + +
+ +
+ + {/* Loading */} + {isHealthLoading && } + + {/* === System status banner === */} + {!isHealthLoading && filteredHealthData.length > 0 && ( +
+ {systemStatus === 'operational' + ? + : systemStatus === 'degraded' + ? + : } +
+ + {systemStatus === 'operational' ? 'All Systems Operational' : systemStatus === 'degraded' ? 'Degraded Performance Detected' : 'Some Services Affected'} + + + {filteredHealthData.length} health check{filteredHealthData.length !== 1 ? 's' : ''} · {activRangeLabel} + +
+ + {/* SLO summary dots */} +
+ {activeServices.map(cfg => { + const uptime = computeAvgUptime(serviceCharts[cfg.key]); + const dotColor = uptime >= cfg.sloTarget ? '#00F670' : uptime >= 95 ? '#FFD700' : '#FF354C'; + return ( + +
+
+ {cfg.label} + {uptime.toFixed(2)}% +
+ + ); + })} +
)} + {/* === Service health rows (vertical) === */} +
+ {activeServices.map(cfg => renderServiceCard(cfg))} +
+ {/* === Failure details panel === */} + {renderFailureDetails()} + + {/* Legend removed and moved to the top */} + + {/* === Live Executions === */} + {userdata.support_access && ( + <> +
+
+
+ Live Executions + + {[['1h', '1h'], ['7h', '7h'], ['1d', '1d'], ['7d', '7d']].map(([key, label]) => ( + + ))} + +
+ {isLiveExecutionsLoading && } + +
+ + )} +
); }; -export default HealthPage; +export default HealthPage; \ No newline at end of file diff --git a/frontend/src/components/LeftSideBar.jsx b/frontend/src/components/LeftSideBar.jsx index 056867ae..6050f092 100644 --- a/frontend/src/components/LeftSideBar.jsx +++ b/frontend/src/components/LeftSideBar.jsx @@ -52,6 +52,12 @@ const ShuffleLogo = "/images/Shuffle_logo.png"; const detectionIcon = "/icons/detection.svg"; const documentationIcon = "/icons/documentation.svg"; const ExpandMoreAndLessIcon = "/icons/expandMoreIcon.svg"; +const shuffleSecurityLogo = ( + + + +); + const LeftSideBar = ({ userdata, serverside, globalUrl, notifications, SHUFFLE_VERSION }) => { @@ -87,6 +93,17 @@ const LeftSideBar = ({ userdata, serverside, globalUrl, notifications, SHUFFLE_V ); const [activeOrgData, setActiveOrgData] = useState(null); const [isProdStatusOn, setIsProdStatusOn] = useState(false); + const [productAnchorEl, setProductAnchorEl] = useState(null); + + const handleProductClick = (event) => { + event.preventDefault(); + setProductAnchorEl((prev) => (prev ? null : event.currentTarget)); + }; + + const handleProductClose = () => { + setProductAnchorEl(null); + }; + const userOrgs = React.useMemo(() => { return orgOptions.find((option) => option.name === selectedOrg); }, [selectedOrg, orgOptions]); @@ -120,7 +137,7 @@ const LeftSideBar = ({ userdata, serverside, globalUrl, notifications, SHUFFLE_V setCurrentSelectedTheme(userdata?.theme); } }, [userdata]); - + const CustomPopper = (props) => { @@ -839,6 +856,8 @@ const LeftSideBar = ({ userdata, serverside, globalUrl, notifications, SHUFFLE_V regiontag = "EU-2"; } else if (regiontag === "ca"){ regiontag = "CA"; + } else if (regiontag === "uk"){ + regiontag = "UK"; } } } @@ -1022,7 +1041,7 @@ const LeftSideBar = ({ userdata, serverside, globalUrl, notifications, SHUFFLE_V }} > - - Shuffle Logo - - + + { + !showPartnerLogo && e.preventDefault(); + }} + > + Shuffle Logo + {!showPartnerLogo && expandLeftNav && ( + + )} + + + + + + { + if (isCloud) { + //ReactGA.event({ + // category: "sidebar", + // action: "click_shuffle_security", + // label: "", + //}) + + window.location.href = "https://security.shuffler.io/incidents?utm_source=shuffler_sidebar"; + } else { + const { protocol, hostname } = window.location; + + var newPort = 3002; + if (protocol === "https") { + newPort = 3444 + } + + const newUrl = `${protocol}//${hostname}:${newPort}/incidents`; + window.location.href = newUrl; + } + }} + sx={{ + borderRadius: "8px", + padding: "10px 12px", + border: "1px solid transparent", + "&:hover": { + backgroundColor: themeMode === "dark" ? "#2C2C2C" : "#F5F5F5", + }, + display: "flex", + gap: "12px", + alignItems: "center", + }} + > + + {shuffleSecurityLogo} + + + Shuffle{" "} + Security + + + { + handleProductClose(); + window.location.href = + isCloud && !showPartnerLogo ? "/" : "/workflows"; + }} + sx={{ + borderRadius: "8px", + padding: "10px 12px", + border: + themeMode === "dark" + ? "1px solid rgba(242, 100, 2, 0.3)" + : "1px solid rgba(242, 100, 2, 0.2)", + backgroundColor: + themeMode === "dark" + ? "rgba(242, 100, 2, 0.05)" + : "rgba(242, 100, 2, 0.02)", + "&:hover": { + backgroundColor: + themeMode === "dark" + ? "rgba(242, 100, 2, 0.1)" + : "rgba(242, 100, 2, 0.06)", + }, + display: "flex", + gap: "12px", + alignItems: "center", + }} + > + + Shuffle + + + Shuffle{" "} + Core + + + { !isCloud && expandLeftNav && ( @@ -1747,7 +1944,8 @@ const LeftSideBar = ({ userdata, serverside, globalUrl, notifications, SHUFFLE_V
- {activeMainTab === "setup" && ( - <> - {(isIntegration || isAgent) && selectedAction && Object.getOwnPropertyNames(selectedAction).length > 0 && selectedActionParameters?.length > 0 ? - apps !== undefined && apps !== null && apps.length > 0 && wrapperapp !== undefined && newimage !== undefined ? -
-
{ - selectedAction.example = "noapp" - selectedAction.large_image = newimage - if (cy !== undefined && cy !== null) { - const foundnode = cy.getElementById(selectedAction.id) - if (foundnode !== undefined && foundnode !== null) { - foundnode.data("large_image", newimage) - } - } - - /* - const iconInfo = GetIconInfo(selectedAction) - if (iconInfo !== undefined && iconInfo !== null) { - selectedAction.fillGradient = iconInfo.fillGradient - - selectedAction.iconBackground = iconInfo.iconBackgroundColor - selectedAction.fillstyle = "linear-gradient" - } - */ - - const paramIndex = selectedAction.parameters !== undefined && selectedAction.parameters !== null ? selectedAction.parameters.findIndex((param) => param.name === "app_name") : -1 - if (paramIndex === -1) { - console.log("Couldn't find app_name parameter") - selectedAction.parameters.push({ - name: "app_name", - value: wrapperapp.name, - autocompleted: false, - }) - } else { - selectedAction.parameters[paramIndex].value = wrapperapp.name - } - - setSelectedAction(selectedAction) - setUpdate(Math.random()) - - }}> - -
- -
-
-
- - {apps.map((app, appIndex) => { - // Forces it into every category (for now) - // This is to make it possible to "use" shuffle for Singul natively - if (app.name === "Shuffle Tools") { - if (actionname == "Intel" || actionname == "Intel") { - app.categories = [actionname] - } - } - - if (app.categories === undefined || app.categories === null || app.categories.length === 0) { - return null - } - - var newactionname = actionname.toLowerCase() - if (isAgent === true) { - newactionname = "ai" - } - - var found = false - for (var key in app.categories) { - - var localnewactionname = newactionname - if (newactionname == "comms") { - localnewactionname = "communication" - } - - if (app.categories[key].toLowerCase() !== localnewactionname) { - continue - } - - found = true - break - } - - if (!found) { - return null - } - - var isAppSelected = false - const paramIndex = selectedAction.parameters.findIndex((param) => param.name === "app_name") - if (paramIndex > -1) { - // Check the actual value and if it's the same - if (selectedAction.parameters[paramIndex].value === app.name) { - isAppSelected = true - } - } - - return ( -
{ - selectedAction.example = "" - selectedAction.large_image = app.large_image - if (cy !== undefined && cy !== null) { - const foundnode = cy.getElementById(selectedAction.id) - if (foundnode !== undefined && foundnode !== null) { - foundnode.data("large_image", app.large_image) - } - } - - if (paramIndex === -1) { - console.log("Couldn't find app_name parameter") - selectedAction.parameters.push({ - name: "app_name", - value: app.name, - autocompleted: false, - }) - } else { - selectedAction.parameters[paramIndex].value = app.name - } - - setSelectedAction(selectedAction) - setUpdate(Math.random()) - - - var requiresAuth = app?.authentication?.required - if (requiresAuth && appAuthentication?.length > 0) { - for (var key in appAuthentication) { - if (appAuthentication[key]?.app?.name === app?.name) { - requiresAuth = false - break - } - } - } - - setRequiresAuthentication(requiresAuth); - }}> - - - -
- ) - })} -
- : null - : - null - } + + {activeMainTab === "setup" && ( + <>
Name @@ -2657,7 +2652,21 @@ const ParsedAction = (props) => { placeholder={selectedAction.execution_delay} value={delay} onChange={(event) => { + // Check if positive number + if (isNaN(event.target.value) || Number(event.target.value) < 0) { + toast.error("Please enter a valid positive number for delay.") + return + } + + // Check if first number is 0 + if (event.target.value.length > 1 && event.target.value.charAt(0) === "0") { + event.target.value = event.target.value.substring(1) + } + setDelay(event.target.value) + selectedAction.execution_delay = event.target.value + setSelectedAction(selectedAction) + setUpdate(Math.random()) }} /> @@ -2702,9 +2711,13 @@ const ParsedAction = (props) => { fullWidth variant="contained" onClick={() => { - //if (authenticationType.type === "oauth2" && authenticationType.redirect_uri !== undefined && authenticationType.redirect_uri !== null) { - // return null - //} + if (isCloud) { + ReactGA.event({ + category: "Integration", + action: "Authenticate", + label: `${selectedApp?.name} - Open 1`, + }) + } setAuthenticationModalOpen(true); }} @@ -2718,19 +2731,7 @@ const ParsedAction = (props) => { ) : null} {/* Change made in new release when we added Tabs system in it */} - {( - (selectedAction.authentication !== undefined && - selectedAction.authentication !== null && - selectedAction.authentication.length > 0) || - (selectedApp.name !== undefined && - (((selectedAction.authentication === undefined || - selectedAction.authentication === null || - selectedAction.authentication.length === 0)) || - isAgent || - isIntegration) && - requiresAuthentication) - ) ? ( - + {appMayNeedAuth ? (
{ 0 && selectedAction?.selectedAuthentication && Object.getOwnPropertyNames(selectedAction.selectedAuthentication).length !== 0 ? ( + workflow?.suborg_distribution?.length > 0 && selectedAction?.selectedAuthentication && typeof selectedAction.selectedAuthentication === 'object' && Object.getOwnPropertyNames(selectedAction.selectedAuthentication).length !== 0 ? (
{ labelId="select-app-auth" value={ selectedAction?.authentication_id === "authgroups" ? "authgroups" : - !selectedAction?.selectedAuthentication || Object.getOwnPropertyNames(selectedAction.selectedAuthentication).length === 0 + (selectedAction?.selectedAuthentication === null || !selectedAction?.selectedAuthentication || typeof selectedAction.selectedAuthentication !== 'object' || Object.getOwnPropertyNames(selectedAction.selectedAuthentication).length === 0) ? "No selection" : selectedAction?.selectedAuthentication } @@ -2997,6 +2998,14 @@ const ParsedAction = (props) => { variant="outlined" style={{}} onClick={() => { + if (isCloud) { + ReactGA.event({ + category: "Integration", + action: "Authenticate", + label: `${selectedApp?.name} - Open 2`, + }) + } + setAuthenticationModalOpen(true); }} > @@ -3005,68 +3014,18 @@ const ParsedAction = (props) => {
- {requiresAuthentication && (!selectedAction.authentication_id || selectedAction.authentication_id === "") ? ( -
- - Authentication needed. - - - Some steps in this workflow won’t run until you connect your account. - - -
- ) : null} +
) : null} - {selectedAction.authentication_id === "authgroups" && (authGroups === undefined || authGroups === null || authGroups.length === 0) ? - - - Create your first Authentication group - - - : null} + {selectedAction.authentication_id === "authgroups" && (authGroups === undefined || authGroups === null || authGroups.length === 0) ? + + + Create your first Authentication group + + + : null} {/*showEnvironment !== undefined && showEnvironment && environments.length > 1 && !isIntegration ? ( @@ -3159,6 +3118,8 @@ const ParsedAction = (props) => {
) : null*/} + + {workflow.execution_variables !== undefined && workflow.execution_variables !== null && workflow.execution_variables.length > 0 ? (
Runtime variable (optional) @@ -3470,11 +3431,80 @@ const ParsedAction = (props) => { ) : null}
+ {appMayNeedAuth && !hasAuth && !isAgent && !isIntegration ? ( +
+ + Authentication needed {isIntegration || isAgent ? `` : "."} + + + This step may not work until you authenticate it. + + +
+ ) : null} + {activeMainTab === "setup" && ( + + + + setAnchorEl(null)} + + anchorOrigin={{ + vertical: 'bottom', + horizontal: 'left', + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'left', + }} + + style={{ + zIndex: 20000, + marginTop: 2, + border: "1px solid rgba(255,255,255,0.3)", + }} + + PaperProps={{ + style: { + maxHeight: 600, + maxWidth: 450, + } + }} + > + { + e.preventDefault() + e.stopPropagation() + + const paramIndex = selectedAction.parameters.findIndex((param) => param.name === "app_name") + if (paramIndex === -1) { + selectedAction.parameters.push({ + "description": "The name of the app to run the LLM query against", + "id": "", + "name": "app_name", + "example": "", + "value": "", + "multiline": false, + "multiselect": false, + "options": null, + "action_field": "", + "variant": "STATIC_VALUE", + "required": true, + "configuration": false, + "tags": null, + "schema": { + "type": "" + }, + "skip_multicheck": false, + "value_replace": null, + "unique_toggled": false, + "error": "", + "hidden": false, + + "custom_value": true, + }) + } else { + selectedAction.parameters[paramIndex].custom_value = true + } + + // Overwrite params for custom value handling + const newSelectedActionParameters = JSON.parse(JSON.stringify(selectedAction?.parameters)) + setSelectedActionParameters(newSelectedActionParameters) + setSelectedAction(selectedAction) + setAnchorEl(null) + }} + selected={false} + style={{ + margin: 7, + display: "flex", + minWidth: 400, + maxWidth: 400, + cursor: "pointer", + }} + > + + Custom Value + + + + + {apps.map((item, index) => { + const parsedName = (item.name?.charAt(0).toUpperCase() + item.name?.substring(1)).replace(/_/g, " ") + return ( + { + //handleSelect(item) + setSelectedActionLocal(selectedAction, item) + setAnchorEl(null) + }} + selected={false} + style={{ + margin: 7, + display: "flex", + minWidth: 400, + maxWidth: 400, + cursor: "pointer", + }} + > + + + {parsedName} + + + ) + })} + + + + {apps.map((app, appIndex) => { + // Forces it into every category (for now) + // This is to make it possible to "use" shuffle for Singul natively + if (app.name === "Shuffle Tools") { + if (actionname == "Intel" || actionname == "Intel") { + app.categories = [actionname] + } + } + + var isAppSelected = false + const paramIndex = selectedAction.parameters.findIndex((param) => param.name === "app_name") + if (paramIndex > -1) { + // Check the actual value and if it's the same + //if (selectedAction.parameters[paramIndex].value === app.name) { + if (selectedAction.parameters[paramIndex].value.includes(app.name)) { + isAppSelected = true + } + } + + if (app.categories === undefined || app.categories === null || app.categories.length === 0) { + if (!isAppSelected) { + return null + } + } + + var newactionname = actionname.toLowerCase() + if (isAgent === true) { + //newactionname = "ai" + } else { + var found = false + for (var key in app.categories) { + + var localnewactionname = newactionname + if (newactionname == "comms") { + localnewactionname = "communication" + } + + if (app.categories[key].toLowerCase() !== localnewactionname) { + continue + } + + found = true + break + } + + if (!found && !isAppSelected) { + return null + } + } + + + return ( +
{ + setSelectedActionLocal(selectedAction, app) + }}> + + + +
+ ) + })} + +
+
+ : null + : null + }
@@ -3524,7 +3824,7 @@ const ParsedAction = (props) => { marginBottom: hideExtraTypes ? 50 : 200, }} > { - selectedActionParameters !== undefined && selectedActionParameters !== null && selectedAction && Object.getOwnPropertyNames(selectedAction).length > 0 && selectedActionParameters.length > 0 ? + selectedActionParameters !== undefined && selectedActionParameters !== null && selectedAction && selectedAction !== null && typeof selectedAction === 'object' && Object.getOwnPropertyNames(selectedAction).length > 0 && selectedActionParameters.length > 0 ?
{/* { } if ((isIntegration || isAgent) && data.name === "app_name") { - return null - } + if (data?.custom_value === true) { + //data.label = "Allowed MCPs" + } else { + return null + } + } /* // Somehow autogenerate from the app itself @@ -3827,13 +4131,9 @@ const ParsedAction = (props) => { } if (selectedAction.name === "custom_action" && data.name === "body") { - for (var key in selectedActionParameters) { - const param = selectedActionParameters[key] - if (param.name === "method") { - if (param.value === "GET") { - return null - } - } + const methodParam = selectedActionParameters.find(p => p.name === "method") || selectedAction.parameters?.find(p => p.name === "method"); + if (methodParam?.value?.toUpperCase() === "GET") { + return null } } @@ -3897,6 +4197,7 @@ const ParsedAction = (props) => { backgroundColor: themeMode === "dark" ? "#161616" : "#CCCCCC", color: theme.palette.text.primary, fontWeight: 600, + borderRadius: "6px !important", "&:hover": { backgroundColor: themeMode === "dark" ? "rgba(0,0,0,0.3)" : "rgba(0,0,0,0.1)", }, @@ -4322,7 +4623,7 @@ const ParsedAction = (props) => { } - if ((multiline === undefined || multiline === false) && ((data?.autocompleted === true || data?.field_active === true) || data.name.startsWith("${") && data.name.endsWith("}"))) { + if ((multiline === undefined || multiline === false) && (data.name.startsWith("${") && data.name.endsWith("}"))) { multiline = true } @@ -4827,6 +5128,16 @@ const ParsedAction = (props) => { fullWidth id={"rightside_field_" + count} onChange={(e) => { + if (e.target.value.includes("custom_shuffle_action")) { + data.options = [] + selectedActionParameters[count].options = [] + setSelectedActionParameters(selectedActionParameters) + selectedAction.parameters = selectedActionParameters + setSelectedAction(selectedAction) + setUpdate(Math.random()) + return + } + changeActionParameter(e, count, data); setUpdate(Math.random()); }} @@ -4871,6 +5182,22 @@ const ParsedAction = (props) => { ); } )} + + + {isAgent || isIntegration ? + + Custom Value + + : null} ); } else if (data.variant === "STATIC_VALUE") { @@ -5263,12 +5590,14 @@ const ParsedAction = (props) => { const buttonTitle = `Authenticate the ${selectedApp?.name?.replaceAll("_", " ")} API` const hasAutocomplete = data?.autocompleted === true + const isPathField = selectedAction?.name === "custom_action" && data?.name === "path" + if (data.variant === undefined || data.variant === null) { data.variant = "STATIC_VALUE" } - var isFirstOptional = optionalFound === false && data.configuration === false && data.required === false ? true : false - if (optionalFound === false && data.configuration === false && data.required === false) { + var isFirstOptional = optionalFound === false && data.configuration === false && data.required === false && !isPathField ? true : false + if (optionalFound === false && data.configuration === false && data.required === false && !isPathField) { optionalFound = true } @@ -5295,7 +5624,7 @@ const ParsedAction = (props) => { } } - const isOptional = data.configuration === false && data.required === false + const isOptional = (data.configuration === false && data.required === false) && !isPathField return (
@@ -5352,6 +5681,13 @@ const ParsedAction = (props) => { color: theme.palette.textPrimary, }} onClick={() => { + if (isCloud) { + ReactGA.event({ + category: "Integration", + action: "Authenticate", + label: `${selectedApp?.name} - Open 4`, + }) + } setAuthenticationModalOpen(true); }} /> diff --git a/frontend/src/components/PartnersUsecasesTab.jsx b/frontend/src/components/PartnersUsecasesTab.jsx index 9cbc31a1..85c40d56 100644 --- a/frontend/src/components/PartnersUsecasesTab.jsx +++ b/frontend/src/components/PartnersUsecasesTab.jsx @@ -846,6 +846,7 @@ const PartnersUsecasesTab = ({ isCloud, globalUrl, userdata, partnerData, setPar setIsLoading(true); if(!isCloud || !userdata?.active_org?.is_partner) { // If the user is not a partner or if it's not a cloud environment do not make api call :) + setIsLoading(false); return; } // Load usecase data from API diff --git a/frontend/src/components/RuntimeDebugger.jsx b/frontend/src/components/RuntimeDebugger.jsx index ba531c5b..51a3aaaf 100644 --- a/frontend/src/components/RuntimeDebugger.jsx +++ b/frontend/src/components/RuntimeDebugger.jsx @@ -1087,6 +1087,9 @@ const RuntimeDebugger = (props) => { options={[{ "name": "Agent Runs", "id": "AGENT", + },{ + "name": "Sensor Actions", + "id": "SENSOR_ACTION", }].concat(workflows)} fullWidth style={{ diff --git a/frontend/src/components/SearchContactForm.jsx b/frontend/src/components/SearchContactForm.jsx new file mode 100644 index 00000000..ff5747a5 --- /dev/null +++ b/frontend/src/components/SearchContactForm.jsx @@ -0,0 +1,121 @@ +import React, { useState } from "react"; +import theme from "../theme.jsx"; +import { TextField, Typography, Button } from "@mui/material"; + +const SearchContactForm = ({ globalUrl, isMobile, tabName }) => { + const [formMail, setFormMail] = useState(""); + const [message, setMessage] = useState(""); + const [formMessage, setFormMessage] = useState(""); + + const submitContact = (email, message) => { + const data = { + firstname: "", + lastname: "", + title: "", + companyname: "", + email: email, + phone: "", + message: message, + }; + + const errorMessage = + "Something went wrong. Please contact frikky@shuffler.io directly."; + + fetch(globalUrl + "/api/v1/contact", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(data), + }) + .then((response) => response.json()) + .then((response) => { + setFormMessage( + response?.success === true ? response.reason : errorMessage + ); + setFormMail(""); + setMessage(""); + }) + .catch(() => { + setFormMessage(errorMessage); + }); + }; + + return ( +
+ + Can't find what you're looking for? + +
+ setFormMail(e.target.value)} + /> + setMessage(e.target.value)} + /> +
+ + + {formMessage} + +
+ ); +}; + +export default SearchContactForm; diff --git a/frontend/src/components/SearchData.jsx b/frontend/src/components/SearchData.jsx index de209b4b..f7a7dd33 100644 --- a/frontend/src/components/SearchData.jsx +++ b/frontend/src/components/SearchData.jsx @@ -47,7 +47,7 @@ const chipStyle = { backgroundColor: "#3d3f43", height: 30, marginRight: 5, paddingLeft: 5, paddingRight: 5, height: 28, cursor: "pointer", borderColor: "#3d3f43", color: "white", } -const searchClient = algoliasearch("JNSS5CFDZZ", "c8f882473ff42d41158430be09ec2b4e") +const searchClient = algoliasearch("JNSS5CFDZZ", "33e4e3564f4f060e96e0531957bed552") const SearchData = props => { const { serverside, globalUrl, userdata } = props let navigate = useNavigate(); diff --git a/frontend/src/components/Searchfield.jsx b/frontend/src/components/Searchfield.jsx index 4447c73b..57558241 100644 --- a/frontend/src/components/Searchfield.jsx +++ b/frontend/src/components/Searchfield.jsx @@ -38,7 +38,6 @@ import aa from 'search-insights' import { InstantSearch, Configure, connectSearchBox, connectHits, Index } from 'react-instantsearch-dom'; //import { InstantSearch, SearchBox, Hits, connectSearchBox, connectHits, Index } from 'react-instantsearch-dom'; -import { HotKeys } from 'react-hotkeys'; // https://www.algolia.com/doc/api-reference/widgets/search-box/react/ const chipStyle = { backgroundColor: "#3d3f43", height: 30, marginRight: 5, paddingLeft: 5, paddingRight: 5, height: 28, cursor: "pointer", borderColor: "#3d3f43", color: "white", @@ -168,4 +167,4 @@ const SearchField = props => { ) } -export default SearchField; +export default SearchField; \ No newline at end of file diff --git a/frontend/src/components/ShuffleCodeEditor1.jsx b/frontend/src/components/ShuffleCodeEditor1.jsx index 3059d936..c2438b97 100644 --- a/frontend/src/components/ShuffleCodeEditor1.jsx +++ b/frontend/src/components/ShuffleCodeEditor1.jsx @@ -61,8 +61,6 @@ import PaperComponent from "../components/PaperComponent.jsx"; import { padding, textAlign } from '@mui/system'; import data from '../frameworkStyle.jsx'; import { useNavigate, Link, useParams, useSearchParams } from "react-router-dom"; -import { tags as t } from '@lezer/highlight'; - import AceEditor from "react-ace"; import ace from "ace-builds"; @@ -149,7 +147,10 @@ const CodeEditor = (props) => { // Auto-indent JSON-like content (with safety hehe) const autoIndentContent = React.useCallback((content) => { - return content + if (!isFileEditor) { + console.log("Autoindent disabled") + return content + } // Safety checks :) if (!content || typeof content !== 'string' || content.trim().length === 0) { @@ -238,6 +239,24 @@ const CodeEditor = (props) => { expectedOutput(localcodedata) }, [localcodedata]) + useEffect(() => { + if (!isFileEditor) { + return + } + + if (codedata === undefined || codedata === null || typeof codedata !== 'string') { + return + } + + const indentedContent = autoIndentContent(codedata); + if (indentedContent !== undefined && indentedContent !== null) { + console.log("SETTING: ", indentedContent) + setlocalcodedata(indentedContent); + } else { + console.log("INDENT FAILED") + } + }, []) + // Auto-indent when codedata prop changes useEffect(() => { if (codedata && codedata !== localcodedata && typeof codedata === 'string') { @@ -1597,7 +1616,7 @@ const CodeEditor = (props) => { if (e.srcElement.className === "ace_content") { console.log("DRAG STOP IN CONTENT!", e.srcElement.className) - let usedposition = e.offsetY + const usedposition = e.offsetY if (usedposition === undefined || usedposition === null) { toast.info(`Error: LayerY is undefined or null. Please contact ${supportEmail}`) return @@ -1784,8 +1803,8 @@ const CodeEditor = (props) => { // zIndex: 12501, pointerEvents: "auto", color: theme.palette.DialogStyle.color, - minWidth: isMobile || isWorkflowEditor || fullScreenModeEnabled ? "100%" : isFileEditor ? "650px" : "80%", - maxWidth: isMobile || isWorkflowEditor || fullScreenModeEnabled ? "100%" : isFileEditor ? "650px" : "1100px", + minWidth: isMobile || isWorkflowEditor || fullScreenModeEnabled ? "100%" : isFileEditor ? 800 : "80%", + maxWidth: isMobile || isWorkflowEditor || fullScreenModeEnabled ? "100%" : isFileEditor ? 800 : "1100px", minHeight: isMobile || isWorkflowEditor || fullScreenModeEnabled ? "100%" : "auto", maxHeight: isMobile || isWorkflowEditor || fullScreenModeEnabled ? "100%" : "700px", border: "3px solid rgba(255,255,255,0.3)", @@ -1981,7 +2000,8 @@ const CodeEditor = (props) => { paddingLeft: 10, }} > - File Editor ({localcodedata.length}) + {/* cba positioning */} + File Editor ({localcodedata.length})                                                                             {validation === true ? Valid JSON : Invalid JSON}
@@ -2511,7 +2531,7 @@ const CodeEditor = (props) => { }
- {(actionId || triggerId || conditionId) && !isWorkflowEditor && !isFileEditor ? + {/*(actionId || triggerId || conditionId) && !isWorkflowEditor && !isFileEditor ? <> { : null - } + */}
} @@ -2583,7 +2603,7 @@ const CodeEditor = (props) => { mode={isWorkflowEditor ? "yaml" : selectedAction === undefined ? "json" : selectedAction.name === "execute_python" ? "python" : selectedAction.name === "execute_bash" ? "bash" : "json"} theme="gruvbox" height={fullScreenModeEnabled ? "84vh" : isFileEditor ? 450 : isWorkflowEditor ? "90vh" : 550} - width={isFileEditor ? 650 : fullScreenModeEnabled ? "50vw" : isWorkflowEditor ? "90vw" : "100%"} + width={isFileEditor ? 800 : fullScreenModeEnabled ? isFileEditor ? "100%" : "50vw" : isWorkflowEditor ? "90vw" : "100%"} markers={markers} highlightActiveLine={false} @@ -2717,7 +2737,7 @@ const CodeEditor = (props) => {
: - {selectedAction.name === "execute_python" || selectedAction.name === "execute_bash" ? + {selectedAction?.name === "execute_python" || selectedAction?.name === "execute_bash" ? "Code to run" : triggerId ? `Output: ${triggerName?.replaceAll("_", " ").slice(0, 1).toUpperCase() + triggerName?.replaceAll("_", " ").slice(1)} (${triggerField})` : diff --git a/frontend/src/components/SubOrgDistributionDialog.jsx b/frontend/src/components/SubOrgDistributionDialog.jsx new file mode 100644 index 00000000..31740c80 --- /dev/null +++ b/frontend/src/components/SubOrgDistributionDialog.jsx @@ -0,0 +1,265 @@ +import React, { useState, useContext } from "react"; +import { + Dialog, + DialogTitle, + DialogContent, + Box, + TextField, + Button, + Typography, + List, + ListItem, + ListItemText, + Checkbox, + InputAdornment, +} from "@mui/material"; +import { Search as SearchIcon } from "@mui/icons-material"; +import { Context } from "../context/ContextApi.jsx"; +import { getTheme } from "../theme.jsx"; + +/** + * A reusable dialog for selecting/distributing sub-organizations. + * + * Props: + * open {boolean} - controls dialog visibility + * onClose {function} - called on Cancel or backdrop click (no args) + * title {string} - dialog title text + * extraInfo {string} - secondary line below the title (e.g. "Selected Key: xxx") + * orgs {Array} - ordered array of { id, name, image? } objects to display + * selectedOrgIds {string[]} - currently selected org IDs (controlled) + * onSelectionChange {function} - called with a updater fn (prev => next) when selection changes + * onSave {function} - called with the final selectedOrgIds array when Save is clicked + * disabled {boolean} - disables checkboxes and Save button (default: false) + */ +const SubOrgDistributionDialog = ({ + open, + onClose, + title, + extraInfo = null, + orgs = [], + selectedOrgIds = [], + onSelectionChange, + onSave, + disabled = false, +}) => { + const [searchQuery, setSearchQuery] = useState(""); + const { themeMode, brandColor } = useContext(Context); + const theme = getTheme(themeMode, brandColor); + + const safeOrgs = orgs || []; + + const filteredOrgs = safeOrgs.filter( + o => o && o.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + const handleSelectAll = () => { + if (searchQuery) { + const filteredIds = filteredOrgs.map(o => o.id); + onSelectionChange(prev => [...new Set([...prev, ...filteredIds])]); + } else { + const allIds = safeOrgs.map(o => o.id); + onSelectionChange(prev => [...new Set([...prev, ...allIds])]); + } + }; + + const handleDeselectAll = () => { + if (searchQuery) { + const filteredIds = filteredOrgs.map(o => o.id); + onSelectionChange(prev => prev.filter(id => !filteredIds.includes(id))); + } else { + onSelectionChange([]); + } + }; + + const handleToggle = (id) => { + if (disabled) return; + onSelectionChange(prev => + prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id] + ); + }; + + const handleClose = () => { + setSearchQuery(""); + onClose(); + }; + + const filteredSelected = filteredOrgs.filter(o => selectedOrgIds.includes(o.id)).length; + const countText = searchQuery + ? `${filteredSelected} of ${filteredOrgs.length} filtered selected` + : `${selectedOrgIds.length} of ${safeOrgs.length} selected`; + + const imageSize = 22; + const imageStyle = { width: imageSize, height: imageSize, pointerEvents: "none", marginRight: 10 }; + + return ( + + + + {title} + + {extraInfo && ( + + {extraInfo} + + )} + + + + setSearchQuery(e.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + style: { color: theme.palette.textFieldStyle?.color }, + }} + style={{ backgroundColor: theme.palette.textFieldStyle?.backgroundColor, flexShrink: 0 }} + /> + +
+ + + + {countText} + +
+ +
+ + {filteredOrgs.map((org, index) => { + const isSelected = selectedOrgIds.includes(org.id); + const hasImage = org.image !== undefined; + return ( + handleToggle(org.id)} + sx={{ + cursor: disabled ? "default" : "pointer", + backgroundColor: index % 2 === 0 + ? "transparent" + : themeMode === "dark" ? "rgba(255,255,255,0.02)" : "rgba(0,0,0,0.02)", + "&:hover": { + backgroundColor: themeMode === "dark" ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.05)", + }, + }} + > + + {hasImage && ( + org.image === "" ? ( + {org.name} + ) : ( + {org.name} + ) + )} + + + ); + })} + {filteredOrgs.length === 0 && ( + + + + )} + +
+
+ + +
+ + +
+
+
+ ); +}; + +export default SubOrgDistributionDialog; diff --git a/frontend/src/components/TenantsTab.jsx b/frontend/src/components/TenantsTab.jsx index 16cd5f11..779cc910 100644 --- a/frontend/src/components/TenantsTab.jsx +++ b/frontend/src/components/TenantsTab.jsx @@ -1,5 +1,6 @@ import React, { memo, useContext, useEffect, useState } from 'react'; -import {getTheme} from "../theme.jsx"; +import { DataGrid } from '@mui/x-data-grid'; +import { getTheme } from "../theme.jsx"; import { Context } from '../context/ContextApi.jsx'; import { FormControl, @@ -24,9 +25,11 @@ import { IconButton, Modal, Checkbox, - } from "@mui/material"; - - import { + Select, + MenuItem, +} from "@mui/material"; + +import { Edit as EditIcon, Polyline as PolylineIcon, CheckCircle as CheckCircleIcon, @@ -34,11 +37,13 @@ import { Apps as AppsIcon, Business as BusinessIcon, Flag, - ArrowDropDown as ArrowDropDownIcon, + ArrowDropDown as ArrowDropDownIcon, VisibilityOff, Visibility, + KeyboardArrowLeft, + KeyboardArrowRight, - } from "@mui/icons-material"; +} from "@mui/icons-material"; import { toast } from 'react-toastify'; @@ -73,8 +78,15 @@ const TenantsTab = memo((props) => { const theme = getTheme(themeMode, brandColor); const [accountDeleteButtonClicked, setAccountDeleteButtonClicked] = useState(false); const [selectedSuborg, setSelectedSuborg] = useState(null); + const [rowsPerPage, setRowsPerPage] = useState(10); + const [nextCursor, setNextCursor] = useState(""); + const [currentCursor, setCurrentCursor] = useState(""); + const [cursorStack, setCursorStack] = useState([]); + const [localPage, setLocalPage] = useState(0); + const [loadingSubOrgs, setLoadingSubOrgs] = useState(false); + const [isChangingOrg, setIsChangingOrg] = useState(false); useEffect(() => { - if(parentOrg !== null && parentOrgFlag === null) { + if (parentOrg !== null && parentOrgFlag === null) { let regiontag = "UK"; let regionCode = "gb"; @@ -85,25 +97,25 @@ const TenantsTab = memo((props) => { regiontag = namesplit[namesplit.length - 1]; if (regiontag === "california") { - regiontag = "US"; - regionCode = "us"; + regiontag = "US"; + regionCode = "us"; } else if (regiontag === "frankfurt") { - regiontag = "EU-2"; - regionCode = "eu"; + regiontag = "EU-2"; + regionCode = "eu"; } else if (regiontag === "ca") { - regiontag = "CA"; - regionCode = "ca"; - }else if (regiontag === "au") { + regiontag = "CA"; + regionCode = "ca"; + } else if (regiontag === "au") { regiontag = "AUS"; regionCode = "au" } } setParentOrgFlag(regionCode); setParentOrgRegionName(regiontag); + } } - } }, [parentOrg, parentOrgFlag]); - + var syncList = [ { primary: "Workflows", @@ -127,19 +139,26 @@ const TenantsTab = memo((props) => { useEffect(() => { if (userdata.orgs !== undefined && userdata.orgs !== null && userdata.orgs.length > 0) { - handleGetSubOrgs(userdata.active_org.id); + handleGetSubOrgs(userdata.active_org.id, "", 100); } else console.log("error in user data") }, [userdata]); - const handleGetSubOrgs = (orgId) => { + const handleGetSubOrgs = (orgId, cursor = "", limit = 100, direction = "next") => { + const effectiveLimit = limit !== null ? limit : 100; if (orgId.length === 0) { toast("Organization ID not defined. Please contact us on https://shuffler.io if this persists logout."); return; } - fetch(`${globalUrl}/api/v1/orgs/${orgId}/suborgs`, { + setLoadingSubOrgs(true); + let url = `${globalUrl}/api/v1/orgs/${orgId}/suborgs?limit=${effectiveLimit}`; + if (cursor) { + url += `&cursor=${encodeURIComponent(cursor)}`; + } + + fetch(url, { method: "GET", credentials: "include", headers: { @@ -154,49 +173,87 @@ const TenantsTab = memo((props) => { }) .then((responseJson) => { if (responseJson.success === false) { - setLoadOrgs(false) + setLoadOrgs(false); + setLoadingSubOrgs(false); //toast("Failed getting your org. If this persists, please contact support."); } else { - const { subOrgs, parentOrg } = responseJson; - setLoadOrgs(false) - setSubOrgs(subOrgs); + const { subOrgs, parentOrg, cursor: responseCursor } = responseJson; + setLoadOrgs(false); + setLoadingSubOrgs(false); + setSubOrgs(subOrgs || []); setParentOrg(parentOrg); + setNextCursor(responseCursor || ""); + + if (direction === "prev") { + const len = (subOrgs || []).length; + setLocalPage(len > 0 ? Math.ceil(len / rowsPerPage) - 1 : 0); + } else { + setLocalPage(0); + } let regiontag = "UK"; let regionCode = "gb"; if (parentOrg?.region_url?.length > 0) { - const regionsplit = parentOrg?.region_url.split("."); - if (regionsplit.length > 2 && !regionsplit[0].includes("shuffler")) { - const namesplit = regionsplit[0].split("/"); - regiontag = namesplit[namesplit.length - 1]; + const regionsplit = parentOrg?.region_url.split("."); + if (regionsplit.length > 2 && !regionsplit[0].includes("shuffler")) { + const namesplit = regionsplit[0].split("/"); + regiontag = namesplit[namesplit.length - 1]; - if (regiontag === "california") { - regiontag = "US"; - regionCode = "us"; - } else if (regiontag === "frankfurt") { - regiontag = "EU-2"; - regionCode = "eu"; - } else if (regiontag === "ca") { - regiontag = "CA"; - regionCode = "ca"; - }else if (regiontag === "au") { - regiontag = "AUS"; - regionCode = "au" + if (regiontag === "california") { + regiontag = "US"; + regionCode = "us"; + } else if (regiontag === "frankfurt") { + regiontag = "EU-2"; + regionCode = "eu"; + } else if (regiontag === "ca") { + regiontag = "CA"; + regionCode = "ca"; + } else if (regiontag === "au") { + regiontag = "AUS"; + regionCode = "au" + } } - } setParentOrgFlag(regionCode); setParentOrgRegionName(regiontag); - } + } } }) .catch((error) => { console.log("Error getting sub orgs: ", error); //toast("Error getting sub organizations"); - setLoadOrgs(false) + setLoadOrgs(false); + setLoadingSubOrgs(false); }); }; + const handleNextPage = () => { + const maxLocalPage = Math.ceil(subOrgs.length / rowsPerPage) - 1; + if (localPage < maxLocalPage) { + setLocalPage(prev => prev + 1); + } else if (nextCursor && nextCursor !== currentCursor) { + setCursorStack(prev => [...prev, currentCursor]); + setCurrentCursor(nextCursor); + handleGetSubOrgs(userdata.active_org.id, nextCursor, 100, "next"); + } + }; + + const handlePrevPage = () => { + if (localPage > 0) { + setLocalPage(prev => prev - 1); + } else if (cursorStack.length > 0) { + const prevCursor = cursorStack[cursorStack.length - 1]; + setCursorStack(prev => prev.slice(0, -1)); + setCurrentCursor(prevCursor); + handleGetSubOrgs(userdata.active_org.id, prevCursor, 100, "prev"); + } + }; + + const handleChangeRowsPerPage = (newSize) => { + setRowsPerPage(Number(newSize)); + setLocalPage(0); + }; + const GridItem = (props) => { const [expanded, setExpanded] = React.useState(false); const [showEdit, setShowEdit] = React.useState(false); @@ -502,7 +559,7 @@ const TenantsTab = memo((props) => { const createSubOrg = (currentOrgId, name) => { const data = { name: name, org_id: currentOrgId }; const url = globalUrl + `/api/v1/orgs/${currentOrgId}/create_sub_org`; - setSuborglistOpen(true) + setSuborglistOpen(true) fetch(url, { mode: "cors", @@ -520,8 +577,8 @@ const TenantsTab = memo((props) => { if (responseJson["success"] === false) { if (responseJson.reason !== undefined) { toast.error(responseJson.reason, { - autoClose: 5000, - }) + autoClose: 5000, + }) } else { toast("Failed creating suborg. Please try again"); } @@ -554,6 +611,7 @@ const TenantsTab = memo((props) => { localStorage.setItem("globalUrl", ""); localStorage.setItem("getting_started_sidebar", "open"); + setIsChangingOrg(true); fetch(`${globalUrl}/api/v1/orgs/${orgId}/change`, { mode: "cors", credentials: "include", @@ -569,8 +627,8 @@ const TenantsTab = memo((props) => { if (response.status !== 200) { console.log("Error in response"); } else { - localStorage.setItem("apps", []) - } + localStorage.setItem("apps", []) + } return response.json(); }) @@ -607,212 +665,215 @@ const TenantsTab = memo((props) => { const [disabled, setDisabled] = useState(true); const [open, setOpen] = useState(true); const boxStyling = { - position: "relative", - top: "50%", - left: "50%", - transform: "translate(-50%, -50%)", - zIndex: "9999", - backgroundColor: theme.palette.backgroundColor, - color: theme.palette.text.primary, - padding: 20, - borderRadius: 5, - boxShadow: "0 0 10px rgba(0, 0, 0, 0.3)", - width: 430, - height: 430, + position: "relative", + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", + zIndex: "9999", + backgroundColor: theme.palette.backgroundColor, + color: theme.palette.text.primary, + padding: 20, + borderRadius: 5, + boxShadow: "0 0 10px rgba(0, 0, 0, 0.3)", + width: 430, + height: 430, }; - + const closeIconButtonStyling = { - color: theme.palette.text.primary, - border: "none", - backgroundColor: "transparent", - position: 'relative', - width: 20, - height: 20, - cursor: "pointer", - left: "calc(100% - 30px)", + color: theme.palette.text.primary, + border: "none", + backgroundColor: "transparent", + position: 'relative', + width: 20, + height: 20, + cursor: "pointer", + left: "calc(100% - 30px)", }; - + const handlePasswordVisibility = () => { - setShowPassword(!showPassword); + setShowPassword(!showPassword); }; - + const buttonStyle = { - marginTop: 20, - height: 50, - border: "none", - width: "100%", - fontSize: 16, - backgroundColor: disabled ? "gray" : "red", - color: theme.palette.text.primary, - cursor: disabled === false && "pointer", - }; - + marginTop: 20, + height: 50, + border: "none", + width: "100%", + fontSize: 16, + backgroundColor: disabled ? "gray" : "red", + color: theme.palette.text.primary, + cursor: disabled === false && "pointer", + }; + const handlePasswordChange = (e) => { - setPassword(e.target.value); + setPassword(e.target.value); }; - + const handleCheckBoxEvent = () => { - setUserDeleteAccepted((prev) => !prev); + setUserDeleteAccepted((prev) => !prev); }; - + useEffect(() => { - if (password.length > 8 && userDeleteAccepted) { - setDisabled(false); - } else { - setDisabled(true); - } + if (password.length > 8 && userDeleteAccepted) { + setDisabled(false); + } else { + setDisabled(true); + } }, [password, userDeleteAccepted]); - + const handleDeleteAccount = () => { - const baseURL = globalUrl; - - const url = `${baseURL}/api/v1/orgs/${selectedSuborg?.id}`; + const baseURL = globalUrl; - const data = { - password: password, - }; + const url = `${baseURL}/api/v1/orgs/${selectedSuborg?.id}`; - fetch(url, { - mode: "cors", - method: "DELETE", - body: JSON.stringify(data), - credentials: "include", - crossDomain: true, - withCredentials: true, - headers: { - "Content-Type": "application/json", - }, - }) - .then((response) => response.json()) - .then((data) => { - if (data.success) { - toast.success( - "Suborg deleted" - ); - handleGetSubOrgs(userdata.active_org.id); - setAccountDeleteButtonClicked(false); + const data = { + password: password, + }; - } else { - if (data.reason) { - toast.error(data.reason); - }else { - toast.error("Failed to delete suborg. Please try again or contact support@shuffler.io for help."); - } - } + fetch(url, { + mode: "cors", + method: "DELETE", + body: JSON.stringify(data), + credentials: "include", + crossDomain: true, + withCredentials: true, + headers: { + "Content-Type": "application/json", + }, }) - .catch((error) => { - console.error( - "There was a problem with your fetch operation:", - error - ); - }); + .then((response) => response.json()) + .then((data) => { + if (data.success) { + toast.success( + "Suborg deleted" + ); + setCursorStack([]); + setCurrentCursor(""); + setNextCursor(""); + handleGetSubOrgs(userdata.active_org.id); + setAccountDeleteButtonClicked(false); + + } else { + if (data.reason) { + toast.error(data.reason); + } else { + toast.error("Failed to delete suborg. Please try again or contact support@shuffler.io for help."); + } + } + }) + .catch((error) => { + console.error( + "There was a problem with your fetch operation:", + error + ); + }); }; - + return ( - -
- { - setAccountDeleteButtonClicked(false); - setSelectedSuborg(null); - }} - > - - -

Sub-Organization

- {/*
*/} -
- -
    -
  • - -
  • -
  • - -
  • -
-
- - -
-
- - +
+ { + setAccountDeleteButtonClicked(false); + setSelectedSuborg(null); + }} + > + + +

Sub-Organization

+ {/*
*/} +
+ +
    +
  • + +
  • +
  • + +
  • +
+
- {showPassword ? : } - - ), - }} - /> -
- -
-
- + + +
+
+ + + {showPassword ? : } + + ), + }} + /> +
+ +
+
+ ); - }; + }; const modalView = ( { backgroundColor: theme?.palette?.DialogStyle?.backgroundColor, zIndex: 1000, '& .MuiDialogContent-root': { - backgroundColor: theme?.palette?.DialogStyle?.backgroundColor, + backgroundColor: theme?.palette?.DialogStyle?.backgroundColor, }, '& .MuiDialogTitle-root': { - backgroundColor: theme?.palette?.DialogStyle?.backgroundColor, + backgroundColor: theme?.palette?.DialogStyle?.backgroundColor, }, '& .MuiDialogActions-root': { - backgroundColor: theme?.palette?.DialogStyle?.backgroundColor, + backgroundColor: theme?.palette?.DialogStyle?.backgroundColor, }, }, }} @@ -880,7 +941,7 @@ const TenantsTab = memo((props) => { - - - -
- - Your Parent Organization - -
-
- - - {/* */} - - -
- - - - - {isCloud && ( - - )} - - - - - {loadOrgs ? ( - [...Array(3)].map((_, rowIndex) => ( - - {[ - { width: 100, minWidth: 100, maxWidth: 100 }, - { width: 250, minWidth: 50, maxWidth: 250 }, - { width: 400, minWidth: 400, maxWidth: 400 }, - { width: "28%", minWidth: "28%" }, - { width: 400, minWidth: 400, maxWidth: 400 }, - ].map((style, colIndex) => ( - - - - ))} - - )) - ) : parentOrg?.id?.length > 0 ? ( - - - } - style={{ - width: 100, - minWidth: 100, - maxWidth: 100, - display: "table-cell", - padding: "8px 8px 8px 20px", - textAlign: "center", - }} - /> - - {isCloud && ( - - {parentOrgFlag} - -
- } - style={{ display: "table-cell", padding: 8, verticalAlign: "middle" }} - /> - )} - - - - - - - } - style={{ display: "table-cell", verticalAlign: "middle" }} - /> - - ): ( - - {Array(5).fill().map((_, index) => ( - - ))} - - )} - -
-
- - {subOrgs.length > 0 && ( -
- - -
- - Sub Organizations of the Current Organization ({subOrgs.length}) - -
+ Change Active Org + + + + {!selectedOrganization?.creator_org?.length && ( + + )} +
+ ), + }, + ]; + + return ( +
+ {modalView} + {cloudSyncModal} + {accountDeleteButtonClicked && } +
+
+
+ Tenants + + Create, manage and change to sub-organizations (tenants)! {" "} + {isCloud + ? `You can only make a sub organization if you are a customer of shuffle or running a POC of the platform. Please contact ${supportEmail} to try it out.` + : ''}  + + Learn more + + +
+ + + + + +
+ + Your Parent Organization + +
+
+ {/* { }} /> */} -
- - {!suborglistOpen ? - - setSuborglistOpen(true)} - > - Show Sub-Organizations - - } - style={{ - width: 100, - minWidth: 100, - maxWidth: 100, - paddingLeft: 20, - display: "table-cell", - padding: "0px 8px 8px 8px", - textAlign: "center", - borderBottom: theme.palette.defaultBorder, - verticalAlign: "middle", - }} - /> - - : - - - - - {isCloud && ( - - )} - - - - {subOrgs.map((data, index) => { - let regiontag = "UK"; - let regionCode = "gb"; +
+ + + + + {isCloud && ( + + )} + + + - if (data.region_url?.length > 0) { - const regionsplit = data.region_url.split("."); - if (regionsplit.length > 2 && !regionsplit[0].includes("shuffler")) { - const namesplit = regionsplit[0].split("/"); - regiontag = namesplit[namesplit.length - 1]; - if (regiontag === "california") { - regiontag = "US"; - regionCode = "us"; - } else if (regiontag === "frankfurt") { - regiontag = "EU-2"; - regionCode = "eu"; - } else if (regiontag === "ca") { - regiontag = "CA"; - regionCode = "ca"; - } - } - } - var bgColor = themeMode === "dark" ? "#212121" : "#FFFFFF"; - if (index % 2 === 0) { - bgColor = themeMode === "dark" ? "#1A1A1A" : "#EAEAEA"; - } - - return ( - - } style={{ width: 100, - minWidth: 100, - maxWidth: 100, - display: "table-cell", - padding: "8px 8px 8px 20px", - textAlign: "center", }} /> - - - {isCloud && ( - - {regiontag} - -
- } - style={{ display: "table-cell", padding: 8, verticalAlign: "middle" }} - /> - )} - - - - - - - - {selectedOrganization?.creator_org?.length > 0 ? null : -
+ } + style={{ display: "table-cell", padding: 8, verticalAlign: "middle" }} + /> + )} + - Delete Org - } - - - } - style={{ display: "table-cell", verticalAlign: "middle" }} - /> - - )})} - - } - - + /> + + + + + + } + style={{ display: "table-cell", verticalAlign: "middle" }} + /> + + ) : ( + + {Array(5).fill().map((_, index) => ( + + ))} + + )} +
- )} - + {(subOrgs.length > 0 || cursorStack.length > 0 || loadingSubOrgs) && ( +
+ -
- + + Sub Organizations of the Current Organization ({subOrgs.length}) + +
+ +
+ {!suborglistOpen ? ( + + ) : ( + row.id} + sx={{ + border: "none", + color: theme.palette.text.primary, + '& .MuiDataGrid-columnHeaders': { + borderBottom: theme.palette.defaultBorder, + backgroundColor: theme.palette.platformColor, + }, + '& .MuiDataGrid-cell': { + borderBottom: theme.palette.defaultBorder, + display: "flex", + alignItems: "center", + }, + '& .MuiDataGrid-row': { + backgroundColor: theme.palette.platformColor, + }, + '& .MuiDataGrid-overlayWrapper': { + minHeight: subOrgs.length > 0 ? 0 : 100, + }, + }} + /> + )} +
+ {suborglistOpen && ( +
+ + Rows per page: + + + + + + = Math.ceil(subOrgs.length / rowsPerPage) - 1 && (!nextCursor || nextCursor === currentCursor))} + size="small" + sx={{ color: (loadingSubOrgs || (localPage >= Math.ceil(subOrgs.length / rowsPerPage) - 1 && (!nextCursor || nextCursor === currentCursor))) ? theme.palette.text.disabled : theme.palette.text.primary }} + > + + +
+ )} +
+ )} + + - All Tenants - -
+ /> - {/* + + All Tenants + +
+ + {/* */} -
- - {!allTenantsOpen ? - + + {!allTenantsOpen ? + - setAllTenantsOpen(true)} - > - Show ALL your tenants - - } - style={{ - width: 100, - minWidth: 100, - maxWidth: 100, - paddingLeft: 20, - display: "table-cell", - padding: "0px 8px 8px 8px", - textAlign: "center", - borderBottom: theme.palette.defaultBorder, - verticalAlign: "middle", - }} - /> - - : - - - - - {isCloud && ( - - )} - - - + }} + > + setAllTenantsOpen(true)} + > + Show ALL your tenants + + } + style={{ + width: 100, + minWidth: 100, + maxWidth: 100, + paddingLeft: 20, + display: "table-cell", + padding: "0px 8px 8px 8px", + textAlign: "center", + borderBottom: theme.palette.defaultBorder, + verticalAlign: "middle", + }} + /> + + : + + + + + {isCloud && ( + + )} + + + - {userdata?.orgs?.length <= 0 ? ( - [...Array(6)].map((_, rowIndex) => ( - - {Array(7) - .fill() - .map((_, colIndex) => ( - - - - ))} - - )) - ) : ( - userdata?.orgs?.length > 0 && - userdata.orgs.map((data, index) => { - let regiontag = "UK"; - let regionCode = "gb"; + {userdata?.orgs?.length <= 0 ? ( + [...Array(6)].map((_, rowIndex) => ( + + {Array(7) + .fill() + .map((_, colIndex) => ( + + + + ))} + + )) + ) : ( + userdata?.orgs?.length > 0 && + userdata.orgs.map((data, index) => { + let regiontag = "UK"; + let regionCode = "gb"; - if (data.region_url?.length > 0) { - const regionsplit = data.region_url.split("."); - if (regionsplit.length > 2 && !regionsplit[0].includes("shuffler")) { - const namesplit = regionsplit[0].split("/"); - regiontag = namesplit[namesplit.length - 1]; + if (data.region_url?.length > 0) { + const regionsplit = data.region_url.split("."); + if (regionsplit.length > 2 && !regionsplit[0].includes("shuffler")) { + const namesplit = regionsplit[0].split("/"); + regiontag = namesplit[namesplit.length - 1]; - if (regiontag === "california") { - regiontag = "US"; - regionCode = "us"; - } else if (regiontag === "frankfurt") { - regiontag = "EU-2"; - regionCode = "eu"; - } else if (regiontag === "ca") { - regiontag = "CA"; - regionCode = "ca"; - } - } - } + if (regiontag === "california") { + regiontag = "US"; + regionCode = "us"; + } else if (regiontag === "frankfurt") { + regiontag = "EU-2"; + regionCode = "eu"; + } else if (regiontag === "ca") { + regiontag = "CA"; + regionCode = "ca"; + } + } + } - var bgColor = themeMode === "dark" ? "#212121" : "#FFFFFF"; - if (index % 2 === 0) { - bgColor = themeMode === "dark" ? "#1A1A1A" : "#EAEAEA"; - } + var bgColor = themeMode === "dark" ? "#212121" : "#FFFFFF"; + if (index % 2 === 0) { + bgColor = themeMode === "dark" ? "#1A1A1A" : "#EAEAEA"; + } - return ( - - - } - style={{ - width: 100, - minWidth: 100, - maxWidth: 100, - display: "table-cell", - padding: "8px 8px 8px 20px", - textAlign: "center", - }} - /> - - {isCloud ? ( - - {regiontag} + return ( + + + } + style={{ + width: 100, + minWidth: 100, + maxWidth: 100, + display: "table-cell", + padding: "8px 8px 8px 20px", + textAlign: "center", + }} + /> + + {isCloud ? ( + + {regiontag} - -
- } - style={{ - display: "table-cell", - padding: 8, - verticalAlign: "middle", - }} - > - ) : null} - - { - handleClickChangeOrg(data?.id); - }} - > - Change Active Org - - } - style={{ - display: "table-cell", - padding: 8, - verticalAlign: "middle", - }} - > - - ); - }) - )} - } - -
+ +
+ } + style={{ + display: "table-cell", + padding: 8, + verticalAlign: "middle", + }} + > + ) : null} + + { + handleClickChangeOrg(data?.id); + }} + > + Change Active Org + + } + style={{ + display: "table-cell", + padding: 8, + verticalAlign: "middle", + }} + > + + ); + }) + )} + } + +
diff --git a/frontend/src/components/UserManagmentTab.jsx b/frontend/src/components/UserManagmentTab.jsx index 41ab96f0..a27fdedf 100644 --- a/frontend/src/components/UserManagmentTab.jsx +++ b/frontend/src/components/UserManagmentTab.jsx @@ -1,11 +1,10 @@ import React, { useState, useEffect, useContext, memo } from "react"; import { toast } from 'react-toastify'; import { Context } from "../context/ContextApi.jsx"; +import { Link } from "react-router-dom"; import { FormControl, InputLabel, - OutlinedInput, - Checkbox, Tooltip, Typography, Select, @@ -31,23 +30,12 @@ import { import { Cached as CachedIcon, Edit as EditIcon, - Style, } from "@mui/icons-material"; import ModeEditOutlineOutlinedIcon from '@mui/icons-material/ModeEditOutlineOutlined'; import ContentCopyOutlinedIcon from '@mui/icons-material/ContentCopyOutlined'; import {getTheme} from "../theme.jsx"; -const ITEM_HEIGHT = 48; -const ITEM_PADDING_TOP = 8; -const MenuProps = { - PaperProps: { - style: { - maxHeight: ITEM_HEIGHT * 4.5 + ITEM_PADDING_TOP, - width: 500, - }, - }, - getContentAnchorEl: () => null, -}; +import SubOrgDistributionDialog from "./SubOrgDistributionDialog.jsx"; const logsViewModal = false; const userdata = ""; @@ -76,10 +64,11 @@ const UserManagmentTab = memo((props) => { const [logsViewModal, setLogsViewModal] = React.useState(false); const [ipSelected, setIpSelected] = React.useState(""); const [userLogViewing, setUserLogViewing] = React.useState({}); + const [subOrgModalOpen, setSubOrgModalOpen] = React.useState(false); + const [pendingSubOrgs, setPendingSubOrgs] = React.useState([]); const { themeMode, supportEmail, brandColor } = useContext(Context); const theme = getTheme(themeMode, brandColor); - useEffect(() => { if (selectedOrganization?.mfa_required !== MFARequired) { setMFARequired(selectedOrganization?.mfa_required); @@ -247,30 +236,6 @@ const UserManagmentTab = memo((props) => { }); }; - const handleOrgEditChange = (event) => { - if (userdata.id === selectedUser.id) { - toast("Can't remove orgs from yourself"); - return; - } - - if (event.target.value.includes("ALL")) { - toast.info("Adding to available all sub-organizations. This may take a minute.") - event.target.value = selectedOrganization.child_orgs.map((org) => org.id) - } else if (event.target.value.includes("None")) { - toast.info("Removing from all sub-organizations. This may take a minute") - event.target.value = [] - } - - setMatchingOrganizations(event.target.value); - // Workaround for empty orgs - if (event.target.value.length === 0) { - event.target.value.push("REMOVE"); - } - - setUser(selectedUser.id, "suborgs", event.target.value); - //setUser(selectedUser.id, "suborgs", matchingOrganizations) - }; - const userOrgEdit = selectedUser.id !== undefined && selectedUser?.orgs !== undefined && @@ -278,44 +243,44 @@ const UserManagmentTab = memo((props) => { selectedOrganization?.child_orgs !== undefined && selectedOrganization?.child_orgs !== null && selectedOrganization?.child_orgs?.length > 0 ? ( - - - Accessible Sub-Organizations ( - {selectedUser?.orgs ? selectedUser?.orgs?.length - 1 : 0}) - - - + ) : null; + const subOrgManagementDialog = ( + setSubOrgModalOpen(false)} + title={`Manage Sub-Organizations for ${selectedUser?.username || ''}`} + orgs={selectedOrganization?.child_orgs || []} + selectedOrgIds={pendingSubOrgs} + onSelectionChange={setPendingSubOrgs} + onSave={(ids) => { + if (userdata.id === selectedUser.id) { + toast("Can't modify orgs for yourself"); + return; + } + const newValue = ids.length === 0 ? ["REMOVE"] : [...ids]; + setMatchingOrganizations([...ids]); + setUser(selectedUser.id, "suborgs", newValue); + setSubOrgModalOpen(false); + setSelectedUserModalOpen(false); + }} + disabled={selectedUser?.id === userdata?.id} + /> + ); + const getUsers = () => { fetch(globalUrl + "/api/v1/getusers", { method: "GET", @@ -1117,6 +1082,8 @@ const UserManagmentTab = memo((props) => { }); }; + var previousreferrer = "" + var nextreferrer = "" const logview = logsViewModal ? ( { onChange={(event) => { setIpSelected(event.target.value); getLogs(event.target.value, userLogViewing.id); - - }} > {(() => { const uniqueIPs = new Set(); + console.log("Login info: ", userLogViewing.login_info) return userLogViewing.login_info.map((data, index) => { - if ( - data.ip.includes("127.0.0.1") || - uniqueIPs.has(data.ip) - ) { - return null; + console.log("Data: ", data) + if (data.ip.includes("127.0.0.1") || uniqueIPs.has(data.ip)) { + return null } - uniqueIPs.add(data.ip); + uniqueIPs.add(data.ip) return ( - {data.ip} + {data?.timestamp ? new Date(data.timestamp * 1000).toLocaleString() : "N/A"} - {data?.ip} ); }); @@ -1229,12 +1193,13 @@ const UserManagmentTab = memo((props) => { minWidth: 700, maxWidth: 700, overflow: "hidden", - marginLeft: 10, + marginLeft: 50, }} /> {logs.map((data, index) => { - //console.log("LOG: ", data) + previousreferrer = nextreferrer + nextreferrer = data.referer return ( // redirect user to logs @@ -1243,6 +1208,8 @@ const UserManagmentTab = memo((props) => { key={index} style={{ backgroundColor: index % 2 === 0 ? "#1f2023" : "#27292d", + paddingTop: data.referer !== previousreferrer ? 50 : 0, + borderTop: data.referer !== previousreferrer ? `1px solid rgba(255,255,255,0.3)` : "none", }} > { }} /> - + + + )})} @@ -1290,6 +1260,7 @@ const UserManagmentTab = memo((props) => {
{modalView} {editUserModal} + {subOrgManagementDialog} {logview}
diff --git a/frontend/src/components/WorkflowGrid.jsx b/frontend/src/components/WorkflowGrid.jsx index e4a34134..9e843206 100644 --- a/frontend/src/components/WorkflowGrid.jsx +++ b/frontend/src/components/WorkflowGrid.jsx @@ -3,6 +3,7 @@ import React, { useEffect, useState } from 'react'; import {Link} from 'react-router-dom'; import theme from '../theme.jsx'; import { removeQuery } from '../components/ScrollToTop.jsx'; +import SearchContactForm from '../components/SearchContactForm.jsx'; import { Search as SearchIcon, CloudQueue as CloudQueueIcon, Code as CodeIcon } from '@mui/icons-material'; @@ -25,66 +26,23 @@ import { useDebouncedCallback } from "../utils/useDebouncedCallback.jsx"; import WorkflowPaper from "../components/WorkflowPaper.jsx" import WorkflowPaperNew from "../components/WorkflowPaperNew.jsx" -const searchClient = algoliasearch("JNSS5CFDZZ", "c8f882473ff42d41158430be09ec2b4e") +const searchClient = algoliasearch("JNSS5CFDZZ", "eb5fd80aa6ed5ab4730d836cff3ea283") const AppGrid = props => { const { maxRows, showName, showSuggestion, isMobile, globalUrl, parsedXs, alternativeView, onlyResults, inputsearch } = props - const isCloud = window.location.host === "localhost:3002" || window.location.host === "shuffler.io"; + const isCloud = window.location.host === "localhost:3002" || window.location.host === "shuffler.io"; const rowHandler = maxRows === undefined || maxRows === null ? 50 : maxRows const xs = parsedXs === undefined || parsedXs === null ? isMobile ? 6 : 4 : parsedXs //const [apps, setApps] = React.useState([]); //const [filteredApps, setFilteredApps] = React.useState([]); - const [formMail, setFormMail] = React.useState(""); - const [message, setMessage] = React.useState(""); - const [formMessage, setFormMessage] = React.useState(""); const [usecases, setUsecases] = React.useState([]); const [localMessage, setLocalMessage] = React.useState(""); - const buttonStyle = {borderRadius: 30, height: 50, width: 220, margin: isMobile ? "15px auto 15px auto" : 20, fontSize: 18,} - const innerColor = "rgba(255,255,255,0.65)" const borderRadius = 3 window.title = "Shuffle | Workflows | Discover your use-case" - const submitContact = (email, message) => { - const data = { - "firstname": "", - "lastname": "", - "title": "", - "companyname": "", - "email": email, - "phone": "", - "message": message, - } - - const errorMessage = "Something went wrong. Please contact frikky@shuffler.io directly." - - fetch(globalUrl+"/api/v1/contact", { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(data), - }) - .then(response => response.json()) - .then(response => { - if (response.success === true) { - setFormMessage(response.reason) - //toast("Thanks for submitting!") - } else { - setFormMessage(errorMessage) - } - - setFormMail("") - setMessage("") - }) - .catch(error => { - setFormMessage(errorMessage) - console.log(error) - }); - } - const handleKeysetting = (categorydata, workflows) => { console.log("Workflows: ", workflows) //workflows[0].category = ["detect"] @@ -229,10 +187,16 @@ const AppGrid = props => { placeholder="Find Workflows..." id="shuffle_search_field" onChange={(event) => { - removeQuery("q") const value = event.currentTarget.value setInputValue(value) debouncedRefine(value) + const urlSearchParams = new URLSearchParams(window.location.search) + if (value) { + urlSearchParams.set("q", value) + } else { + urlSearchParams.delete("q") + } + window.history.replaceState(null, "", value ? `?${urlSearchParams.toString()}` : window.location.pathname) }} onKeyDown={(event) => { if(event.key === "Enter") { @@ -333,64 +297,10 @@ const AppGrid = props => { {showSuggestion === true ? -
- - Can't find what you're looking for? - -
- setFormMail(e.target.value)} - /> - setMessage(e.target.value)} - /> -
- - {formMessage} -
- : null + + : null } - {onlyResults === true ? null : + {/* {onlyResults === true ? null : Search by @@ -399,7 +309,7 @@ const AppGrid = props => { Algolia logo - } + } */}
) } diff --git a/frontend/src/components/Workflowsearch.jsx b/frontend/src/components/Workflowsearch.jsx index 1b342645..44f77b98 100644 --- a/frontend/src/components/Workflowsearch.jsx +++ b/frontend/src/components/Workflowsearch.jsx @@ -10,7 +10,7 @@ import algoliasearch from 'algoliasearch'; import { InstantSearch, connectSearchBox, connectHits } from 'react-instantsearch-dom'; import { Grid, Paper, TextField, ButtonBase, InputAdornment, Typography, Button, Tooltip} from '@mui/material'; -const searchClient = algoliasearch("JNSS5CFDZZ", "c8f882473ff42d41158430be09ec2b4e") +const searchClient = algoliasearch("JNSS5CFDZZ", "33e4e3564f4f060e96e0531957bed552") const WorkflowSearch = props => { const { maxRows, showName, showSuggestion, isMobile, globalUrl, parsedXs, newSelectedApp, setNewSelectedApp, defaultSearch, showSearch, ConfiguredHits, selectAble, } = props const rowHandler = maxRows === undefined || maxRows === null ? 50 : maxRows diff --git a/frontend/src/components/ssoTab.jsx b/frontend/src/components/ssoTab.jsx index c361e1b1..1d019800 100644 --- a/frontend/src/components/ssoTab.jsx +++ b/frontend/src/components/ssoTab.jsx @@ -1,16 +1,16 @@ import { useEffect, useContext } from "react"; import React from "react"; -import { - Typography, - Switch, - Button, - Tooltip, - TextField, - Grid, +import { + Typography, + Switch, + Button, + Tooltip, + TextField, + Grid, Checkbox } from "@mui/material"; import { makeStyles } from "@mui/styles"; -import { Link } from "react-router-dom"; +import { Link, useSearchParams } from "react-router-dom"; import theme from "../theme.jsx"; import { toast } from "react-toastify"; import { Context } from "../context/ContextApi.jsx"; @@ -28,7 +28,13 @@ const SSOTab = ({selectedOrganization, userdata, isEditOrgTab, globalUrl, handle // Check if user is admin const isAdmin = userdata?.active_org?.role === "admin" || userdata?.support === true; - + + // Read region_url override from URL params (only allow shuffler.io domains) + const [searchParams] = useSearchParams(); + const rawRegionUrl = searchParams.get("region_url"); + const regionUrlOverride = rawRegionUrl && rawRegionUrl.includes("shuffler.io") ? rawRegionUrl : null; + const effectiveGlobalUrl = regionUrlOverride || globalUrl; + // State for tracking user SSO connection status const [users, setUsers] = React.useState([]); const [userSSOConnected, setUserSSOConnected] = React.useState(false); @@ -109,7 +115,7 @@ const SSOTab = ({selectedOrganization, userdata, isEditOrgTab, globalUrl, handle // Function to fetch users and check current user's SSO status const checkUserSSOStatus = () => { setCheckingSSOStatus(true); - fetch(globalUrl + "/api/v1/getusers", { + fetch(effectiveGlobalUrl + "/api/v1/getusers", { method: "GET", headers: { "Content-Type": "application/json", @@ -309,7 +315,7 @@ const SSOTab = ({selectedOrganization, userdata, isEditOrgTab, globalUrl, handle }; const HandleTestSSO = () => { - const url = `${globalUrl}/api/v1/orgs/${selectedOrganization?.id}/change`; + const url = `${effectiveGlobalUrl}/api/v1/orgs/${selectedOrganization?.id}/change`; const data = { org_id: selectedOrganization?.id, sso: true, @@ -366,7 +372,7 @@ const SSOTab = ({selectedOrganization, userdata, isEditOrgTab, globalUrl, handle }; const HandleDisconnectSSO = () => { - const url = `${globalUrl}/api/v1/disconnect_sso`; + const url = `${effectiveGlobalUrl}/api/v1/disconnect_sso`; const data = { org_id: selectedOrganization?.id, }; @@ -444,6 +450,11 @@ const SSOTab = ({selectedOrganization, userdata, isEditOrgTab, globalUrl, handle : "Connect your account with this org's SSO!" } + {regionUrlOverride && ( + + Using region override: {regionUrlOverride} + + )} { - // Handle "system" mode by checking user's system preference - let resolvedMode = themeMode; - if (themeMode === "system" || !themeMode) { - resolvedMode = window?.matchMedia?.("(prefers-color-scheme: dark)")?.matches ? "dark" : "light"; - } - // Ensure mode is only "dark" or "light" - if (resolvedMode !== "dark" && resolvedMode !== "light") { - resolvedMode = "dark"; - } - - return createTheme({ +export const getTheme = (themeMode, brandColor) => + createTheme({ palette: { - mode: resolvedMode, + mode: themeMode, main: brandColor || "#FF8544", primary: { main: brandColor || "#FF8544", @@ -177,35 +167,36 @@ export const getTheme = (themeMode, brandColor) => { contrastText:"#000000", }, text: { - primary: resolvedMode === "dark" ? "#ffffff" : "#1A1A1A", - secondary: resolvedMode === "dark" ? "#9E9E9E" : "#616161", + primary: themeMode === "dark" ? "#ffffff" : "#1A1A1A", + secondary: themeMode === "dark" ? "#9E9E9E" : "#616161", }, - type: resolvedMode, - inputColor: resolvedMode === "dark" ? "rgba(39,41,45,1)" : "rgba(245, 245, 245, 1)", - textColor: resolvedMode === "dark" ? "#F1F1F1" : "#1A1A1A", - textPrimary: resolvedMode === "dark" ? "rgba(255, 255, 255, 0.8)" : "rgba(26, 26, 26, 0.8)", - surfaceColor: resolvedMode === "dark" ? "#27292d" : "#EFEFEF", - platformColor: resolvedMode === "dark" ? "#212121" : "#ffffff", - backgroundColor: resolvedMode === "dark" ? "#1a1a1a" : "#f1f1f1", - cytoscapeBackgroundColor: resolvedMode === "dark" ? "#161616" : "#f5f5f5", - distributionColor: resolvedMode === "dark" ? "#40E0D0" : "#008080", - cardBackgroundColor: resolvedMode === "dark" ? "#1e1e1e" : "#eaeaea", - cardHoverColor: resolvedMode === "dark" ? "#323232" : "#F0F0F0", - hoverColor: resolvedMode === "dark" ? "#323232" : "#D6D6D6", - usecaseCardColor: resolvedMode === "dark" ? "#2f2f2f" : "rgba(245, 245, 245, 1)", - usecaseCardHoverColor: resolvedMode === "dark" ? "#2F2F2F" : "rgba(245, 245, 245, 1)", - usecaseDialogFieldColor: resolvedMode === "dark" ? "#2B2B2B" : "#F5F5F5", - accentColor: resolvedMode === "dark" ? "#ff8544" : "#ff8544", - green: resolvedMode === "dark" ? "#5cc879" : "#008000", - defaultBorder: resolvedMode === "dark" ? '1px solid #494949' : '1px solid #CCCCCC', + type: themeMode, + inputColor: themeMode === "dark" ? "rgba(39,41,45,1)" : "rgba(245, 245, 245, 1)", + textColor: themeMode === "dark" ? "#F1F1F1" : "#1A1A1A", + textPrimary: themeMode === "dark" ? "rgba(255, 255, 255, 0.8)" : "rgba(26, 26, 26, 0.8)", + surfaceColor: themeMode === "dark" ? "#27292d" : "#EFEFEF", + platformColor: themeMode === "dark" ? "#212121" : "#ffffff", + backgroundColor: themeMode === "dark" ? "#1a1a1a" : "#f1f1f1", + cytoscapeBackgroundColor: themeMode === "dark" ? "#161616" : "#f5f5f5", + distributionColor: themeMode === "dark" ? "#40E0D0" : "#008080", + cardBackgroundColor: themeMode === "dark" ? "#1e1e1e" : "#eaeaea", + cardHoverColor: themeMode === "dark" ? "#323232" : "#F0F0F0", + hoverColor: themeMode === "dark" ? "#323232" : "#D6D6D6", + usecaseCardColor: themeMode === "dark" ? "#2f2f2f" : "rgba(245, 245, 245, 1)", + usecaseCardHoverColor: themeMode === "dark" ? "#2F2F2F" : "rgba(245, 245, 245, 1)", + usecaseDialogFieldColor: themeMode === "dark" ? "#2B2B2B" : "#F5F5F5", + accentColor: themeMode === "dark" ? "#ff8544" : "#ff8544", + green: themeMode === "dark" ? "#5cc879" : "#008000", + defaultBorder: themeMode === "dark" ? '1px solid #494949' : '1px solid #CCCCCC', linkColor: brandColor === "#ff8544" ? "#f86a3e" : brandColor, - slateGrayColor: resolvedMode === "dark" ? "#494949" : "#CCCCCC", - parsedAppPaperColor: resolvedMode === "dark" ? "#2f2f2f" : "#CCCCCC", - + slateGrayColor: themeMode === "dark" ? "#494949" : "#CCCCCC", + parsedAppPaperColor: themeMode === "dark" ? "#2f2f2f" : "#CCCCCC", + welcomeCardSubtextColor: themeMode === "dark" ? "#C8C8C8" : "#2f2f2f", + deleteColor: themeMode === "dark" ? "#FD4C62" : "#d32f2f", borderRadius: 10, - loaderColor: resolvedMode === "dark" ? "#1a1a1a" : "#E0E0E0", + loaderColor: themeMode === "dark" ? "#1a1a1a" : "#E0E0E0", jsonIconStyle: "round", - jsonTheme: resolvedMode === "dark" ? "summerfruit" : { + jsonTheme: themeMode === "dark" ? "summerfruit" : { base00: "#ffffff", // background base01: "#f0f0f0", // very light grey base02: "#f5f5f5", // light grey @@ -225,11 +216,11 @@ export const getTheme = (themeMode, brandColor) => { }, jsonCollapseStringsAfterLength: 100, drawer: { - backgroundColor: resolvedMode === "dark" ? "#262626" : "#f9f9f9" + backgroundColor: themeMode === "dark" ? "#262626" : "#f9f9f9" }, actionSidebarField: { - backgroundColor: resolvedMode === "dark" ? "#2F2F2F" : "#F1F1F1", - color: resolvedMode === "dark" ? "#ffffff" : "#000000", + backgroundColor: themeMode === "dark" ? "#2F2F2F" : "#F1F1F1", + color: themeMode === "dark" ? "#ffffff" : "#000000", borderRadius: 8, height: 40, border: "none", @@ -238,53 +229,53 @@ export const getTheme = (themeMode, brandColor) => { padding: 5, width: "98%", borderRadius: 5, - border: resolvedMode === "dark" ? "1px solid rgba(255,255,255,0.7)" : "1px solid rgba(0,0,0,0.3)", - backgroundColor: resolvedMode === "dark" + border: themeMode === "dark" ? "1px solid rgba(255,255,255,0.7)" : "1px solid rgba(0,0,0,0.3)", + backgroundColor: themeMode === "dark" ? "#1A1A1A" : "#f1f1f1", - color: resolvedMode === "dark" + color: themeMode === "dark" ? "#F1F1F1" : "#1A1A1A", overflowX: "auto", }, textFieldStyle: { - backgroundColor: resolvedMode === "dark" ? "#212121" : "#FFFFFF", - color: resolvedMode === "dark" ? "#ffffff" : "#000000", + backgroundColor: themeMode === "dark" ? "#212121" : "#FFFFFF", + color: themeMode === "dark" ? "#ffffff" : "#000000", borderRadius: "5px", height: 40, - border: resolvedMode === "dark" ? "1px solid #4D4D4D" : "1px solid #E0E0E0", + border: themeMode === "dark" ? "1px solid #4D4D4D" : "1px solid #E0E0E0", }, DialogStyle: { - backgroundColor: resolvedMode === "dark" ? "#212121" : "#ffffff", + backgroundColor: themeMode === "dark" ? "#212121" : "#ffffff", borderRadius: 2, - boxShadow: resolvedMode === "dark" ? "0px 0px 10px 0px rgba(0,0,0,0.75)" : "0px 0px 10px 0px rgba(0,0,0,0.2)", - border: resolvedMode === "dark" ? "1px solid #494949" : "1px solid #cccccc", + boxShadow: themeMode === "dark" ? "0px 0px 10px 0px rgba(0,0,0,0.75)" : "0px 0px 10px 0px rgba(0,0,0,0.2)", + border: themeMode === "dark" ? "1px solid #494949" : "1px solid #cccccc", }, innerTextfieldStyle: { height: 40, fontSize: 16, - backgroundColor: resolvedMode === "dark" ? "#212121" : "#f5f5f5", + backgroundColor: themeMode === "dark" ? "#212121" : "#f5f5f5", }, tooltip: { - backgroundColor: resolvedMode === "dark" ? "#212121" : "#ffffff", - color: resolvedMode === "dark" ? "#ffffff" : "#000000", - border: resolvedMode === "dark" ? "1px solid #494949" : "1px solid #cccccc", + backgroundColor: themeMode === "dark" ? "#212121" : "#ffffff", + color: themeMode === "dark" ? "#ffffff" : "#000000", + border: themeMode === "dark" ? "1px solid #494949" : "1px solid #cccccc", }, chipStyle: { - backgroundColor: resolvedMode === "dark" ? "#333333" : "#F5F5F5", - borderColor: resolvedMode === "dark" ? "#444444" : "#E0E0E0", - color: resolvedMode === "dark" ? "#FFFFFF" : "#333333", + backgroundColor: themeMode === "dark" ? "#333333" : "#F5F5F5", + borderColor: themeMode === "dark" ? "#444444" : "#E0E0E0", + color: themeMode === "dark" ? "#FFFFFF" : "#333333", }, defaultImage: "/images/no_image.png", singulOrange: "/images/singul_orange.png", singulGreen: "/images/singul_green.png", singulBlackWhite: "/icons/workflow-page/shuffle_agent.png", - scrollbarColor: resolvedMode === "dark" ? "#494949 #2f2f2f": "#c1c1c1 #f1f1f1", - scrollbarColorTransparent: resolvedMode === "dark" ? '#494949 transparent': "#c1c1c1 transparent", + scrollbarColor: themeMode === "dark" ? "#494949 #2f2f2f": "#c1c1c1 #f1f1f1", + scrollbarColorTransparent: themeMode === "dark" ? '#494949 transparent': "#c1c1c1 transparent", }, typography: { fontFamily: `"inter", "Roboto", "Helvetica", "Arial", sans-serif`, - color: resolvedMode === "dark" ? "#ffffff" : "#000000", + color: themeMode === "dark" ? "#ffffff" : "#000000", useNextVariants: true, fontWeightLight: 300, fontWeightRegular: 400, @@ -292,36 +283,36 @@ export const getTheme = (themeMode, brandColor) => { fontWeightSemiBold: 600, fontWeightBold: 700, allVariants: { - color: resolvedMode === "dark" ? "#ffffff" : "#1A1A1A", + color: themeMode === "dark" ? "#ffffff" : "#1A1A1A", }, h1: { fontSize: 40, - color: resolvedMode === "dark" ? "#ffffff" : "#1A1A1A" + color: themeMode === "dark" ? "#ffffff" : "#1A1A1A" }, h2: { fontSize: 36, - color: resolvedMode === "dark" ? "#ffffff" : "#1A1A1A" + color: themeMode === "dark" ? "#ffffff" : "#1A1A1A" }, h3: { fontSize: 32, - color: resolvedMode === "dark" ? "#ffffff" : "#1A1A1A" + color: themeMode === "dark" ? "#ffffff" : "#1A1A1A" }, h4: { fontSize: 30, fontWeight: 500, - color: resolvedMode === "dark" ? "#ffffff" : "#1A1A1A" + color: themeMode === "dark" ? "#ffffff" : "#1A1A1A" }, h6: { fontSize: 22, - color: resolvedMode === "dark" ? "#ffffff" : "#1A1A1A" + color: themeMode === "dark" ? "#ffffff" : "#1A1A1A" }, body1: { fontSize: 16, - color: resolvedMode === "dark" ? "#ffffff" : "#1A1A1A" + color: themeMode === "dark" ? "#ffffff" : "#1A1A1A" }, body2: { fontSize: 14, - color: resolvedMode === "dark" ? "#ffffff" : "#1A1A1A" + color: themeMode === "dark" ? "#ffffff" : "#1A1A1A" }, }, components: { @@ -336,7 +327,7 @@ export const getTheme = (themeMode, brandColor) => { { props: { variant: 'text', color: 'primary' }, style: { - color: resolvedMode === "dark" ? "#ffffff" : "#1A1A1A", + color: themeMode === "dark" ? "#ffffff" : "#1A1A1A", whiteSpace: "nowrap", textWrap: "normal", }, @@ -344,7 +335,7 @@ export const getTheme = (themeMode, brandColor) => { { props: { variant: 'text', color: 'secondary' }, style: { - color: resolvedMode === "dark" ? "#9E9E9E" : "#616161", + color: themeMode === "dark" ? "#9E9E9E" : "#616161", whiteSpace: "nowrap", textWrap: "normal", }, @@ -352,24 +343,24 @@ export const getTheme = (themeMode, brandColor) => { { props: { variant: 'contained', color: 'primary' }, style: { - backgroundColor: resolvedMode === "dark" ? brandColor || '#ff8544' : brandColor || '#FF7C35', - color: resolvedMode === "dark" ? '#1a1a1a': '#FFFFFF', + backgroundColor: themeMode === "dark" ? brandColor || '#ff8544' : brandColor || '#FF7C35', + color: themeMode === "dark" ? '#1a1a1a': '#FFFFFF', borderRadius: '4px', whiteSpace: "nowrap", textWrap: "normal", transition: 'background-color 0.2s ease-in-out', '&:hover': { fontWeight: 600, - backgroundColor: resolvedMode === 'dark' ? brandColor || "#ff955c" : brandColor || '#FF8D4F', - color: resolvedMode === "dark" ? '#1a1a1a': '#FFFFFF', + backgroundColor: themeMode === 'dark' ? brandColor || "#ff955c" : brandColor || '#FF8D4F', + color: themeMode === "dark" ? '#1a1a1a': '#FFFFFF', }, }, }, { props: { variant: 'contained', color: 'secondary' }, style: { - backgroundColor: resolvedMode === "dark" ? '#494949' : '#C9C9C9', - color: resolvedMode === "dark" ? '#ffffff' : '#4C4C4C', + backgroundColor: themeMode === "dark" ? '#494949' : '#C9C9C9', + color: themeMode === "dark" ? '#ffffff' : '#4C4C4C', borderRadius: '4px', boxShadow: 'none', whiteSpace: "nowrap", @@ -377,23 +368,23 @@ export const getTheme = (themeMode, brandColor) => { textWrap: "normal", '&:hover': { fontWeight: 600, - border: resolvedMode === "dark" ? '1px solid #f1f1f1' : 'none', - backgroundColor: resolvedMode === "dark" ? '#494949' : '#C9C9C9', - color: resolvedMode === "dark" ? '#ffffff' : '#4C4C4C', + border: themeMode === "dark" ? '1px solid #f1f1f1' : 'none', + backgroundColor: themeMode === "dark" ? '#494949' : '#C9C9C9', + color: themeMode === "dark" ? '#ffffff' : '#4C4C4C', }, }, }, { props: { variant: 'outlined', color: 'primary' }, style: { - borderColor: resolvedMode === "dark" ? brandColor || "#ff8544" : brandColor || "#cc5f1f", - color: resolvedMode === "dark" ? brandColor || "#ff8544" : brandColor || "#cc5f1f", + borderColor: themeMode === "dark" ? brandColor || "#ff8544" : brandColor || "#cc5f1f", + color: themeMode === "dark" ? brandColor || "#ff8544" : brandColor || "#cc5f1f", whiteSpace: "nowrap", fontWeight: 'normal', textWrap: "normal", '&:hover': { - backgroundColor: resolvedMode === "dark" ? brandColor || "#ff8544" : "#ffe8dc", - color: resolvedMode === "dark" ? "#1a1a1a" : "#8a3d00", + backgroundColor: themeMode === "dark" ? brandColor || "#ff8544" : "#ffe8dc", + color: themeMode === "dark" ? "#1a1a1a" : "#8a3d00", fontWeight: 600, }, }, @@ -402,14 +393,14 @@ export const getTheme = (themeMode, brandColor) => { props: { variant: 'outlined', color: 'secondary' }, style: { border: '1px solid #C5C5C5', - color: resolvedMode === "dark" ? '#C5C5C5' : '#2D2D2D', + color: themeMode === "dark" ? '#C5C5C5' : '#2D2D2D', whiteSpace: "nowrap", textWrap: "normal", '&:hover': { - backgroundColor: resolvedMode === "dark" ? '#C5C5C5' : '#EFEFEF', - borderColor: resolvedMode === "dark" ? '#C5C5C5' : '#2D2D2D', + backgroundColor: themeMode === "dark" ? '#C5C5C5' : '#EFEFEF', + borderColor: themeMode === "dark" ? '#C5C5C5' : '#2D2D2D', fontWeight: 600, - color: resolvedMode === "dark" ? '#1a1a1a' : '#1A1A1A', + color: themeMode === "dark" ? '#1a1a1a' : '#1A1A1A', }, }, }, @@ -432,8 +423,8 @@ export const getTheme = (themeMode, brandColor) => { background: 'linear-gradient(90deg, #e6743a 0%, #d4456e 50%, #8a4de8 100%)', }, '&:disabled': { - background: resolvedMode === "dark" ? '#494949' : '#C9C9C9', - color: resolvedMode === "dark" ? '#9E9E9E' : '#616161', + background: themeMode === "dark" ? '#494949' : '#C9C9C9', + color: themeMode === "dark" ? '#9E9E9E' : '#616161', }, }, }, @@ -478,9 +469,9 @@ export const getTheme = (themeMode, brandColor) => { }, '&:disabled': { background: 'transparent', - color: resolvedMode === "dark" ? '#9E9E9E' : '#616161', + color: themeMode === "dark" ? '#9E9E9E' : '#616161', '&::before': { - background: resolvedMode === "dark" ? '#494949' : '#C9C9C9', + background: themeMode === "dark" ? '#494949' : '#C9C9C9', }, }, }, @@ -490,7 +481,7 @@ export const getTheme = (themeMode, brandColor) => { MuiTab: { styleOverrides: { root: { - color: resolvedMode === "dark" ? "#C5C5C5" : "#1A1A1A", + color: themeMode === "dark" ? "#C5C5C5" : "#1A1A1A", }, }, }, @@ -499,7 +490,7 @@ export const getTheme = (themeMode, brandColor) => { overrides: { MuiMenu: { list: { - backgroundColor: resolvedMode === "dark" ? "#27292d" : "#ffffff", + backgroundColor: themeMode === "dark" ? "#27292d" : "#ffffff", }, }, MuiCssBaseline: { @@ -545,5 +536,4 @@ export const getTheme = (themeMode, brandColor) => { }, }, }); -} diff --git a/frontend/src/views/AgentUI.jsx b/frontend/src/views/AgentUI.jsx index 6b2fbbd9..7e3ccc3f 100644 --- a/frontend/src/views/AgentUI.jsx +++ b/frontend/src/views/AgentUI.jsx @@ -44,6 +44,7 @@ import { Add as AddIcon, Warning as WarningIcon, Pause as PauseIcon, + Chat as ChatIcon, } from '@mui/icons-material' import { @@ -71,6 +72,7 @@ const AgentUI = (props) => { const [newSelectedApp, setNewSelectedApp] = React.useState({}) const [appPickerAnchor, setAppPickerAnchor] = React.useState(null) const [chosenApps, setChosenApps] = useState([]) + const [planningEnabled, setPlanningEnabled] = useState([]) const activateApp = (appId) => { if (appId === undefined || appId === null || appId === "") { @@ -188,11 +190,11 @@ const AgentUI = (props) => { const agentWrapperStyle = { width: "100%", - maxHeight: "100vh", + minHeight: "100vh", margin: "auto", backgroundColor: theme.palette.backgroundColor, - paddingBottom: showAgentStarter ? 0 : 1500, + paddingBottom: showAgentStarter ? 0 : 50, } @@ -248,21 +250,25 @@ const AgentUI = (props) => { return } + // If no node_id provided, look for the AI Agent node if (node_id === undefined || node_id === null || node_id === "") { - // 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 + if (item?.action?.app_name === "AI Agent") { + node_id = item?.action?.id + break } - - node_id = item?.action?.id - break } - */ if (node_id === undefined || node_id === null || node_id === "") { + // Fallback: if only one result, use it + if (execution_data?.results?.length === 1) { + setAgentActionResult(execution_data.results[0]) + const validatedData = validateJson(execution_data.results[0].result) + if (validatedData.valid) { + setData(validatedData.result) + } + } return } } @@ -270,6 +276,11 @@ const AgentUI = (props) => { var found = false for (var key in execution_data.results) { const item = execution_data.results[key] + + if (item?.action?.app_name !== "AI Agent") { + continue + } + if (item?.action?.id !== node_id) { continue } @@ -289,16 +300,6 @@ const AgentUI = (props) => { if (found === false) { toast.warn("Failed to find the relevant AI Agent result") - - if (execution_data?.results?.length === 1) { - setAgentActionResult(execution_data.results[0]) - const validatedData = validateJson(execution_data.results[0].result) - if (validatedData.valid) { - setData(validatedData.result) - } else { - toast.warn("Action output result is not valid JSON!") - } - } } } @@ -475,7 +476,7 @@ const AgentUI = (props) => { getAppAuth() }, []) - const maxTimelineWidth = 375 + const maxTimelineWidth = 275 const submitQuestions = (decisionId, questionAnswers, isContinuation) => { console.log("Submitting questions: ", decisionId, questionAnswers) @@ -521,8 +522,12 @@ const AgentUI = (props) => { const executionId = params.get("execution_id") const nodeId = params.get("node_id") const authorization = params.get("authorization") + const workflowIdParam = params.get("workflow_id") - const url = `${globalUrl}/api/v1/workflows/${executionId}/run?reference_execution=${executionId}&authorization=${authorization}&answer=true¬e=${encodeURIComponent(JSON.stringify(newArgument))}&agentic=true&decision_id=${decisionId}` + // workflow_id param > execution.workflow.id > executionId + const foundWorkflowId = workflowIdParam !== undefined && workflowIdParam !== null && workflowIdParam !== "" ? workflowIdParam : execution?.workflow?.id !== undefined && execution?.workflow?.id !== null && execution?.workflow?.id !== "" ? execution.workflow.id : executionId + + const url = `${globalUrl}/api/v1/workflows/${foundWorkflowId}/run?reference_execution=${executionId}&authorization=${authorization}&answer=true¬e=${encodeURIComponent(JSON.stringify(newArgument))}&agentic=true&decision_id=${decisionId}&node_id=${nodeId}` fetch(url, { method: "GET", credentials: "include", @@ -893,15 +898,32 @@ const AgentUI = (props) => { />
- {itemLabel} + + {itemLabel} +
{
: null} - {questions?.length > 0 && item?.status === "RUNNING" || item?.status === "WAITING" ? + {item.category !== "agent" && questions?.length > 0 && (item?.status === "RUNNING" || item?.status === "WAITING") ?
{questions.map((q, questionIndex) => { return ( @@ -1189,7 +1211,25 @@ const AgentUI = (props) => { const [continuationText, setContinuationText] = useState("") - var actionResult = execution?.results?.length > 0 ? execution.results[0] : execution + // Find the AI Agent result specifically, not just results[0] + var actionResult = null + if (execution?.results?.length > 0) { + for (var key in execution.results) { + const item = execution.results[key] + if (item?.action?.app_name === "AI Agent") { + actionResult = item + break + } + } + + // Fallback to first result if no AI Agent found + if (actionResult === null) { + actionResult = execution.results[0] + } + } else { + actionResult = execution + } + const validate = validateJson(actionResult?.result) if (validate.valid === true) { actionResult.result = validate.result @@ -1246,6 +1286,11 @@ const AgentUI = (props) => { for (var key in agent_data?.decisions) { const item = agent_data.decisions[key] + if (item.run_details === undefined) { + console.log("Skipping item without run_details:", item) + continue + } + if (item.run_details.started_at === undefined || item.run_details.started_at === null) { item.run_details.started_at = originalStartTime } @@ -1495,6 +1540,7 @@ const AgentUI = (props) => { parsedAction = parsedAction.slice(0, -1) // Remove last comma } + /* const data = { "id": uuid, "name":"agent", @@ -1517,9 +1563,23 @@ const AgentUI = (props) => { "name":"action", "value": parsedAction, } - ]} - + ], + "planning_mode": planningEnabled, + } const url = `${globalUrl}/api/v1/apps/agent_starter/run` + */ + + const data = { + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "tool_name": parsedAction, + "input": { + "text": inputText, + }, + } + } + const url = `${globalUrl}/api/v1/agent` fetch(url, { method: "POST", body: JSON.stringify(data), @@ -1604,8 +1664,8 @@ const AgentUI = (props) => { return ( -
-
+
+
{ agentRequestLoading ? : - - + + @@ -1678,12 +1742,29 @@ const AgentUI = (props) => { }} /> -
+
+ {/* + + + } label="Planning Mode" + style={chipStyle} + variant={planningEnabled ? "contained" : "outlined"} + onClick={() => { + setPlanningEnabled(!planningEnabled) + }} + disabled={true} + /> + + + */} } label="Select Apps / MCPs" style={chipStyle} + variant={"outlined"} onClick={() => { setAppPickerAnchor(document.getElementById("add_app_chip")) }} @@ -1794,18 +1875,21 @@ const AgentUI = (props) => { {chosenApps?.map((app, index) => { return( - + { - window.open(`/apps/${app.id}`, '_blank', 'noopener,noreferrer'); + if (app?.id !== undefined) { + window.open(`/apps/${app.id}`, '_blank', 'noopener,noreferrer'); + } }} style={{ cursor: "pointer", width: 30, height: 30, + backgroundColor: theme.palette.backgroundColor, }} /> diff --git a/frontend/src/views/AngularWorkflow.jsx b/frontend/src/views/AngularWorkflow.jsx index db69e886..f995697f 100755 --- a/frontend/src/views/AngularWorkflow.jsx +++ b/frontend/src/views/AngularWorkflow.jsx @@ -231,7 +231,7 @@ export const triggers = [ status: "uninitialized", trigger_type: "SCHEDULE", errors: null, - large_image: "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDgiIGhlaWdodD0iNDgiIHZpZXdCb3g9IjAgMCA0OCA0OCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjQ4IiBoZWlnaHQ9IjQ4IiByeD0iOCIgZmlsbD0iIzIxQTBCRCIvPgo8Y2lyY2xlIGN4PSIyNCIgY3k9IjI0IiByPSI4Ljc1IiBzdHJva2U9IndoaXRlIiBzdHJva2Utd2lkdGg9IjEuNSIvPgo8cGF0aCBkPSJNMjguNSAyNEgyNC4yNUMyNC4xMTE5IDI0IDI0IDIzLjg4ODEgMjQgMjMuNzVWMjAuNSIgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K", + large_image: "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDgiIGhlaWdodD0iNDgiIHZpZXdCb3g9IjAgMCA0OCA0OCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjQ4IiBoZWlnaHQ9IjQ4IiByeD0iOCIgZmlsbD0iI0UzQTQxQiIvPgo8cmVjdCB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDEyIDEyKSIgZmlsbD0iI0UzQTQxQiIvPgo8Y2lyY2xlIGN4PSIyNCIgY3k9IjI0IiByPSI4Ljc1IiBzdHJva2U9IndoaXRlIiBzdHJva2Utd2lkdGg9IjEuNSIvPgo8cGF0aCBkPSJNMjguNSAyNEgyNC4yNUMyNC4xMTE5IDI0IDI0IDIzLjg4ODEgMjQgMjMuNzVWMjAuNSIgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4=", label: "Schedule", is_valid: true, environment: "onprem", @@ -300,6 +300,11 @@ export const triggers = [ "name": "subflow", "example": "", "value": "", + }, + { + "name": "subflow_failure", + "example": "", + "value": "", } ], status: "running", @@ -498,6 +503,31 @@ export function setActionState(actionId, updates, workflowId = null) { } } +// Will use this function to remove the action data when the node will get removed from the cytoscape. +export function removeActionState(actionId, workflowId = null) { + if (!actionId) return; + + try { + const stored = localStorage.getItem(ACTION_STATES_STORAGE_KEY); + if (!stored) return; + + const allStates = JSON.parse(stored); + + if (workflowId && allStates[workflowId]) { + delete allStates[workflowId][actionId]; + + // Clean up empty workflow objects + if (Object.keys(allStates[workflowId]).length === 0) { + delete allStates[workflowId]; + } + } + + localStorage.setItem(ACTION_STATES_STORAGE_KEY, JSON.stringify(allStates)); + } catch (e) { + console.error("Failed to remove action state:", e); + } +} + const splitter = "|~|"; const svgSize = 24; const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); @@ -505,7 +535,7 @@ const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); //const referenceUrl = "https://shuffler.io/functions/webhooks/" //const referenceUrl = window.location.origin+"/api/v1/hooks/" -const searchClient = algoliasearch("JNSS5CFDZZ", "c8f882473ff42d41158430be09ec2b4e") +const searchClient = algoliasearch("JNSS5CFDZZ", "33e4e3564f4f060e96e0531957bed552") const AngularWorkflow = (defaultprops) => { const { globalUrl, setCookie, isLoggedIn, isLoaded, userdata, data_id, ReactGA, } = defaultprops; const {themeMode, supportEmail, brandColor} = useContext(Context) @@ -566,6 +596,8 @@ const AngularWorkflow = (defaultprops) => { const [originalWorkflow, setOriginalWorkflow] = React.useState({}); const [originalSelectedEnvironment, setOriginalSelectedEnvironment] = React.useState({}); const [subworkflow, setSubworkflow] = React.useState({}); + const [subworkflowFailure, setSubworkflowFailure] = React.useState({}); + const [subworkflowFailureStartnode, setSubworkflowFailureStartnode] = React.useState(""); const [subworkflowStartnode, setSubworkflowStartnode] = React.useState(""); const [leftViewOpen, setLeftViewOpen] = React.useState(isMobile ? false : true); const [leftBarSize, setLeftBarSize] = React.useState(isMobile ? 0 : 235) @@ -1130,7 +1162,14 @@ const AngularWorkflow = (defaultprops) => { "description": "Translates your JSON data into a standard formats, then stores it in the Shuffle Datastore", "label": "Translate standard", "example": "{\"source_data\": \"{\\\"event\\\": \\\"login\\\", \\\"user\\\": \\\"john_doe\\\", \\\"timestamp\\\": \\\"2023-10-01T12:00:00Z\\\"}\", \"standard\": \"OCSF\"}", - "parameters": [{ + "parameters": [ + { + "name": "app_name", + "value": "", + "required": true, + "multiline": false, + }, + { "name": "source_data", "value": "", "required": true, @@ -1150,7 +1189,14 @@ const AngularWorkflow = (defaultprops) => { "name": "Cases", "description": "Available actions for case management", "label": "Cases", - "parameters": [{ + "parameters": [ + { + "name": "app_name", + "value": "", + "required": true, + "multiline": false, + }, + { "name": "action", "value": "list_tickets", "options": [ @@ -1175,7 +1221,14 @@ const AngularWorkflow = (defaultprops) => { "name": "Communication", "description": "Available actions for communication", "label": "Communication", - "parameters": [{ + "parameters": [ + { + "name": "app_name", + "value": "", + "required": true, + "multiline": false, + }, + { "name": "action", "value": "list_messages", "options": [ @@ -1210,7 +1263,8 @@ const AngularWorkflow = (defaultprops) => { "disable_user", "get_identity", "get_asset", - "search_identity" + "search_identity", + "list_users", ], "required": true, }, @@ -2199,6 +2253,26 @@ const AngularWorkflow = (defaultprops) => { } } + if (param.name === "subflow_failure" && param.value !== undefined && param.value !== null && param.value.length > 0) { + if (param.value === workflow?.id) { + setSubworkflowFailure(workflow); + } else { + const sub = responseJson.find((data) => data?.id === param.value); + if (sub !== undefined) { + setSubworkflowFailure(sub); + + // Populate startnode if set + const startnodeParam = trigger.parameters.find((p) => p.name === "subflow_failure_startnode"); + if (startnodeParam && startnodeParam.value && sub.actions) { + const foundAction = sub.actions.find((a) => a?.id === startnodeParam.value); + if (foundAction) { + setSubworkflowFailureStartnode(foundAction); + } + } + } + } + } + if (param.name === "startnode" && param.value !== undefined && param.value !== null) { if (Object.getOwnPropertyNames(baseSubflow).length > 0) { @@ -2344,7 +2418,7 @@ const AngularWorkflow = (defaultprops) => { return } - setExecutionsLoading(true); + setExecutionsLoading(true); var url = `${globalUrl}/api/v2/workflows/${id}/executions` var method = "GET" @@ -2825,6 +2899,7 @@ const AngularWorkflow = (defaultprops) => { stop() return } + //console.log(responseJson) // Loop nodes and find results // Update on every interval? idk @@ -3971,6 +4046,8 @@ const AngularWorkflow = (defaultprops) => { if (actionAppname === appname) { workflow.actions[actionkey].selectedAuthentication = item; workflow.actions[actionkey].authentication_id = item.id; + selectedAction.selectedAuthentication = item; + selectedAction.authentication_id = item.id; appUpdates = true; } } @@ -5195,7 +5272,8 @@ const AngularWorkflow = (defaultprops) => { if (responseJson.public) { - setAppAuthentication([]) + // Delay setting appAuthentication to prevent race condition with graph setup + setTimeout(() => setAppAuthentication([]), 100) setLeftBarSize(300) if (Object.getOwnPropertyNames(creatorProfile).length === 0) { @@ -5586,7 +5664,7 @@ const AngularWorkflow = (defaultprops) => { } ReactDOM.unstable_batchedUpdates(() => { - setRightSideBarOpen(true); + // setRightSideBarOpen(true); setLastSaved(false); /* @@ -6772,7 +6850,7 @@ const AngularWorkflow = (defaultprops) => { } //event.target.unselect(); - setRightSideBarOpen(true); + // setRightSideBarOpen(true); return } else if (data.buttonType === "copy") { @@ -6864,7 +6942,7 @@ const AngularWorkflow = (defaultprops) => { if (sourcenode !== null && sourcenode !== undefined) { const sourcedata = sourcenode.data() - if (sourcedata.trigger_type !== "SUBFLOW" && sourcedata.trigger_type !== "USERINPUT") { + if (sourcedata?.trigger_type !== "SUBFLOW" && sourcedata?.trigger_type !== "USERINPUT") { continue } @@ -7129,12 +7207,12 @@ const AngularWorkflow = (defaultprops) => { const tmpAuth = JSON.parse(JSON.stringify(newAppAuth)); - const curappName = curapp.name.toLowerCase() + const curappName = curapp.name.toLowerCase().replaceAll(" ", "_") for (let tmpAuthKey in tmpAuth) { var item = tmpAuth[tmpAuthKey]; const newfields = {}; - if (item.app.name.toLowerCase() !== curappName) { + if (item.app.name.toLowerCase().replaceAll(" ", "_") !== curappName) { continue } @@ -7516,7 +7594,6 @@ const AngularWorkflow = (defaultprops) => { setSelectedTriggerIndex(trigger_index) setSelectedTrigger(data) - //setSelectedActionEnvironment(data.env) }, 25) } else if (data.type === "COMMENT") { if (selectedNodes?.length > 1) { @@ -7829,7 +7906,7 @@ const AngularWorkflow = (defaultprops) => { continue } - const paramname = param.name.toLowerCase().trim().replaceAll("_", " "); + const paramname = param.name?.toLowerCase()?.trim()?.replaceAll("_", " "); const foundresult = GetParamMatch(paramname, exampledata, ""); if (foundresult.length > 0) { @@ -7866,10 +7943,7 @@ const AngularWorkflow = (defaultprops) => { continue } - const paramname = param.name - .toLowerCase() - .trim() - .replaceAll("_", " "); + const paramname = param.name?.toLowerCase()?.trim()?.replaceAll("_", " "); const foundresult = GetParamMatch(paramname, exampledata, ""); if (foundresult.length > 0) { @@ -8169,11 +8243,11 @@ const AngularWorkflow = (defaultprops) => { if (workflow.branches[branchkey].destination_id === edge.target && workflow.branches[branchkey].source_id === edge.source) { console.log("That branch already exists: ", workflow.branches[branchkey]) - const foundbranch = cy.getElementById(workflow.branches[branchkey].id) + //const foundbranch = cy.getElementById(workflow.branches[branchkey].id) + const foundbranch = cy.getElementById(edge.id) if (foundbranch !== undefined && foundbranch !== null && foundbranch.data() !== undefined && foundbranch.data() !== null) { console.log("Removing branch: ", foundbranch.data()) - - event.target.remove() + //event.target.remove() found = true break @@ -8545,6 +8619,10 @@ const AngularWorkflow = (defaultprops) => { workflow.actions = workflow.actions.filter((a) => a.id !== data.id); workflow.triggers = workflow.triggers.filter((a) => a.id !== data.id); + + // Clean up action state from localStorage + removeActionState(data.id, workflow.id); + if (workflow.start === data.id && workflow.actions.length > 0) { // FIXME - should check branches connected to startnode, as picking random // is just confusing @@ -8654,7 +8732,7 @@ const AngularWorkflow = (defaultprops) => { if ((event.ctrlKey || event.metaKey) && !event.shiftKey) { // If any modal/sidebar is open, let browser handle normal copy - if (isAnyModalOrSidebarOpen) { + if (isAnyModalOrSidebarOpen || event.target?.closest('.MuiDialog-root, .MuiModal-root, [role="dialog"]')) { return } @@ -10105,8 +10183,8 @@ const AngularWorkflow = (defaultprops) => { // Calculates how a branch should curve (it's still weird~) // https://codepen.io/guillaumethomas/pen/xxbbBKO const calculateEdgeCurve = (sourcenodePosition, destinationnodePosition) => { - const xParsed = destinationnodePosition.x - sourcenodePosition.x - const yParsed = destinationnodePosition.y - sourcenodePosition.y + const xParsed = destinationnodePosition?.x - sourcenodePosition?.x + const yParsed = destinationnodePosition?.y - sourcenodePosition?.y const z = Math.sqrt(xParsed * xParsed + yParsed * yParsed) const costheta = xParsed / z @@ -10273,7 +10351,7 @@ const AngularWorkflow = (defaultprops) => { action.iconBackground = iconInfo.iconBackgroundColor action.fillstyle = "linear-gradient" } - }else if(!action.isStartNode) { + } else if(!action.isStartNode) { // This is to round the corners of the image // If action has no large_image (e.g. imported/synced workflow where it was stripped), // inject it from the available apps in the sidebar @@ -10283,6 +10361,7 @@ const AngularWorkflow = (defaultprops) => { apps.find((a) => a.name === action.app_name) imageSource = (foundApp && foundApp.large_image) ? foundApp.large_image : "" } + const originalBase64 = imageSource !== "" ? imageSource : theme.palette.defaultImage const roundedImage = await roundBase64Image(originalBase64, 16); action = {...action, large_image: roundedImage} @@ -11424,10 +11503,10 @@ const AngularWorkflow = (defaultprops) => { // No matter what, it's being stopped. if (!responseJson.success) { if (responseJson.reason !== undefined) { - toast("Failed to stop schedule: " + responseJson.reason); + toast.warn("Failed to stop schedule: " + responseJson.reason); } } else { - toast("Successfully stopped schedule"); + toast.success("Successfully stopped schedule"); } if (triggerindex !== undefined && triggerindex !== null && triggerindex >= 0) { @@ -13161,7 +13240,7 @@ const AngularWorkflow = (defaultprops) => { if (queryID !== undefined && queryID !== null) { aa('init', { appId: "JNSS5CFDZZ", - apiKey: "c8f882473ff42d41158430be09ec2b4e", + apiKey: "33e4e3564f4f060e96e0531957bed552", }) const timestamp = new Date().getTime() @@ -16032,7 +16111,7 @@ const AngularWorkflow = (defaultprops) => { onClick={() => { // Change Direction of the branch target/source const foundBranch = cy.getElementById(selectedEdge.id) - if (foundBranch !== undefined && foundBranch !== null) { + if (foundBranch !== undefined && foundBranch !== null && foundBranch?.length > 0) { const source = foundBranch.data("source") const target = foundBranch.data("target") @@ -19292,6 +19371,236 @@ const AngularWorkflow = (defaultprops) => { /> ) : null} + {workflow?.triggers && + workflow?.triggers[selectedTriggerIndex] && + workflow?.triggers[selectedTriggerIndex].parameters + ? ( +
+ On Decline + + Optionally trigger a workflow when the user declines + + {workflows === undefined || + workflows === null || + workflows.length === 0 ? null : ( + option.id === value.id} + getOptionLabel={(option) => { + if (option === undefined || option === null || option.name === undefined || option.name === null) { + return "No Workflow Selected"; + } + const newname = (option.name.charAt(0).toUpperCase() + option.name.substring(1)).replaceAll("_", " "); + return newname; + }} + options={ + [{ + "id": "", + "name": "No Workflow Selected", + }].concat(workflows) + } + fullWidth + onChange={(event, newValue) => { + if (newValue === null || newValue === undefined || newValue.id === undefined) { + return + } + + var failureParamIndex = workflow.triggers[selectedTriggerIndex].parameters.findIndex((param) => param.name === "subflow_failure") + if (failureParamIndex === -1) { + workflow.triggers[selectedTriggerIndex].parameters.push({ + "name": "subflow_failure", + "value": "", + }) + failureParamIndex = workflow.triggers[selectedTriggerIndex].parameters.length - 1 + } + + workflow.triggers[selectedTriggerIndex].parameters[failureParamIndex].value = newValue.id + setSubworkflowFailureStartnode("") + + // Fetch workflow to get actions for startnode selection + if (newValue.id.length > 0 && (newValue.actions === undefined || newValue.actions === null || newValue.actions.length === 0)) { + fetch(`${globalUrl}/api/v1/workflows/${newValue.id}`, { + method: "GET", + headers: { "Content-Type": "application/json" }, + credentials: "include", + }) + .then((resp) => resp.json()) + .then((responseJson) => { + if (responseJson.id !== undefined) { + setSubworkflowFailure(responseJson) + + // Default startnode + const startAction = responseJson.actions?.find((a) => a.id === responseJson.start) + if (startAction) { + setSubworkflowFailureStartnode(startAction) + } + } + }) + .catch((error) => { + console.log("Failed fetching decline workflow: ", error) + }) + } else { + setSubworkflowFailure(newValue) + const startAction = newValue.actions?.find((a) => a.id === newValue.start) + if (startAction) { + setSubworkflowFailureStartnode(startAction) + } + } + + setWorkflow(workflow) + setUpdate(Math.random()) + setLastSaved(false) + event.target.blur() + }} + renderOption={(props, data, state) => { + return ( + + + {data.name} + + ) + }} + renderInput={(params) => { + return ( +
+ + {subworkflowFailure === null || subworkflowFailure === undefined || subworkflowFailure?.id === undefined || subworkflowFailure?.id === null || subworkflowFailure?.id.length === 0 ? null : + + + + + + } +
+ ); + }} + /> + )} + + {subworkflowFailure?.actions !== undefined && subworkflowFailure?.actions !== null && subworkflowFailure?.actions?.length > 0 ? ( + option.id === value.id} + getOptionLabel={(option) => { + if (option === undefined || option === null || option.label === undefined || option.label === null) { + return "Default"; + } + const newname = (option.label.charAt(0).toUpperCase() + option.label.substring(1)).replaceAll("_", " "); + return newname; + }} + options={subworkflowFailure.actions} + fullWidth + onChange={(event, newValue) => { + setSubworkflowFailureStartnode(newValue) + + var startnodeParamIndex = workflow.triggers[selectedTriggerIndex].parameters.findIndex((param) => param.name === "subflow_failure_startnode") + if (startnodeParamIndex === -1) { + workflow.triggers[selectedTriggerIndex].parameters.push({ + "name": "subflow_failure_startnode", + "value": "", + }) + startnodeParamIndex = workflow.triggers[selectedTriggerIndex].parameters.length - 1 + } + + workflow.triggers[selectedTriggerIndex].parameters[startnodeParamIndex].value = newValue?.id || "" + setWorkflow(workflow) + setUpdate(Math.random()) + setLastSaved(false) + }} + renderOption={(props, action, state) => { + return ( + + {action.label} + + ) + }} + renderInput={(params) => { + return ( + + ); + }} + /> + ) : null} +
+ ) : null} +
Required Input-Questions @@ -19978,6 +20287,7 @@ const AngularWorkflow = (defaultprops) => { "&.Mui-selected": { backgroundColor: themeMode === "dark" ? "#1e1e1e" : "#CCCCCC", color: theme.palette.text.primary, + borderRadius: "6px !important", fontWeight: 600, "&:hover": { backgroundColor: themeMode === "dark" ? "rgba(0,0,0,0.3)" : "rgba(0,0,0,0.1)", @@ -20020,7 +20330,6 @@ const AngularWorkflow = (defaultprops) => { justifyContent: "space-between", width: "100%", position: "relative", - minHeight: 80, }}> {/* Left: Workflow Name Container */}
{ }} > {workflow?.name !== undefined && workflow?.name !== null && workflow?.name?.length > 0 ? - + : null } {workflow.name} + {/* Warning Messages */} + {!distributedFromParent || userdata?.support === true ? + isCorrectOrg ? null : + + Warning: { + toast.info("Changing to correct organisation. Please wait a few seconds.") + changeOrg() + }} + >Change Active Organization to edit this Workflow. + + : + + suborgWorkflows?.length === 0 ? + + Warning: This workflow is controlled by your parent org and may not be editable. + + : + null + } + {parentWorkflows === undefined || parentWorkflows === null || parentWorkflows.length === 0 ? null : + + }
{/* Center: Build/Debug Toggle */} @@ -20237,6 +20602,7 @@ const AngularWorkflow = (defaultprops) => { saveWorkflow(workflow, undefined, undefined, e.target.value) /* Standard re-loads */ + setAllTriggers(undefined) setSelectedTriggerIndex(-1) getEnvironments(e.target.value) @@ -20561,7 +20927,7 @@ const AngularWorkflow = (defaultprops) => { id="execution_location" style={{ color: theme.palette.text.primary }} > - Runtime Location + Runtime Location ({selectedActionEnvironment?.Name})