From 2c5ae726dad2bdc3e95511dd38b9f22f63daab16 Mon Sep 17 00:00:00 2001 From: Paul Jouvanceau Date: Mon, 1 Jun 2026 09:38:29 +0200 Subject: [PATCH 1/6] fix(NodeCard): show "Not Provisioned" icon only for explicit false values Previously, the icon appeared for any falsy value including "n/a", which caused incorrect warnings for objects like clusters where provisioned is legitimately "n/a". Now the icon is shown only when provisioned is exactly false or "false". Updated NodeCard logic and adjusted tests accordingly: - Removed dependency on parseProvisionedState - Added tests for false, "false", true, and "n/a" cases --- src/components/NodeCard.jsx | 7 ++-- src/components/tests/NodeCard.test.jsx | 46 +++++++++++++++++++++----- 2 files changed, 40 insertions(+), 13 deletions(-) diff --git a/src/components/NodeCard.jsx b/src/components/NodeCard.jsx index 4ba398e..362378f 100644 --- a/src/components/NodeCard.jsx +++ b/src/components/NodeCard.jsx @@ -35,7 +35,6 @@ const NodeCard = ({ individualNodeMenuAnchorRef = null, getColor = () => grey[500], getNodeState = () => ({avail: "unknown", frozen: "unfrozen", state: null}), - parseProvisionedState = (state) => !!state, instanceName, onOpenLogs = () => logger.warn("onOpenLogs not provided"), onViewInstance, @@ -49,7 +48,7 @@ const NodeCard = ({ } const {avail, frozen, state} = getNodeState(node); - const isInstanceNotProvisioned = nodeData?.provisioned !== undefined ? !parseProvisionedState(nodeData.provisioned) : false; + const isInstanceNotProvisioned = nodeData?.provisioned === false || nodeData?.provisioned === "false"; const handleCardClick = (e) => { if (e.target.closest('button') || e.target.closest('input') || e.target.closest('.no-click')) { @@ -112,13 +111,12 @@ const NodeCard = ({ - {/* Bloc central : freeze / not provisioned / state, juste avant logs+status */} @@ -138,7 +136,6 @@ const NodeCard = ({ {state && {state}} - {/* Bloc droite : logs + rond de status + bouton d’actions, fixes */} { expect(screen.getByTestId('AcUnitIcon')).toBeInTheDocument(); }); - test('shows not provisioned icon when instance is not provisioned', () => { + test('shows not provisioned icon when instance is not provisioned (false)', () => { const nodeData = {provisioned: false}; - const parseProvisionedState = jest.fn(() => false); render( - + ); expect(screen.getByTestId('PriorityHighIcon')).toBeInTheDocument(); - expect(parseProvisionedState).toHaveBeenCalledWith(false); + }); + + test('shows not provisioned icon when instance is not provisioned ("false")', () => { + const nodeData = {provisioned: "false"}; + + render( + + + + ); + + expect(screen.getByTestId('PriorityHighIcon')).toBeInTheDocument(); + }); + + test('does NOT show not provisioned icon when provisioned is "n/a"', () => { + const nodeData = {provisioned: "n/a"}; + + render( + + + + ); + + expect(screen.queryByTestId('PriorityHighIcon')).not.toBeInTheDocument(); + }); + + test('does NOT show not provisioned icon when provisioned is true', () => { + const nodeData = {provisioned: true}; + + render( + + + + ); + + expect(screen.queryByTestId('PriorityHighIcon')).not.toBeInTheDocument(); }); test('displays node state when available', () => { From 5559a1fa635af03874fe93018bfbb43755c08f21 Mon Sep 17 00:00:00 2001 From: Paul Jouvanceau Date: Mon, 1 Jun 2026 10:43:07 +0200 Subject: [PATCH 2/6] fix(ObjectDetail, NodeCard): improve data reactivity and action menus - Replace Popper with Menu for batch and individual node actions to ensure full display - Use fine-grained selectors from useEventStore for reactive data updates (objectStatus, objectInstanceStatus, instanceMonitor, instanceConfig) - Derive memoized state (objectData, memoizedObjectData, nodesList) that automatically re-renders on store changes - Add error handling for useEventStore.subscribe: wrap subscriptions in try/catch and validate unsubscribe return type, logging warnings on failures - Update NodeCard to accept setIndividualNodeMenuAnchor prop for compatibility with tests and parent - Remove French comments Fixes stale data after SSE events and incomplete action menu display. --- src/components/NodeCard.jsx | 65 ++--- src/components/ObjectDetails.jsx | 426 ++++++++++++++----------------- 2 files changed, 212 insertions(+), 279 deletions(-) diff --git a/src/components/NodeCard.jsx b/src/components/NodeCard.jsx index 362378f..2b24ce4 100644 --- a/src/components/NodeCard.jsx +++ b/src/components/NodeCard.jsx @@ -1,4 +1,4 @@ -import React, {forwardRef, useState} from "react"; +import React, {useState} from "react"; import { Box, Typography, @@ -14,16 +14,6 @@ import ArticleIcon from "@mui/icons-material/Article"; import {grey, blue, red} from "@mui/material/colors"; import logger from '../utils/logger.js'; -const BoxWithRef = forwardRef((props, ref) => ( - -)); -BoxWithRef.displayName = 'BoxWithRef'; - -const IconButtonWithRef = forwardRef((props, ref) => ( - -)); -IconButtonWithRef.displayName = 'IconButtonWithRef'; - const NodeCard = ({ node, nodeData = {}, @@ -32,7 +22,6 @@ const NodeCard = ({ actionInProgress = false, setIndividualNodeMenuAnchor = () => logger.warn("setIndividualNodeMenuAnchor not provided"), setCurrentNode = () => logger.warn("setCurrentNode not provided"), - individualNodeMenuAnchorRef = null, getColor = () => grey[500], getNodeState = () => ({avail: "unknown", frozen: "unfrozen", state: null}), instanceName, @@ -59,8 +48,14 @@ const NodeCard = ({ } }; + const handleMenuOpen = (e) => { + e.stopPropagation(); + setCurrentNode(node); + setIndividualNodeMenuAnchor(e.currentTarget); + }; + return ( - - {/* Bloc gauche : checkbox + nom */} e.stopPropagation()} className="no-click"> (view resources) @@ -111,15 +101,7 @@ const NodeCard = ({ - + {frozen === "frozen" && ( @@ -136,16 +118,9 @@ const NodeCard = ({ {state && {state}} - + - { e.stopPropagation(); onOpenLogs(node, resolvedInstanceName); @@ -154,7 +129,7 @@ const NodeCard = ({ aria-label={`View logs for instance ${resolvedInstanceName || node}`} > - + @@ -166,24 +141,18 @@ const NodeCard = ({ /> - { - e.stopPropagation(); - e.persist(); - setCurrentNode(node); - setIndividualNodeMenuAnchor(e.currentTarget); - }} + - + - + ); }; diff --git a/src/components/ObjectDetails.jsx b/src/components/ObjectDetails.jsx index 27b3432..33eb1ab 100644 --- a/src/components/ObjectDetails.jsx +++ b/src/components/ObjectDetails.jsx @@ -5,23 +5,21 @@ import { Box, Button, CircularProgress, - ClickAwayListener, Dialog, DialogActions, DialogContent, DialogTitle, - ListItemIcon, - ListItemText, + Menu, MenuItem, - Paper, - Popper, - Snackbar, Typography, Drawer, IconButton, TextField, useTheme, Grid, + Snackbar, + ListItemIcon, + ListItemText, } from "@mui/material"; import CloseIcon from "@mui/icons-material/Close"; import {green, grey, orange, red} from "@mui/material/colors"; @@ -67,29 +65,25 @@ const ObjectDetail = () => { const decodedObjectName = decodeURIComponent(objectName); const {namespace, kind, name} = parseObjectPath(decodedObjectName); const navigate = useNavigate(); + const theme = useTheme(); - const objectStatus = useEventStore((s) => s.objectStatus); - const objectInstanceStatus = useEventStore((s) => s.objectInstanceStatus); + const objectStatus = useEventStore((s) => s.objectStatus[decodedObjectName]); + const objectInstanceStatus = useEventStore((s) => s.objectInstanceStatus[decodedObjectName]); const instanceMonitor = useEventStore((s) => s.instanceMonitor); - const instanceConfig = useEventStore((s) => s.instanceConfig); + const instanceConfig = useEventStore((s) => s.instanceConfig[decodedObjectName]); const clearConfigUpdate = useEventStore((s) => s.clearConfigUpdate); - const theme = useTheme(); - const [configLoading, setConfigLoading] = useState(false); const [configError, setConfigError] = useState(null); const [configNode, setConfigNode] = useState(null); const [configDialogOpen, setConfigDialogOpen] = useState(false); const [selectedNodes, setSelectedNodes] = useState([]); - const [nodesActionsAnchor, setNodesActionsAnchor] = useState(null); - const nodesActionsAnchorRef = useRef(null); + const [actionsMenuAnchor, setActionsMenuAnchor] = useState(null); const [individualNodeMenuAnchor, setIndividualNodeMenuAnchor] = useState(null); - const individualNodeMenuAnchorRef = useRef(null); const [currentNode, setCurrentNode] = useState(null); const [objectMenuAnchor, setObjectMenuAnchor] = useState(null); - const objectMenuAnchorRef = useRef(null); const [pendingAction, setPendingAction] = useState(null); const [actionInProgress, setActionInProgress] = useState(false); @@ -137,13 +131,37 @@ const ObjectDetail = () => { const isProcessingConfigUpdate = useRef(false); const isMounted = useRef(true); - useEffect(() => { - isMounted.current = true; - return () => { - isMounted.current = false; - closeEventSource(); - }; - }, [decodedObjectName]); + const objectData = useMemo(() => { + const avail = objectStatus?.avail || "n/a"; + const frozen = objectStatus?.frozen === "frozen" ? "frozen" : "unfrozen"; + let globalExpect = null; + if (objectInstanceStatus) { + for (const node of Object.keys(objectInstanceStatus)) { + const monitorKey = `${node}:${decodedObjectName}`; + const monitor = instanceMonitor[monitorKey] || {}; + if (monitor.global_expect && monitor.global_expect !== "none") { + globalExpect = monitor.global_expect; + break; + } + } + } + return {avail, frozen, globalExpect}; + }, [objectStatus, objectInstanceStatus, instanceMonitor, decodedObjectName]); + + const memoizedObjectData = useMemo(() => { + if (!objectInstanceStatus) return {}; + const enhanced = {}; + Object.keys(objectInstanceStatus).forEach(node => { + enhanced[node] = { + ...objectInstanceStatus[node], + instanceConfig: instanceConfig?.[node] || {resources: {}}, + instanceMonitor: instanceMonitor[`${node}:${decodedObjectName}`] || {resources: {}}, + }; + }); + return enhanced; + }, [objectInstanceStatus, instanceConfig, instanceMonitor, decodedObjectName]); + + const nodesList = useMemo(() => Object.keys(memoizedObjectData), [memoizedObjectData]); const fetchInitialObjectData = useCallback(async () => { setInitialDataError(null); @@ -177,36 +195,34 @@ const ObjectDetail = () => { } }, [decodedObjectName]); - const openSnackbar = useCallback((msg, sev = "success") => { - setSnackbar({open: true, message: msg, severity: sev}); - }, []); - - const closeSnackbar = useCallback(() => { - setSnackbar((s) => ({...s, open: false})); - }, []); + useEffect(() => { + const token = localStorage.getItem("authToken"); + if (token) { + const filters = objectEventTypes.map(type => { + if (["CONNECTION_OPENED", "CONNECTION_ERROR", "RECONNECTION_ATTEMPT", "MAX_RECONNECTIONS_REACHED", "CONNECTION_CLOSED"].includes(type)) { + return type; + } else { + return `${type},path=${decodedObjectName}`; + } + }); + startEventReception(token, filters); + } + return () => closeEventSource(); + }, [decodedObjectName, objectEventTypes]); - const openActionDialog = useCallback((action, context = null) => { - setPendingAction({action, ...(context ? context : {})}); - setSeats(1); - setGreetTimeout("5s"); - if (action === "console") { - setConsoleDialogOpen(true); - } else if (action === "freeze") { - setCheckboxes(DEFAULT_CHECKBOXES); - setConfirmDialogOpen(true); - } else if (action === "stop") { - setStopCheckbox(DEFAULT_STOP_CHECKBOX); - setStopDialogOpen(true); - } else if (action === "unprovision") { - setUnprovisionCheckboxes(DEFAULT_UNPROVISION_CHECKBOXES); - setUnprovisionDialogOpen(true); - } else if (action === "purge") { - setPurgeCheckboxes(DEFAULT_PURGE_CHECKBOXES); - setPurgeDialogOpen(true); + useEffect(() => { + const hasData = objectInstanceStatus && Object.keys(objectInstanceStatus).length > 0; + if (!hasData) { + fetchInitialObjectData().finally(() => setInitialLoading(false)); } else { - setSimpleDialogOpen(true); + setInitialLoading(false); } + }, [objectInstanceStatus, fetchInitialObjectData]); + + const openSnackbar = useCallback((msg, sev = "success") => { + setSnackbar({open: true, message: msg, severity: sev}); }, []); + const closeSnackbar = useCallback(() => setSnackbar((s) => ({...s, open: false})), []); const postActionUrl = useCallback(({node, objectName, action}) => { const {namespace, kind, name} = parseObjectPath(objectName); @@ -329,57 +345,28 @@ const ObjectDetail = () => { } }, [decodedObjectName, configLoading]); - const getColor = useCallback((status) => { - if (status === "up" || status === true) return green[500]; - if (status === "down" || status === false) return red[500]; - if (status === "warn") return orange[500]; - return grey[500]; - }, []); - - const getNodeState = useCallback((node) => { - const instanceStatus = objectInstanceStatus[decodedObjectName] || {}; - const monitorKey = `${node}:${decodedObjectName}`; - const monitor = instanceMonitor[monitorKey] || {}; - const avail = instanceStatus[node]?.avail || ''; - const frozen = instanceStatus[node]?.frozen_at && instanceStatus[node]?.frozen_at !== "0001-01-01T00:00:00Z" ? "frozen" : "unfrozen"; - const state = monitor.state !== "idle" ? monitor.state : null; - return {avail, frozen, state}; - }, [objectInstanceStatus, instanceMonitor, decodedObjectName]); - - const getObjectStatus = useCallback(() => { - const obj = objectStatus[decodedObjectName] || {}; - const avail = obj?.avail; - const frozen = obj?.frozen; - const nodes = Object.keys(objectInstanceStatus[decodedObjectName] || {}); - let globalExpect = null; - for (const node of nodes) { - const monitorKey = `${node}:${decodedObjectName}`; - const monitor = instanceMonitor[monitorKey] || {}; - if (monitor.global_expect && monitor.global_expect !== "none") { - globalExpect = monitor.global_expect; - break; - } + const openActionDialog = useCallback((action, context = null) => { + setPendingAction({action, ...(context ? context : {})}); + setSeats(1); + setGreetTimeout("5s"); + if (action === "console") { + setConsoleDialogOpen(true); + } else if (action === "freeze") { + setCheckboxes(DEFAULT_CHECKBOXES); + setConfirmDialogOpen(true); + } else if (action === "stop") { + setStopCheckbox(DEFAULT_STOP_CHECKBOX); + setStopDialogOpen(true); + } else if (action === "unprovision") { + setUnprovisionCheckboxes(DEFAULT_UNPROVISION_CHECKBOXES); + setUnprovisionDialogOpen(true); + } else if (action === "purge") { + setPurgeCheckboxes(DEFAULT_PURGE_CHECKBOXES); + setPurgeDialogOpen(true); + } else { + setSimpleDialogOpen(true); } - return {avail, frozen, globalExpect}; - }, [objectStatus, objectInstanceStatus, instanceMonitor, decodedObjectName]); - - const handleNodesActionsOpen = useCallback((e) => setNodesActionsAnchor(e.currentTarget), []); - const handleNodesActionsClose = useCallback(() => setNodesActionsAnchor(null), []); - const handleBatchNodeActionClick = useCallback((action) => { - openActionDialog(action, {batch: "nodes"}); - handleNodesActionsClose(); - }, [openActionDialog, handleNodesActionsClose]); - - const handleIndividualNodeActionClick = useCallback((action) => { - if (!currentNode) return; - openActionDialog(action, {node: currentNode}); - setIndividualNodeMenuAnchor(null); - }, [openActionDialog, currentNode]); - - const handleObjectActionClick = useCallback((action) => { - openActionDialog(action); - setObjectMenuAnchor(null); - }, [openActionDialog]); + }, []); const handleDialogConfirm = useCallback(() => { if (!pendingAction || !pendingAction.action) { @@ -428,6 +415,22 @@ const ObjectDetail = () => { setSelectedNodes(prev => prev.includes(node) ? prev.filter(n => n !== node) : [...prev, node]); }, []); + const handleBatchNodeActionClick = (action) => { + openActionDialog(action, {batch: "nodes"}); + setActionsMenuAnchor(null); + }; + + const handleIndividualNodeActionClick = (action) => { + if (!currentNode) return; + openActionDialog(action, {node: currentNode}); + setIndividualNodeMenuAnchor(null); + }; + + const handleObjectActionClick = (action) => { + openActionDialog(action); + setObjectMenuAnchor(null); + }; + const handleViewInstance = useCallback((node) => { navigate(`/nodes/${node}/objects/${encodeURIComponent(decodedObjectName)}`); }, [decodedObjectName, navigate]); @@ -444,6 +447,22 @@ const ObjectDetail = () => { setSelectedInstanceForLogs(null); }, []); + const getColor = useCallback((status) => { + if (status === "up" || status === true) return green[500]; + if (status === "down" || status === false) return red[500]; + if (status === "warn") return orange[500]; + return grey[500]; + }, []); + + const getNodeState = useCallback((node) => { + const nodeStatus = objectInstanceStatus?.[node] || {}; + const monitor = instanceMonitor[`${node}:${decodedObjectName}`] || {}; + const avail = nodeStatus.avail || ''; + const frozen = nodeStatus.frozen_at && nodeStatus.frozen_at !== "0001-01-01T00:00:00Z" ? "frozen" : "unfrozen"; + const state = monitor.state !== "idle" ? monitor.state : null; + return {avail, frozen, state}; + }, [objectInstanceStatus, instanceMonitor, decodedObjectName]); + const startResizing = useCallback((e) => { e.preventDefault(); const isTouch = e.type === 'touchstart'; @@ -474,61 +493,28 @@ const ObjectDetail = () => { document.body.style.cursor = "ew-resize"; }, [drawerWidth, minDrawerWidth, maxDrawerWidth]); - // SSE + initial data fetch - useEffect(() => { - const token = localStorage.getItem("authToken"); - if (token) { - const filters = objectEventTypes.map(type => { - if (["CONNECTION_OPENED", "CONNECTION_ERROR", "RECONNECTION_ATTEMPT", "MAX_RECONNECTIONS_REACHED", "CONNECTION_CLOSED"].includes(type)) { - return type; - } else { - return `${type},path=${decodedObjectName}`; - } - }); - startEventReception(token, filters); - } - const hasData = objectInstanceStatus[decodedObjectName] && Object.keys(objectInstanceStatus[decodedObjectName]).length > 0; - if (!hasData) fetchInitialObjectData(); - return () => closeEventSource(); - }, [decodedObjectName, objectEventTypes, fetchInitialObjectData, objectInstanceStatus]); - - // Initial config loading effect (restored with immediate false if data exists) - const objectData = objectInstanceStatus?.[decodedObjectName]; useEffect(() => { const loadInitialConfig = async () => { - if (objectData) { - // If we already have instance data, we can stop the loading indicator immediately - setInitialLoading(false); - const nodes = Object.keys(objectInstanceStatus[decodedObjectName] || {}); + if (objectInstanceStatus && Object.keys(objectInstanceStatus).length > 0) { + const nodes = Object.keys(objectInstanceStatus); const initialNode = nodes.find((node) => { - return objectData[node]?.encap && Object.values(objectData[node].encap).some( + const nodeData = objectInstanceStatus[node]; + return nodeData?.encap && Object.values(nodeData.encap).some( (container) => container.resources && Object.keys(container.resources).length > 0 ); }) || nodes[0]; if (initialNode) { - try { - await fetchConfig(initialNode); - } catch (err) { - setConfigError("Failed to load initial configuration."); - } - } else { - setConfigError("No nodes available to fetch configuration."); + await fetchConfig(initialNode); } - } else { - setConfigError("No object data available."); - setInitialLoading(false); } }; - loadInitialConfig().catch(() => { - }); - }, [decodedObjectName, objectData, objectInstanceStatus, fetchConfig]); + loadInitialConfig(); + }, [objectInstanceStatus, fetchConfig]); - // Config updates effect useEffect(() => { - if (!isMounted.current) return; - let subscription; + let unsubscribe = null; try { - subscription = useEventStore.subscribe( + const maybeUnsubscribe = useEventStore.subscribe( (state) => state.configUpdates, async (updates) => { if (!isMounted.current || isProcessingConfigUpdate.current) return; @@ -552,24 +538,25 @@ const ObjectDetail = () => { }, {fireImmediately: false} ); + if (typeof maybeUnsubscribe === 'function') { + unsubscribe = maybeUnsubscribe; + } else { + logger.warn("[ObjectDetail] Subscription is not a function:", maybeUnsubscribe); + } } catch (err) { logger.warn("[ObjectDetail] Failed to subscribe to configUpdates:", err); } return () => { - if (typeof subscription === 'function') subscription(); - else if (subscription) logger.warn("[ObjectDetail] Subscription is not a function:", subscription); + if (typeof unsubscribe === 'function') unsubscribe(); }; }, [decodedObjectName, clearConfigUpdate, fetchConfig, openSnackbar]); - // Instance config effect useEffect(() => { - if (!isMounted.current) return; - let subscription; + let unsubscribe = null; try { - subscription = useEventStore.subscribe( + const maybeUnsubscribe = useEventStore.subscribe( (state) => state.instanceConfig, (newConfig) => { - if (!isMounted.current) return; const config = newConfig[decodedObjectName]; if (config && configNode) { setConfigDialogOpen(true); @@ -577,30 +564,20 @@ const ObjectDetail = () => { } } ); + if (typeof maybeUnsubscribe === 'function') { + unsubscribe = maybeUnsubscribe; + } else { + logger.warn("[ObjectDetail] Subscription is not a function:", maybeUnsubscribe); + } } catch (err) { logger.warn("[ObjectDetail] Failed to subscribe to instanceConfig:", err); - return; } return () => { - if (typeof subscription === 'function') subscription(); - else if (subscription) logger.warn("[ObjectDetail] Subscription is not a function:", subscription); + if (typeof unsubscribe === 'function') unsubscribe(); }; }, [decodedObjectName, configNode, openSnackbar]); - const memoizedObjectData = useMemo(() => { - const enhanced = {}; - const data = objectInstanceStatus?.[decodedObjectName] || {}; - Object.keys(data).forEach(node => { - enhanced[node] = { - ...data[node], - instanceConfig: instanceConfig?.[decodedObjectName]?.[node] || {resources: {}}, - instanceMonitor: instanceMonitor[`${node}:${decodedObjectName}`] || {resources: {}}, - }; - }); - return enhanced; - }, [objectInstanceStatus, instanceConfig, instanceMonitor, decodedObjectName]); - - const memoizedNodes = useMemo(() => Object.keys(memoizedObjectData), [memoizedObjectData]); + const showKeys = ["cfg", "sec"].includes(kind); if (initialLoading) { return ( @@ -611,13 +588,12 @@ const ObjectDetail = () => { ); } - const showKeys = ["cfg", "sec"].includes(kind); - if (Object.keys(memoizedObjectData).length === 0) { + if (nodesList.length === 0 && !initialDataError) { return ( {decodedObjectName} - {initialDataError ? `Error loading object: ${initialDataError}` : "No information available for object."} + No information available for object. {showKeys && } { }}> { objectData} getColor={getColor} - objectMenuAnchorRef={objectMenuAnchorRef} /> @@ -741,7 +717,8 @@ const ObjectDetail = () => { helperText="Number of simultaneous users allowed in the console"/> setGreetTimeout(e.target.value)} + value={greetTimeout} + onChange={(e) => setGreetTimeout(e.target.value)} helperText="Time to wait for console connection (e.g., 5s, 10s)"/> @@ -751,15 +728,8 @@ const ObjectDetail = () => { setConsoleUrlDialogOpen(false)} maxWidth="sm" - fullWidth sx={{ - '& .MuiDialog-paper': { - minWidth: {xs: '90vw', sm: '500px'}, - maxWidth: '90vw', - mx: {xs: 2, sm: 0}, - my: {xs: 2, sm: 0} - } - }}> - Console URL + fullWidth> + Console URL { }}> - + + + {showKeys && } @@ -802,11 +772,31 @@ const ObjectDetail = () => { {!["sec", "cfg", "usr"].includes(kind) && ( <> - + - {memoizedNodes.map(node => ( + + setActionsMenuAnchor(null)} + anchorOrigin={{vertical: 'bottom', horizontal: 'right'}} + transformOrigin={{vertical: 'top', horizontal: 'right'}} + > + {INSTANCE_ACTIONS.map(({name, icon}) => ( + handleBatchNodeActionClick(name)}> + {icon} + {name.charAt(0).toUpperCase() + name.slice(1)} + + ))} + + + {nodesList.map(node => ( { parseProvisionedState={parseProvisionedState} setPendingAction={setPendingAction} setSimpleDialogOpen={setSimpleDialogOpen} - individualNodeMenuAnchorRef={individualNodeMenuAnchorRef} namespace={namespace} kind={kind} instanceName={name} @@ -830,46 +819,21 @@ const ObjectDetail = () => { onViewInstance={handleViewInstance} /> ))} - - - - {INSTANCE_ACTIONS.map(({name, icon}) => ( - handleBatchNodeActionClick(name)} - role="menuitem" - aria-label={name.charAt(0).toUpperCase() + name.slice(1)}> - {icon} - {name.charAt(0).toUpperCase() + name.slice(1)} - - ))} - - - - - setIndividualNodeMenuAnchor(null)}> - - {INSTANCE_ACTIONS.map(({name, icon}) => ( - handleIndividualNodeActionClick(name)} - role="menuitem" - aria-label={name.charAt(0).toUpperCase() + name.slice(1)}> - {icon} - {name.charAt(0).toUpperCase() + name.slice(1)} - - ))} - - - + + setIndividualNodeMenuAnchor(null)} + anchorOrigin={{vertical: 'bottom', horizontal: 'right'}} + transformOrigin={{vertical: 'top', horizontal: 'right'}} + > + {INSTANCE_ACTIONS.map(({name, icon}) => ( + handleIndividualNodeActionClick(name)}> + {icon} + {name.charAt(0).toUpperCase() + name.slice(1)} + + ))} + )} @@ -886,7 +850,7 @@ const ObjectDetail = () => { {logsDrawerOpen && selectedNodeForLogs && ( Date: Mon, 1 Jun 2026 09:38:29 +0200 Subject: [PATCH 3/6] fix(NodeCard): show "Not Provisioned" icon only for explicit false values Previously, the icon appeared for any falsy value including "n/a", which caused incorrect warnings for objects like clusters where provisioned is legitimately "n/a". Now the icon is shown only when provisioned is exactly false or "false". Updated NodeCard logic and adjusted tests accordingly: - Removed dependency on parseProvisionedState - Added tests for false, "false", true, and "n/a" cases --- src/components/NodeCard.jsx | 7 ++-- src/components/tests/NodeCard.test.jsx | 46 +++++++++++++++++++++----- 2 files changed, 40 insertions(+), 13 deletions(-) diff --git a/src/components/NodeCard.jsx b/src/components/NodeCard.jsx index 4ba398e..362378f 100644 --- a/src/components/NodeCard.jsx +++ b/src/components/NodeCard.jsx @@ -35,7 +35,6 @@ const NodeCard = ({ individualNodeMenuAnchorRef = null, getColor = () => grey[500], getNodeState = () => ({avail: "unknown", frozen: "unfrozen", state: null}), - parseProvisionedState = (state) => !!state, instanceName, onOpenLogs = () => logger.warn("onOpenLogs not provided"), onViewInstance, @@ -49,7 +48,7 @@ const NodeCard = ({ } const {avail, frozen, state} = getNodeState(node); - const isInstanceNotProvisioned = nodeData?.provisioned !== undefined ? !parseProvisionedState(nodeData.provisioned) : false; + const isInstanceNotProvisioned = nodeData?.provisioned === false || nodeData?.provisioned === "false"; const handleCardClick = (e) => { if (e.target.closest('button') || e.target.closest('input') || e.target.closest('.no-click')) { @@ -112,13 +111,12 @@ const NodeCard = ({ - {/* Bloc central : freeze / not provisioned / state, juste avant logs+status */} @@ -138,7 +136,6 @@ const NodeCard = ({ {state && {state}} - {/* Bloc droite : logs + rond de status + bouton d’actions, fixes */} { expect(screen.getByTestId('AcUnitIcon')).toBeInTheDocument(); }); - test('shows not provisioned icon when instance is not provisioned', () => { + test('shows not provisioned icon when instance is not provisioned (false)', () => { const nodeData = {provisioned: false}; - const parseProvisionedState = jest.fn(() => false); render( - + ); expect(screen.getByTestId('PriorityHighIcon')).toBeInTheDocument(); - expect(parseProvisionedState).toHaveBeenCalledWith(false); + }); + + test('shows not provisioned icon when instance is not provisioned ("false")', () => { + const nodeData = {provisioned: "false"}; + + render( + + + + ); + + expect(screen.getByTestId('PriorityHighIcon')).toBeInTheDocument(); + }); + + test('does NOT show not provisioned icon when provisioned is "n/a"', () => { + const nodeData = {provisioned: "n/a"}; + + render( + + + + ); + + expect(screen.queryByTestId('PriorityHighIcon')).not.toBeInTheDocument(); + }); + + test('does NOT show not provisioned icon when provisioned is true', () => { + const nodeData = {provisioned: true}; + + render( + + + + ); + + expect(screen.queryByTestId('PriorityHighIcon')).not.toBeInTheDocument(); }); test('displays node state when available', () => { From 1eadb2ff52062d9ac7c6976f6dcee69835cf178a Mon Sep 17 00:00:00 2001 From: Paul Jouvanceau Date: Mon, 1 Jun 2026 10:43:07 +0200 Subject: [PATCH 4/6] fix(ObjectDetail, NodeCard): improve data reactivity and action menus - Replace Popper with Menu for batch and individual node actions to ensure full display - Use fine-grained selectors from useEventStore for reactive data updates (objectStatus, objectInstanceStatus, instanceMonitor, instanceConfig) - Derive memoized state (objectData, memoizedObjectData, nodesList) that automatically re-renders on store changes - Add error handling for useEventStore.subscribe: wrap subscriptions in try/catch and validate unsubscribe return type, logging warnings on failures - Update NodeCard to accept setIndividualNodeMenuAnchor prop for compatibility with tests and parent - Remove French comments Fixes stale data after SSE events and incomplete action menu display. --- src/components/NodeCard.jsx | 65 ++--- src/components/ObjectDetails.jsx | 426 ++++++++++++++----------------- 2 files changed, 212 insertions(+), 279 deletions(-) diff --git a/src/components/NodeCard.jsx b/src/components/NodeCard.jsx index 362378f..2b24ce4 100644 --- a/src/components/NodeCard.jsx +++ b/src/components/NodeCard.jsx @@ -1,4 +1,4 @@ -import React, {forwardRef, useState} from "react"; +import React, {useState} from "react"; import { Box, Typography, @@ -14,16 +14,6 @@ import ArticleIcon from "@mui/icons-material/Article"; import {grey, blue, red} from "@mui/material/colors"; import logger from '../utils/logger.js'; -const BoxWithRef = forwardRef((props, ref) => ( - -)); -BoxWithRef.displayName = 'BoxWithRef'; - -const IconButtonWithRef = forwardRef((props, ref) => ( - -)); -IconButtonWithRef.displayName = 'IconButtonWithRef'; - const NodeCard = ({ node, nodeData = {}, @@ -32,7 +22,6 @@ const NodeCard = ({ actionInProgress = false, setIndividualNodeMenuAnchor = () => logger.warn("setIndividualNodeMenuAnchor not provided"), setCurrentNode = () => logger.warn("setCurrentNode not provided"), - individualNodeMenuAnchorRef = null, getColor = () => grey[500], getNodeState = () => ({avail: "unknown", frozen: "unfrozen", state: null}), instanceName, @@ -59,8 +48,14 @@ const NodeCard = ({ } }; + const handleMenuOpen = (e) => { + e.stopPropagation(); + setCurrentNode(node); + setIndividualNodeMenuAnchor(e.currentTarget); + }; + return ( - - {/* Bloc gauche : checkbox + nom */} e.stopPropagation()} className="no-click"> (view resources) @@ -111,15 +101,7 @@ const NodeCard = ({ - + {frozen === "frozen" && ( @@ -136,16 +118,9 @@ const NodeCard = ({ {state && {state}} - + - { e.stopPropagation(); onOpenLogs(node, resolvedInstanceName); @@ -154,7 +129,7 @@ const NodeCard = ({ aria-label={`View logs for instance ${resolvedInstanceName || node}`} > - + @@ -166,24 +141,18 @@ const NodeCard = ({ /> - { - e.stopPropagation(); - e.persist(); - setCurrentNode(node); - setIndividualNodeMenuAnchor(e.currentTarget); - }} + - + - + ); }; diff --git a/src/components/ObjectDetails.jsx b/src/components/ObjectDetails.jsx index 27b3432..33eb1ab 100644 --- a/src/components/ObjectDetails.jsx +++ b/src/components/ObjectDetails.jsx @@ -5,23 +5,21 @@ import { Box, Button, CircularProgress, - ClickAwayListener, Dialog, DialogActions, DialogContent, DialogTitle, - ListItemIcon, - ListItemText, + Menu, MenuItem, - Paper, - Popper, - Snackbar, Typography, Drawer, IconButton, TextField, useTheme, Grid, + Snackbar, + ListItemIcon, + ListItemText, } from "@mui/material"; import CloseIcon from "@mui/icons-material/Close"; import {green, grey, orange, red} from "@mui/material/colors"; @@ -67,29 +65,25 @@ const ObjectDetail = () => { const decodedObjectName = decodeURIComponent(objectName); const {namespace, kind, name} = parseObjectPath(decodedObjectName); const navigate = useNavigate(); + const theme = useTheme(); - const objectStatus = useEventStore((s) => s.objectStatus); - const objectInstanceStatus = useEventStore((s) => s.objectInstanceStatus); + const objectStatus = useEventStore((s) => s.objectStatus[decodedObjectName]); + const objectInstanceStatus = useEventStore((s) => s.objectInstanceStatus[decodedObjectName]); const instanceMonitor = useEventStore((s) => s.instanceMonitor); - const instanceConfig = useEventStore((s) => s.instanceConfig); + const instanceConfig = useEventStore((s) => s.instanceConfig[decodedObjectName]); const clearConfigUpdate = useEventStore((s) => s.clearConfigUpdate); - const theme = useTheme(); - const [configLoading, setConfigLoading] = useState(false); const [configError, setConfigError] = useState(null); const [configNode, setConfigNode] = useState(null); const [configDialogOpen, setConfigDialogOpen] = useState(false); const [selectedNodes, setSelectedNodes] = useState([]); - const [nodesActionsAnchor, setNodesActionsAnchor] = useState(null); - const nodesActionsAnchorRef = useRef(null); + const [actionsMenuAnchor, setActionsMenuAnchor] = useState(null); const [individualNodeMenuAnchor, setIndividualNodeMenuAnchor] = useState(null); - const individualNodeMenuAnchorRef = useRef(null); const [currentNode, setCurrentNode] = useState(null); const [objectMenuAnchor, setObjectMenuAnchor] = useState(null); - const objectMenuAnchorRef = useRef(null); const [pendingAction, setPendingAction] = useState(null); const [actionInProgress, setActionInProgress] = useState(false); @@ -137,13 +131,37 @@ const ObjectDetail = () => { const isProcessingConfigUpdate = useRef(false); const isMounted = useRef(true); - useEffect(() => { - isMounted.current = true; - return () => { - isMounted.current = false; - closeEventSource(); - }; - }, [decodedObjectName]); + const objectData = useMemo(() => { + const avail = objectStatus?.avail || "n/a"; + const frozen = objectStatus?.frozen === "frozen" ? "frozen" : "unfrozen"; + let globalExpect = null; + if (objectInstanceStatus) { + for (const node of Object.keys(objectInstanceStatus)) { + const monitorKey = `${node}:${decodedObjectName}`; + const monitor = instanceMonitor[monitorKey] || {}; + if (monitor.global_expect && monitor.global_expect !== "none") { + globalExpect = monitor.global_expect; + break; + } + } + } + return {avail, frozen, globalExpect}; + }, [objectStatus, objectInstanceStatus, instanceMonitor, decodedObjectName]); + + const memoizedObjectData = useMemo(() => { + if (!objectInstanceStatus) return {}; + const enhanced = {}; + Object.keys(objectInstanceStatus).forEach(node => { + enhanced[node] = { + ...objectInstanceStatus[node], + instanceConfig: instanceConfig?.[node] || {resources: {}}, + instanceMonitor: instanceMonitor[`${node}:${decodedObjectName}`] || {resources: {}}, + }; + }); + return enhanced; + }, [objectInstanceStatus, instanceConfig, instanceMonitor, decodedObjectName]); + + const nodesList = useMemo(() => Object.keys(memoizedObjectData), [memoizedObjectData]); const fetchInitialObjectData = useCallback(async () => { setInitialDataError(null); @@ -177,36 +195,34 @@ const ObjectDetail = () => { } }, [decodedObjectName]); - const openSnackbar = useCallback((msg, sev = "success") => { - setSnackbar({open: true, message: msg, severity: sev}); - }, []); - - const closeSnackbar = useCallback(() => { - setSnackbar((s) => ({...s, open: false})); - }, []); + useEffect(() => { + const token = localStorage.getItem("authToken"); + if (token) { + const filters = objectEventTypes.map(type => { + if (["CONNECTION_OPENED", "CONNECTION_ERROR", "RECONNECTION_ATTEMPT", "MAX_RECONNECTIONS_REACHED", "CONNECTION_CLOSED"].includes(type)) { + return type; + } else { + return `${type},path=${decodedObjectName}`; + } + }); + startEventReception(token, filters); + } + return () => closeEventSource(); + }, [decodedObjectName, objectEventTypes]); - const openActionDialog = useCallback((action, context = null) => { - setPendingAction({action, ...(context ? context : {})}); - setSeats(1); - setGreetTimeout("5s"); - if (action === "console") { - setConsoleDialogOpen(true); - } else if (action === "freeze") { - setCheckboxes(DEFAULT_CHECKBOXES); - setConfirmDialogOpen(true); - } else if (action === "stop") { - setStopCheckbox(DEFAULT_STOP_CHECKBOX); - setStopDialogOpen(true); - } else if (action === "unprovision") { - setUnprovisionCheckboxes(DEFAULT_UNPROVISION_CHECKBOXES); - setUnprovisionDialogOpen(true); - } else if (action === "purge") { - setPurgeCheckboxes(DEFAULT_PURGE_CHECKBOXES); - setPurgeDialogOpen(true); + useEffect(() => { + const hasData = objectInstanceStatus && Object.keys(objectInstanceStatus).length > 0; + if (!hasData) { + fetchInitialObjectData().finally(() => setInitialLoading(false)); } else { - setSimpleDialogOpen(true); + setInitialLoading(false); } + }, [objectInstanceStatus, fetchInitialObjectData]); + + const openSnackbar = useCallback((msg, sev = "success") => { + setSnackbar({open: true, message: msg, severity: sev}); }, []); + const closeSnackbar = useCallback(() => setSnackbar((s) => ({...s, open: false})), []); const postActionUrl = useCallback(({node, objectName, action}) => { const {namespace, kind, name} = parseObjectPath(objectName); @@ -329,57 +345,28 @@ const ObjectDetail = () => { } }, [decodedObjectName, configLoading]); - const getColor = useCallback((status) => { - if (status === "up" || status === true) return green[500]; - if (status === "down" || status === false) return red[500]; - if (status === "warn") return orange[500]; - return grey[500]; - }, []); - - const getNodeState = useCallback((node) => { - const instanceStatus = objectInstanceStatus[decodedObjectName] || {}; - const monitorKey = `${node}:${decodedObjectName}`; - const monitor = instanceMonitor[monitorKey] || {}; - const avail = instanceStatus[node]?.avail || ''; - const frozen = instanceStatus[node]?.frozen_at && instanceStatus[node]?.frozen_at !== "0001-01-01T00:00:00Z" ? "frozen" : "unfrozen"; - const state = monitor.state !== "idle" ? monitor.state : null; - return {avail, frozen, state}; - }, [objectInstanceStatus, instanceMonitor, decodedObjectName]); - - const getObjectStatus = useCallback(() => { - const obj = objectStatus[decodedObjectName] || {}; - const avail = obj?.avail; - const frozen = obj?.frozen; - const nodes = Object.keys(objectInstanceStatus[decodedObjectName] || {}); - let globalExpect = null; - for (const node of nodes) { - const monitorKey = `${node}:${decodedObjectName}`; - const monitor = instanceMonitor[monitorKey] || {}; - if (monitor.global_expect && monitor.global_expect !== "none") { - globalExpect = monitor.global_expect; - break; - } + const openActionDialog = useCallback((action, context = null) => { + setPendingAction({action, ...(context ? context : {})}); + setSeats(1); + setGreetTimeout("5s"); + if (action === "console") { + setConsoleDialogOpen(true); + } else if (action === "freeze") { + setCheckboxes(DEFAULT_CHECKBOXES); + setConfirmDialogOpen(true); + } else if (action === "stop") { + setStopCheckbox(DEFAULT_STOP_CHECKBOX); + setStopDialogOpen(true); + } else if (action === "unprovision") { + setUnprovisionCheckboxes(DEFAULT_UNPROVISION_CHECKBOXES); + setUnprovisionDialogOpen(true); + } else if (action === "purge") { + setPurgeCheckboxes(DEFAULT_PURGE_CHECKBOXES); + setPurgeDialogOpen(true); + } else { + setSimpleDialogOpen(true); } - return {avail, frozen, globalExpect}; - }, [objectStatus, objectInstanceStatus, instanceMonitor, decodedObjectName]); - - const handleNodesActionsOpen = useCallback((e) => setNodesActionsAnchor(e.currentTarget), []); - const handleNodesActionsClose = useCallback(() => setNodesActionsAnchor(null), []); - const handleBatchNodeActionClick = useCallback((action) => { - openActionDialog(action, {batch: "nodes"}); - handleNodesActionsClose(); - }, [openActionDialog, handleNodesActionsClose]); - - const handleIndividualNodeActionClick = useCallback((action) => { - if (!currentNode) return; - openActionDialog(action, {node: currentNode}); - setIndividualNodeMenuAnchor(null); - }, [openActionDialog, currentNode]); - - const handleObjectActionClick = useCallback((action) => { - openActionDialog(action); - setObjectMenuAnchor(null); - }, [openActionDialog]); + }, []); const handleDialogConfirm = useCallback(() => { if (!pendingAction || !pendingAction.action) { @@ -428,6 +415,22 @@ const ObjectDetail = () => { setSelectedNodes(prev => prev.includes(node) ? prev.filter(n => n !== node) : [...prev, node]); }, []); + const handleBatchNodeActionClick = (action) => { + openActionDialog(action, {batch: "nodes"}); + setActionsMenuAnchor(null); + }; + + const handleIndividualNodeActionClick = (action) => { + if (!currentNode) return; + openActionDialog(action, {node: currentNode}); + setIndividualNodeMenuAnchor(null); + }; + + const handleObjectActionClick = (action) => { + openActionDialog(action); + setObjectMenuAnchor(null); + }; + const handleViewInstance = useCallback((node) => { navigate(`/nodes/${node}/objects/${encodeURIComponent(decodedObjectName)}`); }, [decodedObjectName, navigate]); @@ -444,6 +447,22 @@ const ObjectDetail = () => { setSelectedInstanceForLogs(null); }, []); + const getColor = useCallback((status) => { + if (status === "up" || status === true) return green[500]; + if (status === "down" || status === false) return red[500]; + if (status === "warn") return orange[500]; + return grey[500]; + }, []); + + const getNodeState = useCallback((node) => { + const nodeStatus = objectInstanceStatus?.[node] || {}; + const monitor = instanceMonitor[`${node}:${decodedObjectName}`] || {}; + const avail = nodeStatus.avail || ''; + const frozen = nodeStatus.frozen_at && nodeStatus.frozen_at !== "0001-01-01T00:00:00Z" ? "frozen" : "unfrozen"; + const state = monitor.state !== "idle" ? monitor.state : null; + return {avail, frozen, state}; + }, [objectInstanceStatus, instanceMonitor, decodedObjectName]); + const startResizing = useCallback((e) => { e.preventDefault(); const isTouch = e.type === 'touchstart'; @@ -474,61 +493,28 @@ const ObjectDetail = () => { document.body.style.cursor = "ew-resize"; }, [drawerWidth, minDrawerWidth, maxDrawerWidth]); - // SSE + initial data fetch - useEffect(() => { - const token = localStorage.getItem("authToken"); - if (token) { - const filters = objectEventTypes.map(type => { - if (["CONNECTION_OPENED", "CONNECTION_ERROR", "RECONNECTION_ATTEMPT", "MAX_RECONNECTIONS_REACHED", "CONNECTION_CLOSED"].includes(type)) { - return type; - } else { - return `${type},path=${decodedObjectName}`; - } - }); - startEventReception(token, filters); - } - const hasData = objectInstanceStatus[decodedObjectName] && Object.keys(objectInstanceStatus[decodedObjectName]).length > 0; - if (!hasData) fetchInitialObjectData(); - return () => closeEventSource(); - }, [decodedObjectName, objectEventTypes, fetchInitialObjectData, objectInstanceStatus]); - - // Initial config loading effect (restored with immediate false if data exists) - const objectData = objectInstanceStatus?.[decodedObjectName]; useEffect(() => { const loadInitialConfig = async () => { - if (objectData) { - // If we already have instance data, we can stop the loading indicator immediately - setInitialLoading(false); - const nodes = Object.keys(objectInstanceStatus[decodedObjectName] || {}); + if (objectInstanceStatus && Object.keys(objectInstanceStatus).length > 0) { + const nodes = Object.keys(objectInstanceStatus); const initialNode = nodes.find((node) => { - return objectData[node]?.encap && Object.values(objectData[node].encap).some( + const nodeData = objectInstanceStatus[node]; + return nodeData?.encap && Object.values(nodeData.encap).some( (container) => container.resources && Object.keys(container.resources).length > 0 ); }) || nodes[0]; if (initialNode) { - try { - await fetchConfig(initialNode); - } catch (err) { - setConfigError("Failed to load initial configuration."); - } - } else { - setConfigError("No nodes available to fetch configuration."); + await fetchConfig(initialNode); } - } else { - setConfigError("No object data available."); - setInitialLoading(false); } }; - loadInitialConfig().catch(() => { - }); - }, [decodedObjectName, objectData, objectInstanceStatus, fetchConfig]); + loadInitialConfig(); + }, [objectInstanceStatus, fetchConfig]); - // Config updates effect useEffect(() => { - if (!isMounted.current) return; - let subscription; + let unsubscribe = null; try { - subscription = useEventStore.subscribe( + const maybeUnsubscribe = useEventStore.subscribe( (state) => state.configUpdates, async (updates) => { if (!isMounted.current || isProcessingConfigUpdate.current) return; @@ -552,24 +538,25 @@ const ObjectDetail = () => { }, {fireImmediately: false} ); + if (typeof maybeUnsubscribe === 'function') { + unsubscribe = maybeUnsubscribe; + } else { + logger.warn("[ObjectDetail] Subscription is not a function:", maybeUnsubscribe); + } } catch (err) { logger.warn("[ObjectDetail] Failed to subscribe to configUpdates:", err); } return () => { - if (typeof subscription === 'function') subscription(); - else if (subscription) logger.warn("[ObjectDetail] Subscription is not a function:", subscription); + if (typeof unsubscribe === 'function') unsubscribe(); }; }, [decodedObjectName, clearConfigUpdate, fetchConfig, openSnackbar]); - // Instance config effect useEffect(() => { - if (!isMounted.current) return; - let subscription; + let unsubscribe = null; try { - subscription = useEventStore.subscribe( + const maybeUnsubscribe = useEventStore.subscribe( (state) => state.instanceConfig, (newConfig) => { - if (!isMounted.current) return; const config = newConfig[decodedObjectName]; if (config && configNode) { setConfigDialogOpen(true); @@ -577,30 +564,20 @@ const ObjectDetail = () => { } } ); + if (typeof maybeUnsubscribe === 'function') { + unsubscribe = maybeUnsubscribe; + } else { + logger.warn("[ObjectDetail] Subscription is not a function:", maybeUnsubscribe); + } } catch (err) { logger.warn("[ObjectDetail] Failed to subscribe to instanceConfig:", err); - return; } return () => { - if (typeof subscription === 'function') subscription(); - else if (subscription) logger.warn("[ObjectDetail] Subscription is not a function:", subscription); + if (typeof unsubscribe === 'function') unsubscribe(); }; }, [decodedObjectName, configNode, openSnackbar]); - const memoizedObjectData = useMemo(() => { - const enhanced = {}; - const data = objectInstanceStatus?.[decodedObjectName] || {}; - Object.keys(data).forEach(node => { - enhanced[node] = { - ...data[node], - instanceConfig: instanceConfig?.[decodedObjectName]?.[node] || {resources: {}}, - instanceMonitor: instanceMonitor[`${node}:${decodedObjectName}`] || {resources: {}}, - }; - }); - return enhanced; - }, [objectInstanceStatus, instanceConfig, instanceMonitor, decodedObjectName]); - - const memoizedNodes = useMemo(() => Object.keys(memoizedObjectData), [memoizedObjectData]); + const showKeys = ["cfg", "sec"].includes(kind); if (initialLoading) { return ( @@ -611,13 +588,12 @@ const ObjectDetail = () => { ); } - const showKeys = ["cfg", "sec"].includes(kind); - if (Object.keys(memoizedObjectData).length === 0) { + if (nodesList.length === 0 && !initialDataError) { return ( {decodedObjectName} - {initialDataError ? `Error loading object: ${initialDataError}` : "No information available for object."} + No information available for object. {showKeys && } { }}> { objectData} getColor={getColor} - objectMenuAnchorRef={objectMenuAnchorRef} /> @@ -741,7 +717,8 @@ const ObjectDetail = () => { helperText="Number of simultaneous users allowed in the console"/> setGreetTimeout(e.target.value)} + value={greetTimeout} + onChange={(e) => setGreetTimeout(e.target.value)} helperText="Time to wait for console connection (e.g., 5s, 10s)"/> @@ -751,15 +728,8 @@ const ObjectDetail = () => { setConsoleUrlDialogOpen(false)} maxWidth="sm" - fullWidth sx={{ - '& .MuiDialog-paper': { - minWidth: {xs: '90vw', sm: '500px'}, - maxWidth: '90vw', - mx: {xs: 2, sm: 0}, - my: {xs: 2, sm: 0} - } - }}> - Console URL + fullWidth> + Console URL { }}> - + + + {showKeys && } @@ -802,11 +772,31 @@ const ObjectDetail = () => { {!["sec", "cfg", "usr"].includes(kind) && ( <> - + - {memoizedNodes.map(node => ( + + setActionsMenuAnchor(null)} + anchorOrigin={{vertical: 'bottom', horizontal: 'right'}} + transformOrigin={{vertical: 'top', horizontal: 'right'}} + > + {INSTANCE_ACTIONS.map(({name, icon}) => ( + handleBatchNodeActionClick(name)}> + {icon} + {name.charAt(0).toUpperCase() + name.slice(1)} + + ))} + + + {nodesList.map(node => ( { parseProvisionedState={parseProvisionedState} setPendingAction={setPendingAction} setSimpleDialogOpen={setSimpleDialogOpen} - individualNodeMenuAnchorRef={individualNodeMenuAnchorRef} namespace={namespace} kind={kind} instanceName={name} @@ -830,46 +819,21 @@ const ObjectDetail = () => { onViewInstance={handleViewInstance} /> ))} - - - - {INSTANCE_ACTIONS.map(({name, icon}) => ( - handleBatchNodeActionClick(name)} - role="menuitem" - aria-label={name.charAt(0).toUpperCase() + name.slice(1)}> - {icon} - {name.charAt(0).toUpperCase() + name.slice(1)} - - ))} - - - - - setIndividualNodeMenuAnchor(null)}> - - {INSTANCE_ACTIONS.map(({name, icon}) => ( - handleIndividualNodeActionClick(name)} - role="menuitem" - aria-label={name.charAt(0).toUpperCase() + name.slice(1)}> - {icon} - {name.charAt(0).toUpperCase() + name.slice(1)} - - ))} - - - + + setIndividualNodeMenuAnchor(null)} + anchorOrigin={{vertical: 'bottom', horizontal: 'right'}} + transformOrigin={{vertical: 'top', horizontal: 'right'}} + > + {INSTANCE_ACTIONS.map(({name, icon}) => ( + handleIndividualNodeActionClick(name)}> + {icon} + {name.charAt(0).toUpperCase() + name.slice(1)} + + ))} + )} @@ -886,7 +850,7 @@ const ObjectDetail = () => { {logsDrawerOpen && selectedNodeForLogs && ( Date: Mon, 1 Jun 2026 09:38:29 +0200 Subject: [PATCH 5/6] fix(eventSource): replace replay parameter with cache for better state persistence Change the SSE query parameter from `replay=true` to `cache=true` to enable proper caching of historical events on the backend. This prevents data loss after page refresh (F5) and ensures that heartbeat statuses, node lists, and event logs are correctly restored from the server cache instead of relying solely on localStorage. - Updated createQueryString() to use `cache=true` instead of `replay=true` - Maintains all existing event filtering and buffer flushing logic Fixes issues where heartbeats disappeared and nodes showed infinite loading after a hard reload. --- src/eventSourceManager.jsx | 4 ++-- src/tests/eventSourceManager.test.jsx | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/eventSourceManager.jsx b/src/eventSourceManager.jsx index 650c73a..9e96942 100644 --- a/src/eventSourceManager.jsx +++ b/src/eventSourceManager.jsx @@ -119,7 +119,7 @@ const createQueryString = (filters = DEFAULT_FILTERS, objectName = null) => { ? OBJECT_SPECIFIC_FILTERS.map(filter => `${filter},path=${encodeURIComponent(objectName)}`) : validFilters; - return `replay=true&${queryFilters.map(filter => `filter=${encodeURIComponent(filter)}`).join('&')}`; + return `cache=true&${queryFilters.map(filter => `filter=${encodeURIComponent(filter)}`).join('&')}`; }; // Get current token @@ -632,7 +632,7 @@ const handleAuthError = (token, url) => { navigationService.redirectToAuth(); }; -const handleReconnection = (url, _token, filters) => { +const handleReconnection = (url, token, filters) => { if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS && isPageActive) { reconnectAttempts++; diff --git a/src/tests/eventSourceManager.test.jsx b/src/tests/eventSourceManager.test.jsx index 3b3f6a8..10e9cf5 100644 --- a/src/tests/eventSourceManager.test.jsx +++ b/src/tests/eventSourceManager.test.jsx @@ -152,7 +152,7 @@ describe('eventSourceManager', () => { addEventListener: jest.fn(), close: jest.fn(), readyState: 1, // OPEN state - url: URL_NODE_EVENT + '?replay=true&filter=NodeStatusUpdated', + url: URL_NODE_EVENT + '?cache=true&filter=NodeStatusUpdated', }; mockLoggerEventSource = { @@ -161,7 +161,7 @@ describe('eventSourceManager', () => { addEventListener: jest.fn(), close: jest.fn(), readyState: 1, - url: URL_NODE_EVENT + '?replay=true&filter=ObjectStatusUpdated', + url: URL_NODE_EVENT + '?cache=true&filter=ObjectStatusUpdated', }; // Mock EventSourcePolyfill to return our mock @@ -335,7 +335,7 @@ describe('eventSourceManager', () => { test('should configure EventSource without objectName', () => { eventSourceManager.configureEventSource('fake-token'); expect(EventSourcePolyfill).toHaveBeenCalled(); - expect(EventSourcePolyfill.mock.calls[0][0]).toContain('replay=true'); + expect(EventSourcePolyfill.mock.calls[0][0]).toContain('cache=true'); }); test('should create an EventSource with valid token via startEventReception', () => { @@ -492,7 +492,7 @@ describe('eventSourceManager', () => { // Coverage for lines 726-727, 745: updateEventSourceToken with open EventSource test('should restart EventSource when updateEventSourceToken called with open connection', () => { // Create initial EventSource — mockEventSource needs a url for updateEventSourceToken to parse - mockEventSource.url = `${URL_NODE_EVENT}?replay=true&filter=NodeStatusUpdated`; + mockEventSource.url = `${URL_NODE_EVENT}?cache=true&filter=NodeStatusUpdated`; eventSourceManager.createEventSource(URL_NODE_EVENT, 'fake-token'); // Prepare mock for the recreated EventSource @@ -502,7 +502,7 @@ describe('eventSourceManager', () => { addEventListener: jest.fn(), close: jest.fn(), readyState: 1, - url: `${URL_NODE_EVENT}?replay=true&filter=NodeStatusUpdated`, + url: `${URL_NODE_EVENT}?cache=true&filter=NodeStatusUpdated`, }; EventSourcePolyfill.mockImplementation(() => recreatedMock); @@ -1526,7 +1526,7 @@ describe('eventSourceManager', () => { describe('Utility functions and helpers', () => { test('should create query string with default filters', () => { eventSourceManager.configureEventSource('fake-token'); - expect(EventSourcePolyfill).toHaveBeenCalledWith(expect.stringContaining('replay=true'), expect.any(Object)); + expect(EventSourcePolyfill).toHaveBeenCalledWith(expect.stringContaining('cache=true'), expect.any(Object)); }); test('should handle invalid filters in createQueryString', () => { @@ -1552,7 +1552,7 @@ describe('eventSourceManager', () => { test('should create query string without objectName', () => { eventSourceManager.configureEventSource('fake-token'); const url = EventSourcePolyfill.mock.calls[0][0]; - expect(url).toContain('replay=true'); + expect(url).toContain('cache=true'); expect(url).not.toContain('path='); }); @@ -1739,7 +1739,7 @@ describe('eventSourceManager', () => { addEventListener: jest.fn(), close: jest.fn(), readyState: 1, - url: `${URL_NODE_EVENT}?replay=true&filter=ObjectStatusUpdated`, + url: `${URL_NODE_EVENT}?cache=true&filter=ObjectStatusUpdated`, }; EventSourcePolyfill.mockImplementation(() => nextLoggerMock); From 92d57b05f3014de999dd82839275aa5adda86095 Mon Sep 17 00:00:00 2001 From: Paul Jouvanceau Date: Tue, 2 Jun 2026 11:39:05 +0200 Subject: [PATCH 6/6] Rename NodeCard into InstanceCard --- .../{NodeCard.jsx => InstanceCard.jsx} | 4 +- src/components/ObjectDetails.jsx | 4 +- ...odeCard.test.jsx => InstanceCard.test.jsx} | 44 +++++++++---------- 3 files changed, 26 insertions(+), 26 deletions(-) rename src/components/{NodeCard.jsx => InstanceCard.jsx} (99%) rename src/components/tests/{NodeCard.test.jsx => InstanceCard.test.jsx} (89%) diff --git a/src/components/NodeCard.jsx b/src/components/InstanceCard.jsx similarity index 99% rename from src/components/NodeCard.jsx rename to src/components/InstanceCard.jsx index 2b24ce4..f90e3ea 100644 --- a/src/components/NodeCard.jsx +++ b/src/components/InstanceCard.jsx @@ -14,7 +14,7 @@ import ArticleIcon from "@mui/icons-material/Article"; import {grey, blue, red} from "@mui/material/colors"; import logger from '../utils/logger.js'; -const NodeCard = ({ +const InstanceCard = ({ node, nodeData = {}, selectedNodes = [], @@ -156,4 +156,4 @@ const NodeCard = ({ ); }; -export default NodeCard; +export default InstanceCard; diff --git a/src/components/ObjectDetails.jsx b/src/components/ObjectDetails.jsx index 33eb1ab..31db47e 100644 --- a/src/components/ObjectDetails.jsx +++ b/src/components/ObjectDetails.jsx @@ -31,7 +31,7 @@ import ActionDialogManager from "../components/ActionDialogManager"; import HeaderSection from "./HeaderSection"; import ConfigSection from "./ConfigSection"; import KeysSection from "./KeysSection"; -import NodeCard from "./NodeCard"; +import InstanceCard from "./InstanceCard.jsx"; import LogsViewer from "./LogsViewer"; import {INSTANCE_ACTIONS, OBJECT_ACTIONS} from "../constants/actions"; import {parseObjectPath} from "../utils/objectUtils.jsx"; @@ -797,7 +797,7 @@ const ObjectDetail = () => { {nodesList.map(node => ( - () => ); jest.mock('@mui/icons-material/PriorityHigh', () => () => ); -describe('NodeCard Component', () => { +describe('InstanceCard Component', () => { const user = userEvent.setup(); beforeEach(() => { @@ -77,7 +77,7 @@ describe('NodeCard Component', () => { test('renders node name correctly', () => { render( - + ); @@ -92,7 +92,7 @@ describe('NodeCard Component', () => { render( - + ); @@ -104,7 +104,7 @@ describe('NodeCard Component', () => { render( - + ); @@ -120,7 +120,7 @@ describe('NodeCard Component', () => { render( - + ); @@ -135,7 +135,7 @@ describe('NodeCard Component', () => { render( - + ); @@ -150,7 +150,7 @@ describe('NodeCard Component', () => { render( - + ); @@ -175,7 +175,7 @@ describe('NodeCard Component', () => { render( - { render( - { render( - + ); @@ -225,7 +225,7 @@ describe('NodeCard Component', () => { render( - + ); @@ -237,7 +237,7 @@ describe('NodeCard Component', () => { render( - + ); @@ -249,7 +249,7 @@ describe('NodeCard Component', () => { render( - + ); @@ -261,7 +261,7 @@ describe('NodeCard Component', () => { render( - + ); @@ -273,7 +273,7 @@ describe('NodeCard Component', () => { render( - + ); @@ -283,7 +283,7 @@ describe('NodeCard Component', () => { test('handles default functions gracefully', () => { render( - + ); @@ -301,7 +301,7 @@ describe('NodeCard Component', () => { test('does not show view instance button when onViewInstance is not provided', () => { render( - + ); @@ -313,7 +313,7 @@ describe('NodeCard Component', () => { test('handles null node prop gracefully', () => { render( - + ); @@ -323,7 +323,7 @@ describe('NodeCard Component', () => { test('disables actions button when actionInProgress is true', () => { render( - + ); @@ -337,7 +337,7 @@ describe('NodeCard Component', () => { render( - { render( - + );