Repository: kubernetes-sigs/kueue

Issue:
Fix kueue-viz frontend error handling
<!-- Please only use this template for submitting clean up requests -->

**What would you like to be cleaned**:

When backend is not available or returns incorrect data, the frontend only displays a generic error "Websockets error".

**Why is this needed**:
To have a better user experience and for debuggability purposes, it would be great to have a better error handling.


Discussion:
@mimowo: /kind dashboard

Solution patch:
diff --git a/cmd/kueueviz/frontend/src/App.css b/cmd/kueueviz/frontend/src/App.css
index 06605395b..96fc91c36 100644
--- a/cmd/kueueviz/frontend/src/App.css
+++ b/cmd/kueueviz/frontend/src/App.css
@@ -78,3 +78,39 @@ tbody.tr.td {
 .pod-reason-column {
   width: 40%;
 }
+
+.error-message {
+  padding: 20px;
+  margin: 20px;
+  background-color: #ffe6e6;
+  text-align: left;
+  border: 1px solid red;
+  border-radius: 5px;
+  box-shadow: 0 2px 4px rgba(255, 0, 0, 0.1);
+}
+
+.error-details-container {
+  margin-top: 8px;
+  text-align: left;
+  background-color: #ffe6e6;
+  border-radius: 5px;
+  overflow: -moz-scrollbars-vertical; 
+  overflow-y: scroll;
+}
+
+.error-details-content {
+  margin-top: 8px;
+  padding: 16px;
+  background-color: #ffe6e6;
+  border-radius: 4px;
+  border: 1px solid #EEE;
+  max-height: 300px;
+  overflow: auto;
+  text-align: left;
+}
+
+.error-details-text {
+  font-family: monospace;
+  white-space: pre-wrap;
+  text-align: left;
+}
diff --git a/cmd/kueueviz/frontend/src/ClusterQueueDetail.jsx b/cmd/kueueviz/frontend/src/ClusterQueueDetail.jsx
index 3eab49ea5..35ff2abad 100644
--- a/cmd/kueueviz/frontend/src/ClusterQueueDetail.jsx
+++ b/cmd/kueueviz/frontend/src/ClusterQueueDetail.jsx
@@ -20,6 +20,7 @@ import { useParams, Link } from 'react-router-dom';
 import useWebSocket from './useWebSocket';
 import './App.css';
 import FlavorTable from './FlavorTable';
+import ErrorMessage from './ErrorMessage';
 
 const ClusterQueueDetail = () => {
   const { clusterQueueName } = useParams();
@@ -32,7 +33,7 @@ const ClusterQueueDetail = () => {
     if (clusterQueueData) setClusterQueue(clusterQueueData);
   }, [clusterQueueData]);
 
-  if (error) return <Typography color="error">{error}</Typography>;
+  if (error) return <ErrorMessage error={error} />;
 
   if (!clusterQueue) {
     return (
diff --git a/cmd/kueueviz/frontend/src/ClusterQueues.jsx b/cmd/kueueviz/frontend/src/ClusterQueues.jsx
index 23facd9a0..007d5a00a 100644
--- a/cmd/kueueviz/frontend/src/ClusterQueues.jsx
+++ b/cmd/kueueviz/frontend/src/ClusterQueues.jsx
@@ -19,6 +19,7 @@ import { Link } from 'react-router-dom';
 import useWebSocket from './useWebSocket';
 import { Typography, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, CircularProgress } from '@mui/material';
 import './App.css';
+import ErrorMessage from './ErrorMessage';
 
 const ClusterQueues = () => {
   const { data: clusterQueues, error } = useWebSocket('/ws/cluster-queues');
@@ -30,7 +31,7 @@ const ClusterQueues = () => {
     }
   }, [clusterQueues]);
 
-  if (error) return <Typography color="error">{error}</Typography>;
+  if (error) return <ErrorMessage error={error} />;
 
   return (
     <Paper style={{ padding: '16px', marginTop: '20px' }}>
diff --git a/cmd/kueueviz/frontend/src/CohortDetail.jsx b/cmd/kueueviz/frontend/src/CohortDetail.jsx
index d3a6dd221..455175d51 100644
--- a/cmd/kueueviz/frontend/src/CohortDetail.jsx
+++ b/cmd/kueueviz/frontend/src/CohortDetail.jsx
@@ -19,6 +19,7 @@ import { useParams,Link } from 'react-router-dom';
 import { Typography, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, CircularProgress } from '@mui/material';
 import useWebSocket from './useWebSocket';
 import './App.css';
+import ErrorMessage from './ErrorMessage';
 
 const CohortDetail = () => {
   const { cohortName } = useParams();
@@ -33,7 +34,7 @@ const CohortDetail = () => {
     }
   }, [cohortData]);
 
-  if (error) return <Typography color="error">{error}</Typography>;
+  if (error) return <ErrorMessage error={error} />;
 
   if (!cohortDetails) {
     return (
diff --git a/cmd/kueueviz/frontend/src/Cohorts.jsx b/cmd/kueueviz/frontend/src/Cohorts.jsx
index f3fc71350..4c9d2361f 100644
--- a/cmd/kueueviz/frontend/src/Cohorts.jsx
+++ b/cmd/kueueviz/frontend/src/Cohorts.jsx
@@ -19,6 +19,7 @@ import { Link } from 'react-router-dom';
 import { Typography, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, CircularProgress } from '@mui/material';
 import useWebSocket from './useWebSocket';
 import './App.css';
+import ErrorMessage from './ErrorMessage';
 
 const Cohorts = () => {
   const { data: cohorts, error } = useWebSocket('/ws/cohorts');
@@ -30,7 +31,7 @@ const Cohorts = () => {
     }
   }, [cohorts]);
 
-  if (error) return <Typography color="error">{error}</Typography>;
+  if (error) return <ErrorMessage error={error} />;
 
   return (
     <Paper style={{ padding: '16px', marginTop: '20px' }}>
diff --git a/cmd/kueueviz/frontend/src/Dashboard.jsx b/cmd/kueueviz/frontend/src/Dashboard.jsx
index 1bd032ec6..a78338a63 100644
--- a/cmd/kueueviz/frontend/src/Dashboard.jsx
+++ b/cmd/kueueviz/frontend/src/Dashboard.jsx
@@ -23,6 +23,7 @@ import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
 import useWebSocket from './useWebSocket';
 import './App.css';
 import { AccessTime, Check, CheckBox, CheckCircle } from '@mui/icons-material';
+import ErrorMessage from './ErrorMessage';
 
 const Dashboard = () => {
   const [queues, setQueues] = useState([]);
@@ -47,10 +48,15 @@ const Dashboard = () => {
         }
       });
     }
-    if (kueueError) setError("Failed to fetch data from WebSocket");
+    if (kueueError) setError(kueueError);
+
     setLoading(false);
   }, [kueueData, kueueError]);
 
+  if (error) {
+    return <ErrorMessage error={error} />;
+  }
+
   const toggleRow = (workloadName) => {
     setExpandedRows((prevExpandedRows) => ({
       ...prevExpandedRows,
@@ -97,7 +103,6 @@ const Dashboard = () => {
   };
 
   if (loading) return <Typography variant="h6">Loading...</Typography>;
-  if (error) return <Typography variant="h6" color="error">{error}</Typography>;
 
   return (
     <>
diff --git a/cmd/kueueviz/frontend/src/ErrorMessage.jsx b/cmd/kueueviz/frontend/src/ErrorMessage.jsx
new file mode 100644
index 000000000..77117c61b
--- /dev/null
+++ b/cmd/kueueviz/frontend/src/ErrorMessage.jsx
@@ -0,0 +1,76 @@
+/*
+Copyright 2025 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React, { useState } from 'react';
+import { Typography, Button, Collapse, Box, Paper } from '@mui/material';
+import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
+import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
+import './App.css';
+
+const ErrorMessage = ({ error }) => {
+  const [expanded, setExpanded] = useState(false);
+  
+  if (!error) return null;
+
+  // Extract the first line as the summary
+  const lines = error.split('\n');
+  const errorSummary = lines[0];
+  
+  // The rest is considered details
+  const errorDetails = lines.slice(1).join('\n');
+  
+  // Format for HTML display
+  const formattedDetails = errorDetails.replace(/\n/g, '<br />');
+
+  return (
+    <Paper className="error-message" elevation={2}>
+      <Typography variant="h6" color="error" gutterBottom>
+        Error
+      </Typography>
+      <Typography variant="body1" color="error.dark">
+        {errorSummary}
+      </Typography>
+      
+      {errorDetails && (
+        <Box className="error-details-container">
+          <Button 
+            variant="text" 
+            color="primary"
+            onClick={() => setExpanded(!expanded)}
+            endIcon={expanded ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
+            size="small"
+          >
+            {expanded ? "Hide Details" : "Show Details"}
+          </Button>
+          
+          <Collapse in={expanded}>
+            <Box className="error-details-content">
+              <Typography 
+                variant="body2" 
+                color="textSecondary" 
+                component="div"
+                className="error-details-text"
+                dangerouslySetInnerHTML={{ __html: formattedDetails }} 
+              />
+            </Box>
+          </Collapse>
+        </Box>
+      )}
+    </Paper>
+  );
+};
+
+export default ErrorMessage; 
\ No newline at end of file
diff --git a/cmd/kueueviz/frontend/src/LocalQueueDetail.jsx b/cmd/kueueviz/frontend/src/LocalQueueDetail.jsx
index 7b8b57469..d1466d133 100644
--- a/cmd/kueueviz/frontend/src/LocalQueueDetail.jsx
+++ b/cmd/kueueviz/frontend/src/LocalQueueDetail.jsx
@@ -21,6 +21,7 @@ import useWebSocket from './useWebSocket';
 import FlavorTable from './FlavorTable';
 import './App.css';
 import { Link } from 'react-router-dom';
+import ErrorMessage from './ErrorMessage';
 
 const LocalQueueDetail = () => {
   const { namespace, queueName } = useParams();
@@ -42,8 +43,8 @@ const LocalQueueDetail = () => {
   }, [workloadsData]);
 
   if (!queue) return <CircularProgress />;
-  if (queueError) return <Typography color="error">{queueError}</Typography>;
-  if (workloadsError) return <Typography color="error">{workloadsError}</Typography>;
+  if (queueError) return <ErrorMessage error={queueError} />;
+  if (workloadsError) return <ErrorMessage error={workloadsError} />;
 
   return (
     <Paper style={{ padding: '16px', marginTop: '20px' }}>
diff --git a/cmd/kueueviz/frontend/src/LocalQueues.jsx b/cmd/kueueviz/frontend/src/LocalQueues.jsx
index dabdfe45c..b7e898db0 100644
--- a/cmd/kueueviz/frontend/src/LocalQueues.jsx
+++ b/cmd/kueueviz/frontend/src/LocalQueues.jsx
@@ -19,6 +19,7 @@ import React, { useEffect, useState } from 'react';
 import { Link } from 'react-router-dom';
 import useWebSocket from './useWebSocket';
 import './App.css';
+import ErrorMessage from './ErrorMessage';
 
 const LocalQueues = () => {
   const { data: localQueues, error } = useWebSocket('/ws/local-queues');
@@ -30,7 +31,7 @@ const LocalQueues = () => {
     }
   }, [localQueues]);
 
-  if (error) return <Typography color="error">{error}</Typography>;
+  if (error) return <ErrorMessage error={error} />;
 
   // Group queues by namespace
   const queuesByNamespace = queues.reduce((acc, queue) => {
diff --git a/cmd/kueueviz/frontend/src/ResourceFlavorDetail.jsx b/cmd/kueueviz/frontend/src/ResourceFlavorDetail.jsx
index 9ff2249d4..86732c554 100644
--- a/cmd/kueueviz/frontend/src/ResourceFlavorDetail.jsx
+++ b/cmd/kueueviz/frontend/src/ResourceFlavorDetail.jsx
@@ -19,6 +19,7 @@ import { useParams, Link } from 'react-router-dom';
 import { Typography, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, CircularProgress, Box } from '@mui/material';
 import useWebSocket from './useWebSocket';
 import './App.css';
+import ErrorMessage from './ErrorMessage';
 
 const ResourceFlavorDetail = () => {
   const { flavorName } = useParams();
@@ -34,7 +35,7 @@ const ResourceFlavorDetail = () => {
     }
   }, [flavorData]);
 
-  if (error) return <Typography color="error">{error}</Typography>;
+  if (error) return <ErrorMessage error={error} />;
 
   if (!flavor) {
     return (
diff --git a/cmd/kueueviz/frontend/src/ResourceFlavors.jsx b/cmd/kueueviz/frontend/src/ResourceFlavors.jsx
index 525bc52f9..fe755363a 100644
--- a/cmd/kueueviz/frontend/src/ResourceFlavors.jsx
+++ b/cmd/kueueviz/frontend/src/ResourceFlavors.jsx
@@ -19,6 +19,7 @@ import { Link } from 'react-router-dom';
 import { Typography, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, CircularProgress } from '@mui/material';
 import useWebSocket from './useWebSocket';
 import './App.css';
+import ErrorMessage from './ErrorMessage';
 
 const ResourceFlavors = () => {
   const { data: flavors, error } = useWebSocket('/ws/resource-flavors');
@@ -30,7 +31,7 @@ const ResourceFlavors = () => {
     }
   }, [flavors]);
 
-  if (error) return <Typography color="error">{error}</Typography>;
+  if (error) return <ErrorMessage error={error} />;
 
   return (
     <Paper style={{ padding: '16px', marginTop: '20px' }}>
diff --git a/cmd/kueueviz/frontend/src/WorkloadDetail.jsx b/cmd/kueueviz/frontend/src/WorkloadDetail.jsx
index 81ec7294d..75f964ad6 100644
--- a/cmd/kueueviz/frontend/src/WorkloadDetail.jsx
+++ b/cmd/kueueviz/frontend/src/WorkloadDetail.jsx
@@ -19,6 +19,7 @@ import { useParams, Link } from 'react-router-dom';
 import { Typography,  Paper, CircularProgress, Grid, Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from '@mui/material';
 import useWebSocket from './useWebSocket';
 import './App.css';
+import ErrorMessage from './ErrorMessage';
 
 const WorkloadDetail = () => {
   const { namespace, workloadName } = useParams();
@@ -38,7 +39,7 @@ const WorkloadDetail = () => {
   }, [eventData]);
 
   if (!workload) return <CircularProgress />;
-  if (workloadError) return <Typography color="error">{workloadError}</Typography>;
+  if (workloadError) return <ErrorMessage error={workloadError} />;
 
   return (
     <Paper style={{ padding: '16px', marginTop: '20px' , alignContent: 'left', alignItems: 'left' }}>
diff --git a/cmd/kueueviz/frontend/src/Workloads.jsx b/cmd/kueueviz/frontend/src/Workloads.jsx
index 8688f327b..d8ec064d2 100644
--- a/cmd/kueueviz/frontend/src/Workloads.jsx
+++ b/cmd/kueueviz/frontend/src/Workloads.jsx
@@ -20,6 +20,7 @@ import { Link } from 'react-router-dom';
 import 'react-toastify/dist/ReactToastify.css';
 import useWebSocket from './useWebSocket';
 import './App.css';
+import ErrorMessage from './ErrorMessage';
 
 const Workloads = () => {
   const { data: data, error } = useWebSocket('/ws/workloads');
@@ -28,7 +29,7 @@ const Workloads = () => {
     setWorkloads(data?.workloads?.items || []);
   }, [data]);
 
-  if (error) return <Typography color="error">{error}</Typography>;
+  if (error) return <ErrorMessage error={error} />;
 
   // Group workloads by namespace
   const workloadsByNamespace = workloads.reduce((acc, workload) => {
diff --git a/cmd/kueueviz/frontend/src/WorkloadsList.jsx b/cmd/kueueviz/frontend/src/WorkloadsList.jsx
index 0838f026b..9b4108d85 100644
--- a/cmd/kueueviz/frontend/src/WorkloadsList.jsx
+++ b/cmd/kueueviz/frontend/src/WorkloadsList.jsx
@@ -17,8 +17,11 @@ limitations under the License.
 import { Link, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Tooltip } from '@mui/material';
 import React from 'react';
 import './App.css';
+import ErrorMessage from './ErrorMessage';
+
+const WorkloadsList = ({ workloads, error }) => {
+  if (error) return <ErrorMessage error={error} />;
 
-const WorkloadsList = ({ workloads }) => {
   return (
     <TableContainer component={Paper}>
       <Table>
diff --git a/cmd/kueueviz/frontend/src/useWebSocket.js b/cmd/kueueviz/frontend/src/useWebSocket.js
index d21005df2..3e6c7f48b 100644
--- a/cmd/kueueviz/frontend/src/useWebSocket.js
+++ b/cmd/kueueviz/frontend/src/useWebSocket.js
@@ -38,7 +38,45 @@ const useWebSocket = (url) => {
 
     ws.onerror = (err) => {
       console.error("WebSocket error:", err);
-      setError("WebSocket connection error");
+      // Log environment variables to the console
+      console.log(`Backend URL: ${fullUrl}`);
+      console.log(`REACT_APP_WEBSOCKET_URL: ${env.REACT_APP_WEBSOCKET_URL}`);
+      console.log(`VITE_WEBSOCKET_URL: ${env.VITE_WEBSOCKET_URL}`);
+
+      // Function to get all properties, including inherited ones
+      const getAllProperties = (obj) => {
+        let props = {};
+        for (let prop in obj) {
+          props[prop] = obj[prop];
+        }
+        // Manually add non-enumerable properties
+        if (obj.currentTarget) {
+          props.currentTarget = {
+            url: obj.currentTarget.url, // Extract the url property from the WebSocket object
+          };
+        }
+        return props;
+      };
+      let errorCause = "";    
+      switch (ws.readyState) {
+        case WebSocket.CONNECTING:
+          errorCause = "Failed to establish connection.";
+          break;
+        case WebSocket.CLOSED:
+          errorCause = "Connection was closed unexpectedly.";
+          break;
+        case WebSocket.CLOSING:
+          errorCause = "Connection is in the process of closing.";
+          break;
+        case WebSocket.OPEN:
+          errorCause = "Error occurred on an open connection.";
+          break;
+        default:
+          errorCause = "Unknown connection state.";
+      }
+      const errorDetails = errorCause + "\n" + JSON.stringify(getAllProperties(err), null, 2);
+      const errorMessage = `Failed to fetch data from WebSocket: ${errorDetails}\n`;
+      setError(errorMessage);
       ws.close();
     };
 



Test patch:
diff --git a/test/e2e/customconfigs/skipjobswithoutqueuename_test.go b/test/e2e/customconfigs/skipjobswithoutqueuename_test.go
index 5c832ac92..013abc0b0 100644
--- a/test/e2e/customconfigs/skipjobswithoutqueuename_test.go
+++ b/test/e2e/customconfigs/skipjobswithoutqueuename_test.go
@@ -112,7 +112,7 @@ var _ = ginkgo.Describe("ManageJobsWithoutQueueName", ginkgo.Ordered, func() {
 				gomega.Eventually(func(g gomega.Gomega) {
 					g.Expect(k8sClient.Get(ctx, jobLookupKey, createdJob)).Should(gomega.Succeed())
 					g.Expect(ptr.Deref(createdJob.Spec.Suspend, false)).To(gomega.BeFalse())
-				}, util.Timeout, util.Interval).Should(gomega.Succeed())
+				}, util.LongTimeout, util.Interval).Should(gomega.Succeed())
 			})
 			ginkgo.By("verifying that the job has been admitted", func() {
 				wlLookupKey := types.NamespacedName{Name: workloadjob.GetWorkloadNameForJob(testJob.Name, testJob.UID), Namespace: ns.Name}
@@ -164,7 +164,7 @@ var _ = ginkgo.Describe("ManageJobsWithoutQueueName", ginkgo.Ordered, func() {
 				gomega.Eventually(func(g gomega.Gomega) {
 					g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(aw), createdAppWrapper)).To(gomega.Succeed())
 					g.Expect(createdAppWrapper.Spec.Suspend).To(gomega.BeFalse())
-				}, util.Timeout, util.Interval).Should(gomega.Succeed())
+				}, util.LongTimeout, util.Interval).Should(gomega.Succeed())
 			})
 
 			ginkgo.By("wait for the wrapped Job to successfully complete", func() {
@@ -223,7 +223,7 @@ var _ = ginkgo.Describe("ManageJobsWithoutQueueName", ginkgo.Ordered, func() {
 				gomega.Eventually(func(g gomega.Gomega) {
 					g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(aw), createdAppWrapper)).To(gomega.Succeed())
 					g.Expect(createdAppWrapper.Spec.Suspend).To(gomega.BeFalse())
-				}, util.Timeout, util.Interval).Should(gomega.Succeed())
+				}, util.LongTimeout, util.Interval).Should(gomega.Succeed())
 			})
 
 			ginkgo.By("wait for the wrapped JobSet to successfully complete", func() {