@@ -1362,10 +1273,10 @@ const AppAuthTab = memo((props) => {
{
- deleteAuthentication(data);
+ setDeleteConfirmTarget(data);
+ setDeleteConfirmOpen(true);
}}
>
@@ -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
+
-
+ }
+ >
+ Upgrade Now
+
+ }
>
- Contact our team to learn more about enterprise features and pricing.
-
+ Talk to Sales
+
+
-
}
- endIcon={
}
- >
- Contact Sales
-
)}
);
};
+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 ? (
-
- ) : 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)
}}
>