diff --git a/src/components/ClusterStatGrids.jsx b/src/components/ClusterStatGrids.jsx index 486b907b..ae3c66a6 100644 --- a/src/components/ClusterStatGrids.jsx +++ b/src/components/ClusterStatGrids.jsx @@ -11,7 +11,6 @@ const ClickLoader = memo(({isLoading}) => ( export const GridNodes = memo(({nodeCount, frozenCount, onClick}) => { const [isLoading, setIsLoading] = useState(false); - const handleClick = useCallback(() => { setIsLoading(true); prepareForNavigation(); @@ -22,13 +21,7 @@ export const GridNodes = memo(({nodeCount, frozenCount, onClick}) => { }, [onClick]); const subtitle = useMemo(() => ( - + { ), [frozenCount, isLoading, handleClick]); - return ( - - ); + return ; }); export const GridObjects = memo(({objectCount, statusCount, onClick}) => { @@ -79,7 +65,6 @@ export const GridObjects = memo(({objectCount, statusCount, onClick}) => { const subtitle = useMemo(() => { const chips = []; const statuses = ['up', 'warn', 'down', 'unprovisioned']; - for (const status of statuses) { const count = statusCount[status] || 0; if (count > 0) { @@ -94,7 +79,6 @@ export const GridObjects = memo(({objectCount, statusCount, onClick}) => { ); } } - return ( {chips} @@ -103,31 +87,15 @@ export const GridObjects = memo(({objectCount, statusCount, onClick}) => { ); }, [statusCount, loadingStatus, handleChipClick]); - return ( - - ); + return ; }); const StatusChip = memo(({status, count, isLoading, onClick}) => { - const colors = { - up: 'green', - warn: 'orange', - down: 'red', - unprovisioned: 'red' - }; - + const colors = {up: 'green', warn: 'orange', down: 'red', unprovisioned: 'red'}; const handleClick = useCallback((e) => { e.stopPropagation(); - if (!isLoading) { - onClick(); - } + if (!isLoading) onClick(); }, [onClick, isLoading]); - return ( { ); }); +// ==================== NAMESPACES ==================== export const GridNamespaces = memo(({namespaceCount, namespaceSubtitle, onClick}) => { const [loadingNamespace, setLoadingNamespace] = useState(''); const [isCardLoading, setIsCardLoading] = useState(false); @@ -160,30 +129,26 @@ export const GridNamespaces = memo(({namespaceCount, namespaceSubtitle, onClick} }, 50); }, [onClick]); - const subtitle = useMemo(() => { - return ( - - {namespaceSubtitle.map(({namespace, status}) => ( - - ))} - - ); - }, [namespaceSubtitle, loadingNamespace, onClick]); + const subtitle = useMemo(() => ( + + {namespaceSubtitle.map(({namespace, status}) => ( + + ))} + + ), [namespaceSubtitle, loadingNamespace, onClick]); return ( { const elements = []; const statusTypes = ['up', 'warn', 'down', 'unprovisioned']; - for (const stat of statusTypes) { const count = status[stat] || 0; if (count > 0) { @@ -313,11 +277,8 @@ const NamespaceChip = memo(({namespace, status, isLoading, onClick, onLoadingCha ); }); -export const GridHeartbeats = memo(({ - heartbeatCount, - perHeartbeatStats = {}, - onClick - }) => { +// ==================== HEARTBEATS ==================== +export const GridHeartbeats = memo(({heartbeatCount, perHeartbeatStats = {}, onClick}) => { const [loadingId, setLoadingId] = useState(''); const handleCardClick = useCallback(() => { @@ -342,34 +303,20 @@ export const GridHeartbeats = memo(({ const subtitle = useMemo(() => { const groups = new Map(); - for (const [fullId, {running, beating}] of Object.entries(perHeartbeatStats)) { if (running === 0 && beating === 0) continue; - let baseId = fullId; - if (fullId.endsWith('.rx')) { - baseId = fullId.slice(0, -3); - } else if (fullId.endsWith('.tx')) { - baseId = fullId.slice(0, -3); - } - - if (!groups.has(baseId)) { - groups.set(baseId, {total: 0, healthy: 0}); - } + if (fullId.endsWith('.rx')) baseId = fullId.slice(0, -3); + else if (fullId.endsWith('.tx')) baseId = fullId.slice(0, -3); + if (!groups.has(baseId)) groups.set(baseId, {total: 0, healthy: 0}); const group = groups.get(baseId); group.total += 1; - - const isHealthy = running > 0 && beating === running; - if (isHealthy) { - group.healthy += 1; - } + if (running > 0 && beating === running) group.healthy += 1; } - const chips = []; for (const [baseId, {total, healthy}] of groups.entries()) { const isHealthy = (total === healthy); const isLoading = loadingId === baseId; - chips.push( ); } - return ( - + {chips} ); }, [perHeartbeatStats, loadingId, handleChipClick]); - return ( - - ); + return ; }); +// ==================== POOLS ==================== export const GridPools = memo(({poolCount, pools, onClick}) => { const [isLoading, setIsLoading] = useState(false); - const handleClick = useCallback(() => { setIsLoading(true); prepareForNavigation(); @@ -426,67 +359,36 @@ export const GridPools = memo(({poolCount, pools, onClick}) => { }, [onClick]); const subtitle = useMemo(() => { - if (!pools || pools.length === 0) { - return null; - } - + if (!pools || pools.length === 0) return null; return ( - + {pools.map((pool) => { - const usagePercentage = pool.size && pool.used >= 0 - ? ((pool.used / pool.size) * 100).toFixed(1) - : "N/A"; + const usagePercentage = pool.size && pool.used >= 0 ? ((pool.used / pool.size) * 100).toFixed(1) : "N/A"; const free = pool.size - pool.used; const freePercent = pool.size ? (free / pool.size) * 100 : 100; const isLowStorage = freePercent < 10; - return ( - - - + ); })} ); }, [pools]); - return ( - - ); + return ; }); +// ==================== NETWORKS ==================== export const GridNetworks = memo(({networks, onClick}) => { const [isLoading, setIsLoading] = useState(false); - const handleCardClick = useCallback(() => { setIsLoading(true); prepareForNavigation(); @@ -496,61 +398,33 @@ export const GridNetworks = memo(({networks, onClick}) => { }, 50); }, [onClick]); - const subtitle = useMemo(() => { - return ( - - {networks.map((network) => { - const usagePercentage = network.size - ? ((network.used / network.size) * 100).toFixed(1) - : 0; - const free = network.size - network.used; - const freePercent = network.size ? (free / network.size) * 100 : 100; - const isLowStorage = freePercent < 10; - - return ( - - - - ); - })} - - ); - }, [networks]); + const subtitle = useMemo(() => ( + + {networks.map((network) => { + const usagePercentage = network.size ? ((network.used / network.size) * 100).toFixed(1) : 0; + const free = network.size - network.used; + const freePercent = network.size ? (free / network.size) * 100 : 100; + const isLowStorage = freePercent < 10; + return ( + + ); + })} + + ), [networks]); - return ( - - ); + return ; }); +// ==================== KINDS ==================== export const GridKinds = memo(({kindCount, kindSubtitle, onClick}) => { const [loadingKind, setLoadingKind] = useState(''); const [isCardLoading, setIsCardLoading] = useState(false); @@ -564,30 +438,26 @@ export const GridKinds = memo(({kindCount, kindSubtitle, onClick}) => { }, 50); }, [onClick]); - const subtitle = useMemo(() => { - return ( - - {kindSubtitle.map(({kind, status}) => ( - - ))} - - ); - }, [kindSubtitle, loadingKind, onClick]); + const subtitle = useMemo(() => ( + + {kindSubtitle.map(({kind, status}) => ( + + ))} + + ), [kindSubtitle, loadingKind, onClick]); return ( { const statusElements = useMemo(() => { const elements = []; const statusTypes = ['up', 'warn', 'down', 'unprovisioned']; - for (const stat of statusTypes) { const count = status[stat] || 0; if (count > 0) { diff --git a/src/components/ConfigSection.jsx b/src/components/ConfigSection.jsx index 3d021955..05b90ec4 100644 --- a/src/components/ConfigSection.jsx +++ b/src/components/ConfigSection.jsx @@ -29,7 +29,7 @@ import DeleteIcon from "@mui/icons-material/Delete"; import {URL_OBJECT, URL_NODE} from "../config/apiPath.js"; import {parseObjectPath} from "../utils/objectUtils"; -const useConfig = (decodedObjectName, configNode, setConfigNode) => { +const useConfig = (decodedObjectName, configNode, setConfigNode, refreshTrigger) => { const initialState = { data: null, loading: false, @@ -43,20 +43,23 @@ const useConfig = (decodedObjectName, configNode, setConfigNode) => { return {...state, loading: false, data: action.payload}; case "FETCH_ERROR": return {...state, loading: false, error: action.payload}; + case "RESET": + return initialState; default: return state; } }; const [state, dispatch] = useReducer(reducer, initialState); const lastFetch = useRef({}); - const fetchConfig = useCallback(async (node) => { + + const fetchConfig = useCallback(async (node, forceBypassThrottle = false) => { if (!node) { - dispatch({type: "FETCH_ERROR", payload: "No node available to fetch configuration."}); + dispatch({type: "RESET"}); return; } const key = `${decodedObjectName}:${node}`; const now = Date.now(); - if (lastFetch.current[key] && now - lastFetch.current[key] < 1000) return; + if (!forceBypassThrottle && lastFetch.current[key] && now - lastFetch.current[key] < 1000) return; lastFetch.current[key] = now; const {namespace, kind, name} = parseObjectPath(decodedObjectName); const token = localStorage.getItem("authToken") || ""; @@ -78,13 +81,26 @@ const useConfig = (decodedObjectName, configNode, setConfigNode) => { dispatch({type: "FETCH_ERROR", payload: `Failed to fetch config: ${err.message}`}); } }, [decodedObjectName, setConfigNode]); + useEffect(() => { if (configNode) { fetchConfig(configNode); } else { - dispatch({type: "FETCH_ERROR", payload: "No node available to fetch configuration."}); + dispatch({type: "RESET"}); } }, [configNode, decodedObjectName, fetchConfig]); + + // Force re-fetch when refreshTrigger bumps (external config change via SSE) + const prevRefreshTrigger = useRef(refreshTrigger); + useEffect(() => { + if (refreshTrigger === prevRefreshTrigger.current) return; + prevRefreshTrigger.current = refreshTrigger; + if (configNode) { + // Bypass throttle so the fresh content is fetched immediately + fetchConfig(configNode, true); + } + }, [refreshTrigger, configNode, fetchConfig]); + return {...state, fetchConfig}; }; @@ -589,7 +605,8 @@ const ConfigSection = ({ setConfigNode, openSnackbar, configDialogOpen, - setConfigDialogOpen + setConfigDialogOpen, + configRefreshTrigger = 0, }) => { const theme = useTheme(); const isDark = theme.palette.mode === 'dark'; @@ -597,7 +614,8 @@ const ConfigSection = ({ const {data: configData, loading: configLoading, error: configError, fetchConfig} = useConfig( decodedObjectName, configNode, - setConfigNode + setConfigNode, + configRefreshTrigger, ); const { data: keywordsData, @@ -661,7 +679,7 @@ const ConfigSection = ({ } openSnackbar("Configuration updated successfully"); if (configNode) { - await fetchConfig(configNode); + await fetchConfig(configNode, true); setConfigDialogOpen(true); } } catch (err) { @@ -728,7 +746,7 @@ const ConfigSection = ({ if (successCount > 0) { openSnackbar(`Successfully added ${successCount} parameter(s)`, "success"); if (configNode) { - await fetchConfig(configNode); + await fetchConfig(configNode, true); await fetchExistingParams(); setConfigDialogOpen(true); } @@ -773,7 +791,7 @@ const ConfigSection = ({ if (successCount > 0) { openSnackbar(`Successfully unset ${successCount} parameter(s)`, "success"); if (configNode) { - await fetchConfig(configNode); + await fetchConfig(configNode, true); await fetchExistingParams(); setConfigDialogOpen(true); } @@ -812,7 +830,7 @@ const ConfigSection = ({ if (successCount > 0) { openSnackbar(`Successfully deleted ${successCount} section(s)`, "success"); if (configNode) { - await fetchConfig(configNode); + await fetchConfig(configNode, true); await fetchExistingParams(); setConfigDialogOpen(true); } @@ -840,7 +858,7 @@ const ConfigSection = ({ return ( - + + }} + disabled={!currentConsoleUrl}>Copy URL + }} + disabled={!currentConsoleUrl}>Open in New Tab @@ -772,22 +740,15 @@ const ObjectDetail = () => { {!["sec", "cfg", "usr"].includes(kind) && ( <> - - - setActionsMenuAnchor(null)} - anchorOrigin={{vertical: 'bottom', horizontal: 'right'}} - transformOrigin={{vertical: 'top', horizontal: 'right'}} - > + setActionsMenuAnchor(null)} + anchorOrigin={{vertical: 'bottom', horizontal: 'right'}} + transformOrigin={{vertical: 'top', horizontal: 'right'}} sx={{zIndex: 10000}}> {INSTANCE_ACTIONS.map(({name, icon}) => ( handleBatchNodeActionClick(name)}> {icon} @@ -795,7 +756,6 @@ const ObjectDetail = () => { ))} - {nodesList.map(node => ( { onViewInstance={handleViewInstance} /> ))} - - setIndividualNodeMenuAnchor(null)} - anchorOrigin={{vertical: 'bottom', horizontal: 'right'}} - transformOrigin={{vertical: 'top', horizontal: 'right'}} - > + setIndividualNodeMenuAnchor(null)} + anchorOrigin={{vertical: 'bottom', horizontal: 'right'}} + transformOrigin={{vertical: 'top', horizontal: 'right'}} sx={{zIndex: 10000}}> {INSTANCE_ACTIONS.map(({name, icon}) => ( handleIndividualNodeActionClick(name)}> {icon} @@ -837,8 +793,6 @@ const ObjectDetail = () => { )} - {configError && {configError}} - { {logsDrawerOpen && selectedNodeForLogs && ( { } }}> - {selectedInstanceForLogs ? `Instance Logs - ${selectedInstanceForLogs}` : `Node Logs - ${selectedNodeForLogs}`} + + {selectedInstanceForLogs ? `Instance Logs - ${selectedInstanceForLogs}` : `Node Logs - ${selectedNodeForLogs}`} + { open={Boolean(instanceMenuAnchor)} anchorEl={instanceMenuAnchor} placement="bottom-end" - sx={{zIndex: 1400}} + sx={{zIndex: 10000}} > setInstanceMenuAnchor(null)}> @@ -951,7 +951,7 @@ const ObjectInstanceView = () => { open={Boolean(resourceMenuAnchor)} anchorEl={resourceMenuAnchor} placement="bottom-end" - sx={{zIndex: 1300}} + sx={{zIndex: 10000}} > setResourceMenuAnchor(null)}> diff --git a/src/components/Objects.jsx b/src/components/Objects.jsx index fcf73b7e..d0501c20 100644 --- a/src/components/Objects.jsx +++ b/src/components/Objects.jsx @@ -935,10 +935,10 @@ const Objects = () => { if (!currentObject) return []; const objectData = objectStatus[currentObject]; return OBJECT_ACTIONS.filter( - ({name}) => - isActionAllowedForSelection(name, [currentObject]) && - (name !== "freeze" || !objectData?.frozen || objectData.frozen !== "frozen") && - (name !== "unfreeze" || objectData?.frozen === "frozen") + (action) => + isActionAllowedForSelection(action.name, [currentObject]) && + (action.name !== "freeze" || !objectData?.frozen || objectData.frozen !== "frozen") && + (action.name !== "unfreeze" || objectData?.frozen === "frozen") ); }, [currentObject, objectStatus]); @@ -1185,6 +1185,7 @@ const Objects = () => { + {/* Menu for row actions (per object) */} { horizontal: 'right', }} sx={{ + zIndex: 10000, "& .MuiPaper-root": { minWidth: 200, boxShadow: "0px 5px 15px rgba(0,0,0,0.2)", } }} > - {filteredRowActions.map(({name, icon}) => ( - { - e.stopPropagation(); - handleActionClick(name, true, currentObject); - }} - sx={{display: "flex", alignItems: "center", gap: 1}} - aria-label={`${name} action for object ${currentObject}`} - > - {icon} - {name.charAt(0).toUpperCase() + name.slice(1)} - - ))} + {filteredRowActions.map((action) => { + const {name, icon, color} = action; + return ( + { + e.stopPropagation(); + handleActionClick(name, true, currentObject); + }} + sx={{ + display: "flex", + alignItems: "center", + gap: 1, + color: color === "red" ? "error.main" : "inherit", + }} + aria-label={`${name} action for object ${currentObject}`} + > + + {icon} + + {name.charAt(0).toUpperCase() + name.slice(1)} + + ); + })} {/* Menu for global actions */} @@ -1235,13 +1247,15 @@ const Objects = () => { horizontal: 'right', }} sx={{ + zIndex: 10000, "& .MuiPaper-root": { minWidth: 200, boxShadow: "0px 5px 15px rgba(0,0,0,0.2)" } }} > - {OBJECT_ACTIONS.map(({name, icon}) => { + {OBJECT_ACTIONS.map((action) => { + const {name, icon, color} = action; const isAllowed = isActionAllowedForSelection(name, selectedObjects); return ( { onClick={() => handleActionClick(name)} disabled={!isAllowed} sx={{ - color: isAllowed ? "inherit" : "text.disabled", + color: isAllowed + ? (color === "red" ? "error.main" : "inherit") + : "text.disabled", "&.Mui-disabled": {opacity: 0.5} }} aria-label={`${name} action for selected objects`} > - + {icon} {name.charAt(0).toUpperCase() + name.slice(1)} diff --git a/src/components/StatCard.jsx b/src/components/StatCard.jsx index 04521fd8..2bbec8cc 100644 --- a/src/components/StatCard.jsx +++ b/src/components/StatCard.jsx @@ -10,45 +10,55 @@ const StatCard = memo(({title, value, subtitle, onClick, dynamicHeight = false, {isLoading && ( - + )} {title} - - {isLoading ? ( - - ) : ( - {value} - )} - + + {isLoading ? : value} + {subtitle && ( - e.stopPropagation()}> - {typeof subtitle === 'string' ? ( - {subtitle} - ) : ( - subtitle - )} + e.stopPropagation()} + > + + {typeof subtitle === 'string' ? ( + {subtitle} + ) : ( + subtitle + )} + )} diff --git a/src/components/tests/ActionDialogs.test.jsx b/src/components/tests/ActionDialogs.test.jsx index da614321..af709a27 100644 --- a/src/components/tests/ActionDialogs.test.jsx +++ b/src/components/tests/ActionDialogs.test.jsx @@ -9,24 +9,30 @@ import { // Mock MUI components to simplify rendering and add accessibility labels jest.mock('@mui/material', () => ({ - Dialog: ({open, children}) => (open ?
{children}
: null), + Dialog: ({open, children}) => (open ?
{children}
: null), DialogTitle: ({children}) =>

{children}

, DialogContent: ({children}) =>
{children}
, DialogActions: ({children}) =>
{children}
, - Button: ({onClick, disabled, children, 'aria-label': ariaLabel}) => - , + Button: ({onClick, disabled, children, 'aria-label': ariaLabel, variant}) => + , Checkbox: ({checked, onChange, 'aria-label': ariaLabel}) => , FormControlLabel: ({control, label}) => , Typography: ({children}) => {children}, - TextField: ({value, onChange, 'aria-label': ariaLabel}) => - , + TextField: ({value, onChange, 'aria-label': ariaLabel, multiline, placeholder, rows, sx}) => { + if (multiline) { + return