diff --git a/backend/cmd/multiplexer.go b/backend/cmd/multiplexer.go index 4afcf76371..e83236821e 100644 --- a/backend/cmd/multiplexer.go +++ b/backend/cmd/multiplexer.go @@ -582,6 +582,14 @@ func (m *Multiplexer) sendIfNewResourceVersion( // sendCompleteMessage sends a COMPLETE message to the client. func (m *Multiplexer) sendCompleteMessage(conn *Connection, clientConn *websocket.Conn) error { + conn.mu.RLock() + if conn.closed { + conn.mu.RUnlock() + return nil // Connection is already closed, no need to send message + } + + conn.mu.RUnlock() + completeMsg := Message{ ClusterID: conn.ClusterID, Path: conn.Path, @@ -593,7 +601,14 @@ func (m *Multiplexer) sendCompleteMessage(conn *Connection, clientConn *websocke conn.writeMu.Lock() defer conn.writeMu.Unlock() - return clientConn.WriteJSON(completeMsg) + err := clientConn.WriteJSON(completeMsg) + if err != nil { + logger.Log(logger.LevelInfo, nil, err, "connection closed while writing complete message") + + return nil // Just return nil for any error - connection is dead anyway + } + + return nil } // sendDataMessage sends the actual data message to the client. diff --git a/backend/cmd/multiplexer_test.go b/backend/cmd/multiplexer_test.go index 0df729ef4a..20160a7934 100644 --- a/backend/cmd/multiplexer_test.go +++ b/backend/cmd/multiplexer_test.go @@ -1001,7 +1001,67 @@ func TestSendCompleteMessage_ClosedConnection(t *testing.T) { // Test with closed connection clientConn.Close() err = m.sendCompleteMessage(conn, clientConn) - assert.Error(t, err) + assert.NoError(t, err) +} + +func TestSendCompleteMessage_ErrorConditions(t *testing.T) { + tests := []struct { + name string + setupConn func(*Connection, *websocket.Conn) + expectedError bool + }{ + { + name: "connection already marked as closed", + setupConn: func(conn *Connection, _ *websocket.Conn) { + conn.closed = true + }, + expectedError: false, + }, + { + name: "normal closure", + setupConn: func(_ *Connection, clientConn *websocket.Conn) { + //nolint:errcheck + clientConn.WriteMessage(websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) + clientConn.Close() + }, + expectedError: false, + }, + { + name: "unexpected close error", + setupConn: func(_ *Connection, clientConn *websocket.Conn) { + //nolint:errcheck + clientConn.WriteMessage(websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.CloseProtocolError, "")) + clientConn.Close() + }, + expectedError: false, // All errors return nil now + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := NewMultiplexer(kubeconfig.NewContextStore()) + clientConn, clientServer := createTestWebSocketConnection() + defer clientServer.Close() + + conn := &Connection{ + ClusterID: "test-cluster", + Path: "/api/v1/pods", + UserID: "test-user", + Query: "watch=true", + } + + tt.setupConn(conn, clientConn) + err := m.sendCompleteMessage(conn, clientConn) + + if tt.expectedError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } } func createMockKubeAPIServer() *httptest.Server { diff --git a/charts/headlamp/README.md b/charts/headlamp/README.md index 87e5b1336c..9989e2358a 100644 --- a/charts/headlamp/README.md +++ b/charts/headlamp/README.md @@ -1,83 +1,231 @@ -# headlamp +# Headlamp Helm Chart -Headlamp is an easy-to-use and extensible Kubernetes web UI. +Headlamp is an easy-to-use and extensible Kubernetes web UI that provides: +- 🚀 Modern, fast, and responsive interface +- 🔒 OIDC authentication support +- 🔌 Plugin system for extensibility +- 🎯 Real-time cluster state updates -**Homepage:** +## Prerequisites -## TL;DR +- Kubernetes 1.21+ +- Helm 3.x +- Cluster admin access for initial setup + +## Quick Start + +Add the Headlamp repository and install the chart: ```console $ helm repo add headlamp https://headlamp-k8s.github.io/headlamp/ +$ helm repo update $ helm install my-headlamp headlamp/headlamp --namespace kube-system ``` +Access Headlamp: +```console +$ kubectl port-forward -n kube-system svc/my-headlamp 8080:80 +``` +Then open http://localhost:8080 in your browser. + +## Installation + +### Basic Installation +```console +$ helm install my-headlamp headlamp/headlamp --namespace kube-system +``` + +### Installation with OIDC +```console +$ helm install my-headlamp headlamp/headlamp \ + --namespace kube-system \ + --set config.oidc.clientID=your-client-id \ + --set config.oidc.clientSecret=your-client-secret \ + --set config.oidc.issuerURL=https://your-issuer-url +``` + +### Installation with Ingress +```console +$ helm install my-headlamp headlamp/headlamp \ + --namespace kube-system \ + --set ingress.enabled=true \ + --set ingress.hosts[0].host=headlamp.example.com \ + --set ingress.hosts[0].paths[0].path=/ +``` + +## Configuration + +### Core Parameters + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| replicaCount | int | `1` | Number of desired pods | +| image.registry | string | `"ghcr.io"` | Container image registry | +| image.repository | string | `"headlamp-k8s/headlamp"` | Container image name | +| image.tag | string | `""` | Container image tag (defaults to Chart appVersion) | +| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy | + +### Application Configuration + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| config.baseURL | string | `""` | Base URL path for Headlamp UI | +| config.pluginsDir | string | `"/headlamp/plugins"` | Directory to load Headlamp plugins from | +| config.extraArgs | array | `[]` | Additional arguments for Headlamp server | + +### OIDC Configuration + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| config.oidc.clientID | string | `""` | OIDC client ID | +| config.oidc.clientSecret | string | `""` | OIDC client secret | +| config.oidc.issuerURL | string | `""` | OIDC issuer URL | +| config.oidc.scopes | string | `""` | OIDC scopes to be used | +| config.oidc.secret.create | bool | `true` | Create OIDC secret using provided values | +| config.oidc.secret.name | string | `"oidc"` | Name of the OIDC secret | +| config.oidc.externalSecret.enabled | bool | `false` | Enable using external secret for OIDC | +| config.oidc.externalSecret.name | string | `""` | Name of external OIDC secret | -## Maintainers +There are three ways to configure OIDC: -See [MAINTAINERS.md](https://github.com/headlamp-k8s/headlamp/blob/main/MAINTAINERS.md) in the headlamp github repo. +1. Using direct configuration: +```yaml +config: + oidc: + clientID: "your-client-id" + clientSecret: "your-client-secret" + issuerURL: "https://your-issuer" + scopes: "openid profile email" +``` -## Source Code +2. Using automatic secret creation: +```yaml +config: + oidc: + secret: + create: true + name: oidc +``` -* -* +3. Using external secret: +```yaml +config: + oidc: + secret: + create: false + externalSecret: + enabled: true + name: your-oidc-secret +``` -### Headlamp parameters +### Deployment Configuration | Key | Type | Default | Description | |-----|------|---------|-------------| -| affinity | object | `{}` | Affinity settings for pod assignment | -| clusterRoleBinding.annotations | object | `{}` | Annotations to add to the cluster role binding | -| clusterRoleBinding.create | bool | `true` | Specified whether a cluster role binding should be created | -| clusterRoleBinding.clusterRoleName| string | `cluster-admin` | Kubernetes ClusterRole name | -| env | list | `[]` | An optional list of environment variables | -| fullnameOverride | string | `""` | Overrides the full name of the chart | -| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy. One of Always, Never, IfNotPresent | +| replicaCount | int | `1` | Number of desired pods | | image.registry | string | `"ghcr.io"` | Container image registry | | image.repository | string | `"headlamp-k8s/headlamp"` | Container image name | -| image.tag | string | `""` | Container image tag, If "" uses appVersion in Chart.yaml | -| imagePullSecrets | list | `[]` | An optional list of references to secrets in the same namespace to use for pulling any of the images used | -| ingress.annotations | object | `{}` | Annotations for Ingress resource | -| ingress.enabled | bool | `false` | Enable ingress controller resource | -| ingress.ingressClassName | string | `""` | The ingress class name. Replacement for the deprecated "kubernetes.io/ingress.class" annotation | -| ingress.hosts | list | `[]` | Hostname(s) for the Ingress resource | +| image.tag | string | `""` | Container image tag (defaults to Chart appVersion) | +| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy | +| imagePullSecrets | list | `[]` | Image pull secrets references | +| nameOverride | string | `""` | Override the name of the chart | +| fullnameOverride | string | `""` | Override the full name of the chart | +| initContainers | list | `[]` | Init containers to run before main container | + +### Security Configuration + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| serviceAccount.create | bool | `true` | Create service account | +| serviceAccount.name | string | `""` | Service account name | +| serviceAccount.annotations | object | `{}` | Service account annotations | +| clusterRoleBinding.create | bool | `true` | Create cluster role binding | +| clusterRoleBinding.clusterRoleName | string | `"cluster-admin"` | Kubernetes ClusterRole name | +| clusterRoleBinding.annotations | object | `{}` | Cluster role binding annotations | +| podSecurityContext | object | `{}` | Pod security context (e.g., fsGroup: 2000) | +| securityContext.runAsNonRoot | bool | `true` | Run container as non-root | +| securityContext.privileged | bool | `false` | Run container in privileged mode | +| securityContext.runAsUser | int | `100` | User ID to run container | +| securityContext.runAsGroup | int | `101` | Group ID to run container | +| securityContext.capabilities | object | `{}` | Container capabilities (e.g., drop: [ALL]) | +| securityContext.readOnlyRootFilesystem | bool | `false` | Mount root filesystem as read-only | + +### Storage Configuration + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| persistentVolumeClaim.enabled | bool | `false` | Enable PVC | +| persistentVolumeClaim.annotations | object | `{}` | PVC annotations | +| persistentVolumeClaim.size | string | `""` | PVC size (required if enabled) | +| persistentVolumeClaim.storageClassName | string | `""` | Storage class name | +| persistentVolumeClaim.accessModes | list | `[]` | PVC access modes | +| persistentVolumeClaim.selector | object | `{}` | PVC selector | +| persistentVolumeClaim.volumeMode | string | `""` | PVC volume mode | +| volumeMounts | list | `[]` | Container volume mounts | +| volumes | list | `[]` | Pod volumes | + +### Network Configuration + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| service.type | string | `"ClusterIP"` | Kubernetes service type | +| service.port | int | `80` | Kubernetes service port | +| ingress.enabled | bool | `false` | Enable ingress | +| ingress.className | string | `""` | Ingress class name | +| ingress.annotations | object | `{}` | Ingress annotations (e.g., kubernetes.io/tls-acme: "true") | +| ingress.hosts | list | `[]` | Ingress hosts configuration | | ingress.tls | list | `[]` | Ingress TLS configuration | -| initContainers | list | `[]` | An optional list of init containers to be run before the main containers. | -| nameOverride | string | `""` | Overrides the name of the chart | + +Example ingress configuration: +```yaml +ingress: + enabled: true + annotations: + kubernetes.io/tls-acme: "true" + hosts: + - host: headlamp.example.com + paths: + - path: / + type: ImplementationSpecific + tls: + - secretName: headlamp-tls + hosts: + - headlamp.example.com +``` + +### Resource Management + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| resources | object | `{}` | Container resource requests/limits | | nodeSelector | object | `{}` | Node labels for pod assignment | -| persistentVolumeClaim.accessModes | list | `[]` | accessModes for the persistent volume claim, eg: ReadWriteOnce, ReadOnlyMany, ReadWriteMany etc. | -| persistentVolumeClaim.annotations | object | `{}` | Annotations to add to the persistent volume claim (if enabled) | -| persistentVolumeClaim.enabled | bool | `false` | Enable Persistent Volume Claim | -| persistentVolumeClaim.selector | object | `{}` | selector for the persistent volume claim. | -| persistentVolumeClaim.size | string | `""` | size of the persistent volume claim, eg: 10Gi. Required if enabled is true. | -| persistentVolumeClaim.storageClassName | string | `""` | storageClassName for the persistent volume claim. | -| persistentVolumeClaim.volumeMode | string | `""` | volumeMode for the persistent volume claim, eg: Filesystem, Block. | -| podAnnotations | object | `{}` | Annotations to add to the pod | -| podSecurityContext | object | `{}` | Headlamp pod's Security Context | -| replicaCount | int | `1` | Number of desired pods | -| resources | object | `{}` | CPU/Memory resource requests/limits | -| securityContext | object | `{}` | Headlamp containers Security Context | -| service.port | int | `80` | Kubernetes Service port | -| service.type | string | `"ClusterIP"` | Kubernetes Service type | -| serviceAccount.annotations | object | `{}` | Annotations to add to the service account | -| serviceAccount.create | bool | `true` | Specifies whether a service account should be created | -| serviceAccount.name | string | `""` | The name of the service account to use.(If not set and create is true, a name is generated using the fullname template) | -| tolerations | list | `[]` | Toleration labels for pod assignment | -| volumeMounts | list | `[]` | Headlamp containers volume mounts | -| volumes | list | `[]` | Headlamp pod's volumes | - - -### Headlamp Configuration - -| Key | Type | Default | Description | -|------------------------------------|--------|-----------------------|-------------------------------------------------------------------------------------------------------| -| config.baseURL | string | `""` | base url path at which headlamp should run | -| config.oidc.clientID | string | `""` | OIDC client ID | -| config.oidc.clientSecret | string | `""` | OIDC client secret | -| config.oidc.issuerURL | string | `""` | OIDC issuer URL | -| config.oidc.scopes | string | `""` | OIDC scopes to be used | -| config.oidc.secret.create | bool | `true` | Enable this option to have the chart automatically create the OIDC secret using the specified values. | -| config.oidc.secret.name | string | `oidc` | Name of the OIDC secret used by headlamp | -| config.oidc.externalSecret.enabled | bool | `false` | Enable this option if you want to use an external secret for OIDC configuration. | -| config.oidc.externalSecret.name | string | `""` | Name of the external OIDC secret to be used by headlamp. | -| config.pluginsDir | string | `"/headlamp/plugins"` | directory to look for plugins | -| config.extraArgs | array | `[]` | Extra arguments that can be given to the container | +| tolerations | list | `[]` | Pod tolerations | +| affinity | object | `{}` | Pod affinity settings | +| podAnnotations | object | `{}` | Pod annotations | +| env | list | `[]` | Additional environment variables | + +Example resource configuration: +```yaml +resources: + limits: + cpu: 100m + memory: 128Mi + requests: + cpu: 100m + memory: 128Mi +``` + +Example environment variables: +```yaml +env: + - name: KUBERNETES_SERVICE_HOST + value: "localhost" + - name: KUBERNETES_SERVICE_PORT + value: "6443" +``` + +## Links + +- [GitHub Repository](https://github.com/headlamp-k8s/headlamp) +- [Documentation](https://headlamp.dev/) +- [Maintainers](https://github.com/headlamp-k8s/headlamp/blob/main/MAINTAINERS.md) diff --git a/frontend/src/components/App/TopBar.tsx b/frontend/src/components/App/TopBar.tsx index f01ed19f6a..70ebb4509f 100644 --- a/frontend/src/components/App/TopBar.tsx +++ b/frontend/src/components/App/TopBar.tsx @@ -399,6 +399,12 @@ export const PureTopBar = memo( ), }, ]; + + const visibleMobileActions = processAppBarActions( + allAppBarActionsMobile, + appBarActionsProcessors + ).filter(action => React.isValidElement(action.action) || typeof action === 'function'); + return ( <> - - - + {visibleMobileActions.length > 0 && ( + + + + )} ) : ( <> diff --git a/frontend/src/components/Sidebar/Sidebar.tsx b/frontend/src/components/Sidebar/Sidebar.tsx index 529db8ab55..afd0239509 100644 --- a/frontend/src/components/Sidebar/Sidebar.tsx +++ b/frontend/src/components/Sidebar/Sidebar.tsx @@ -17,13 +17,8 @@ import { ActionButton } from '../common'; import CreateButton from '../common/Resource/CreateButton'; import NavigationTabs from './NavigationTabs'; import prepareRoutes from './prepareRoutes'; -import SidebarItem from './SidebarItem'; -import { - DefaultSidebars, - setSidebarSelected, - setWhetherSidebarOpen, - SidebarEntry, -} from './sidebarSlice'; +import SidebarItem, { SidebarItemProps } from './SidebarItem'; +import { DefaultSidebars, setSidebarSelected, setWhetherSidebarOpen } from './sidebarSlice'; import VersionButton from './VersionButton'; export const drawerWidth = 240; @@ -153,6 +148,34 @@ const DefaultLinkArea = memo((props: { sidebarName: string; isOpen: boolean }) = ); }); +/** + * Checks if item or any sub items are selected + */ +function getIsSelected(item: SidebarItemProps, selectedName?: string | null): boolean { + if (!selectedName) return false; + return ( + item.name === selectedName || Boolean(item.subList?.find(it => getIsSelected(it, selectedName))) + ); +} + +/** + * Updates the isSelected field of an item + */ +function updateItemSelected( + item: SidebarItemProps, + selectedName?: string | null +): SidebarItemProps { + const isSelected = getIsSelected(item, selectedName); + if (isSelected === false) return item; + return { + ...item, + isSelected: isSelected, + subList: item.subList + ? item.subList.map(it => updateItemSelected(it, selectedName)) + : item.subList, + }; +} + export default function Sidebar() { const { t, i18n } = useTranslation(['glossary', 'translation']); @@ -177,7 +200,7 @@ export default function Sidebar() { return prepareRoutes(t, sidebar.selected.sidebar || ''); }, [ cluster, - sidebar.selected, + sidebar.selected.sidebar, sidebar.entries, sidebar.filters, i18n.language, @@ -195,13 +218,18 @@ export default function Sidebar() { [sidebar.selected.sidebar, isOpen] ); + const processedItems = useMemo( + () => items.map(item => updateItemSelected(item, sidebar.selected.item)), + [items, sidebar.selected.item] + ); + if (sidebar.selected.sidebar === null || !sidebar?.isVisible) { return null; } return ( ( { search, useClusterURL = false, subList = [], - selectedName, + isSelected, hasParent = false, icon, fullWidth = true, @@ -61,31 +61,6 @@ const SidebarItem = memo((props: SidebarItemProps) => { fullURL = createRouteURL(routeName); } - const isSelected = React.useMemo(() => { - if (name === selectedName) { - return true; - } - - let subListToCheck = [...subList]; - for (let i = 0; i < subListToCheck.length; i++) { - const subItem = subListToCheck[i]; - if (subItem.name === selectedName) { - return true; - } - - if (!!subItem.subList) { - subListToCheck = subListToCheck.concat(subItem.subList); - } - } - return false; - }, [subList, name, selectedName]); - - function shouldExpand() { - return isSelected || !!subList.find(item => item.name === selectedName); - } - - const expanded = subList.length > 0 && shouldExpand(); - return hide ? null : ( { padding: 0, }} > - + { {subList.map((item: SidebarItemProps) => ( = args => { export const Selected = Template.bind({}); Selected.args = { - selectedName: 'cluster', + isSelected: true, name: 'cluster', label: 'Cluster', icon: 'mdi:hexagon-multiple-outline', @@ -43,7 +43,7 @@ Selected.args = { export const Unselected = Template.bind({}); Unselected.args = { - selectedName: 'meow', + isSelected: false, name: 'cluster', label: 'Cluster', icon: 'mdi:hexagon-multiple-outline', @@ -52,14 +52,14 @@ Unselected.args = { export const SublistExpanded = Template.bind({}); SublistExpanded.args = { - selectedName: 'cluster', + isSelected: true, name: 'cluster', label: 'Cluster', fullWidth: true, icon: 'mdi:hexagon-multiple-outline', subList: [ { - selectedName: 'cluster', + isSelected: false, name: 'namespaces', label: 'Namespaces', hasParent: true, @@ -69,14 +69,14 @@ SublistExpanded.args = { export const Sublist = Template.bind({}); Sublist.args = { - selectedName: 'meow', + isSelected: false, name: 'cluster', label: 'Cluster', fullWidth: true, icon: 'mdi:hexagon-multiple-outline', subList: [ { - selectedName: 'cluster', + isSelected: false, name: 'namespaces', label: 'Namespaces', hasParent: true, diff --git a/frontend/src/components/common/Label.tsx b/frontend/src/components/common/Label.tsx index 3b4e5d234c..ec6c84a09c 100644 --- a/frontend/src/components/common/Label.tsx +++ b/frontend/src/components/common/Label.tsx @@ -2,7 +2,7 @@ import { Icon, IconProps } from '@iconify/react'; import Grid from '@mui/material/Grid'; import { SxProps, Theme, useTheme } from '@mui/material/styles'; import Typography, { TypographyProps } from '@mui/material/Typography'; -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { DateFormatOptions, localeDate, timeAgo } from '../../lib/util'; import { LightTooltip, TooltipIcon } from './Tooltip'; @@ -205,10 +205,29 @@ export function DateLabel(props: DateLabelProps) { const { date, format = 'brief', iconProps = {} } = props; return ( } hoverInfo={localeDate(date)} icon="mdi:calendar" iconProps={iconProps} /> ); } + +/** + * Shows time passed since given date + * Automatically refreshes + */ +function TimeAgo({ date, format }: { date: number | string | Date; format?: DateFormatOptions }) { + const [formattedDate, setFormattedDate] = useState(() => timeAgo(date, { format })); + + useEffect(() => { + const id = setInterval(() => { + const newFormattedDate = timeAgo(date, { format }); + setFormattedDate(newFormattedDate); + }, 1_000); + + return () => clearInterval(id); + }, []); + + return formattedDate; +} diff --git a/frontend/src/lib/k8s/KubeObject.ts b/frontend/src/lib/k8s/KubeObject.ts index 383870b6cb..04e1fa4d02 100644 --- a/frontend/src/lib/k8s/KubeObject.ts +++ b/frontend/src/lib/k8s/KubeObject.ts @@ -508,36 +508,24 @@ export class KubeObject { }; if (!resourceAttrs.resource) { - resourceAttrs['resource'] = this.pluralName; + resourceAttrs['resource'] = this.apiName; } // @todo: We should get the API info from the API endpoint. - // If we already have the group, version, and resource, then we can make the request - // without trying the API info, which may have several versions and thus be less optimal. + // If we already have the group and version, then we can make the request without + // trying the API info, which may have several versions and thus be less optimal. if (!!resourceAttrs.group && !!resourceAttrs.version && !!resourceAttrs.resource) { return this.fetchAuthorization(resourceAttrs); } - // If we don't have the group, version, and resource, then we have to try all of the + // If we don't have the group or version, then we have to try all of the // API info versions until we find one that works. const apiInfo = this.apiEndpoint.apiInfo; for (let i = 0; i < apiInfo.length; i++) { - const { group, version, resource } = apiInfo[i]; - // We only take from the details from the apiInfo if they're missing from the resourceAttrs. - // The idea is that, since this function may also be called from the instance's getAuthorization, - // it may already have the details from the instance's API version. - const attrs = { ...resourceAttrs }; - - if (!!attrs.resource) { - attrs.resource = resource; - } - if (!!attrs.group) { - attrs.group = group; - } - if (!!attrs.version) { - attrs.version = version; - } + const { group, version } = apiInfo[i]; + // The group and version are tied, so we take both if one is missing. + const attrs = { ...resourceAttrs, group: group, version: version }; let authResult; diff --git a/frontend/src/lib/k8s/api/v2/KubeList.ts b/frontend/src/lib/k8s/api/v2/KubeList.ts index 9b6ecb746e..850b3c7062 100644 --- a/frontend/src/lib/k8s/api/v2/KubeList.ts +++ b/frontend/src/lib/k8s/api/v2/KubeList.ts @@ -31,6 +31,15 @@ export const KubeList = { update: KubeListUpdateEvent, itemClass: ObjectClass ): KubeList> { + // Skip if the update's resource version is older than or equal to what we have + if ( + list.metadata.resourceVersion && + update.object.metadata.resourceVersion && + parseInt(update.object.metadata.resourceVersion) <= parseInt(list.metadata.resourceVersion) + ) { + return list; + } + const newItems = [...list.items]; const index = newItems.findIndex(item => item.metadata.uid === update.object.metadata.uid); diff --git a/frontend/src/lib/k8s/api/v2/useKubeObjectList.ts b/frontend/src/lib/k8s/api/v2/useKubeObjectList.ts index 31d340bc9a..7f373e7f76 100644 --- a/frontend/src/lib/k8s/api/v2/useKubeObjectList.ts +++ b/frontend/src/lib/k8s/api/v2/useKubeObjectList.ts @@ -175,12 +175,8 @@ function useWatchKubeObjectListsMultiplexed({ return lists.map(list => { const key = `${list.cluster}:${list.namespace || ''}`; - // Update resource version if newer one is available - const currentVersion = latestResourceVersions.current[key]; - const newVersion = list.resourceVersion; - if (!currentVersion || parseInt(newVersion) > parseInt(currentVersion)) { - latestResourceVersions.current[key] = newVersion; - } + // Always use the latest resource version from the server + latestResourceVersions.current[key] = list.resourceVersion; // Construct WebSocket URL with current parameters return { diff --git a/frontend/src/lib/k8s/api/v2/webSocket.ts b/frontend/src/lib/k8s/api/v2/webSocket.ts index e8c12162a6..91d8b6380d 100644 --- a/frontend/src/lib/k8s/api/v2/webSocket.ts +++ b/frontend/src/lib/k8s/api/v2/webSocket.ts @@ -318,12 +318,6 @@ export const WebSocketManager = { // Handle COMPLETE messages if (data.type === 'COMPLETE') { this.completedPaths.add(key); - return; - } - - // Skip if path is already completed - if (this.completedPaths.has(key)) { - return; } // Parse and validate update data