From 25834adde93c516ef5ab100e436b1a5f9be26f49 Mon Sep 17 00:00:00 2001 From: Evangelos Skopelitis Date: Mon, 16 Dec 2024 17:28:34 -0500 Subject: [PATCH 1/9] frontend: KubeObject: Use apiName for resource name This change assigns the apiName (rather than the less descriptive pluralName) when the resource name is missing. Signed-off-by: Evangelos Skopelitis --- frontend/src/lib/k8s/KubeObject.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/lib/k8s/KubeObject.ts b/frontend/src/lib/k8s/KubeObject.ts index 383870b6cb..38d7f857cd 100644 --- a/frontend/src/lib/k8s/KubeObject.ts +++ b/frontend/src/lib/k8s/KubeObject.ts @@ -508,7 +508,7 @@ 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. From cf5b20636bd43e0328eb8844f4c6c2aa0770a9c4 Mon Sep 17 00:00:00 2001 From: Evangelos Skopelitis Date: Mon, 16 Dec 2024 17:25:27 -0500 Subject: [PATCH 2/9] frontend: KubeObject: Refactor getAuthorization logic This change updates the logic of the getAuthorization function in KubeObject, which previously intended to test auth by separating the group from its respective version when one of these was missing. Now, these two fields are linked and grabbed together from apiInfo when one is missing. Fixes: #2633 Signed-off-by: Evangelos Skopelitis --- frontend/src/lib/k8s/KubeObject.ts | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/frontend/src/lib/k8s/KubeObject.ts b/frontend/src/lib/k8s/KubeObject.ts index 38d7f857cd..04e1fa4d02 100644 --- a/frontend/src/lib/k8s/KubeObject.ts +++ b/frontend/src/lib/k8s/KubeObject.ts @@ -513,31 +513,19 @@ export class KubeObject { // @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; From ffc648b8de20e58967b678e716cdfdac4d6230d3 Mon Sep 17 00:00:00 2001 From: Oleksandr Dubenko Date: Thu, 5 Dec 2024 11:31:02 +0100 Subject: [PATCH 3/9] frontend Sidebar: Move "isSelected" logic outside individual items Change logic that determines if the item is selected to the parent component. This allows for better memoization of individual items. Signed-off-by: Oleksandr Dubenko --- frontend/src/components/Sidebar/Sidebar.tsx | 51 ++++++++++++++----- .../src/components/Sidebar/SidebarItem.tsx | 35 ++----------- .../Sidebar/Sidebaritem.stories.tsx | 12 ++--- 3 files changed, 50 insertions(+), 48 deletions(-) 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, From 2391ac588b5db305f54a38e56d7ee6a7b54302cc Mon Sep 17 00:00:00 2001 From: Oleksandr Dubenko Date: Fri, 3 Jan 2025 12:00:10 +0100 Subject: [PATCH 4/9] frontend: Add TimeAgo component that displays and auto refreshes time Signed-off-by: Oleksandr Dubenko --- frontend/src/components/common/Label.tsx | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) 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; +} From bd59781bbb136a764deeb2a43d74b236a6a2fb72 Mon Sep 17 00:00:00 2001 From: Kautilya Tripathi Date: Wed, 18 Dec 2024 04:34:57 +0530 Subject: [PATCH 5/9] charts: Reorganize and Enhance the Structure - Add a more detailed introduction with key features. - Add a Prerequisites section. - Improve installation instructions with common configurations. - Add a Quick Start Guide with examples. - Better organization of configuration tables with grouping. Signed-off-by: Kautilya Tripathi --- charts/headlamp/README.md | 272 +++++++++++++++++++++++++++++--------- 1 file changed, 210 insertions(+), 62 deletions(-) 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) From c68429c2e61596d6a64d11665dbd77ab67ea1356 Mon Sep 17 00:00:00 2001 From: Kautilya Tripathi Date: Mon, 6 Jan 2025 15:56:26 +0530 Subject: [PATCH 6/9] backend: prevent panic in WebSocket multiplexer This fixes a race condition in the WebSocket multiplexer where sending a COMPLETE message could panic if the connection was closed. Added proper connection state checking and error handling to safely handle closed connections. - Add connection state check before writing messages - Return early if connection is already closed - Add error logging for failed writes - Properly handle WriteJSON errors Signed-off-by: Kautilya Tripathi --- backend/cmd/multiplexer.go | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/backend/cmd/multiplexer.go b/backend/cmd/multiplexer.go index 4afcf76371..9d3204e007 100644 --- a/backend/cmd/multiplexer.go +++ b/backend/cmd/multiplexer.go @@ -582,6 +582,15 @@ 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 +602,12 @@ func (m *Multiplexer) sendCompleteMessage(conn *Connection, clientConn *websocke conn.writeMu.Lock() defer conn.writeMu.Unlock() - return clientConn.WriteJSON(completeMsg) + if err := clientConn.WriteJSON(completeMsg); err != nil { + logger.Log(logger.LevelError, nil, err, "writing complete message") + return err + } + + return nil } // sendDataMessage sends the actual data message to the client. From 0fb7ee9370e41d50059e0972097b6ba9559e8ead Mon Sep 17 00:00:00 2001 From: Kautilya Tripathi Date: Mon, 6 Jan 2025 16:36:19 +0530 Subject: [PATCH 7/9] frontend: Remove message filtering after COMPLETE Previously, WebSocket messages were being filtered out after receiving a COMPLETE message, which prevented the UI from updating when resources changed. This fix removes the filtering logic to ensure all updates are processed, keeping the UI in sync with the cluster state. Signed-off-by: Kautilya Tripathi --- frontend/src/lib/k8s/api/v2/KubeList.ts | 9 +++++++++ frontend/src/lib/k8s/api/v2/useKubeObjectList.ts | 8 ++------ frontend/src/lib/k8s/api/v2/webSocket.ts | 6 ------ 3 files changed, 11 insertions(+), 12 deletions(-) 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 From 9c0d97750e27643629b1a3c90ef104db622f805c Mon Sep 17 00:00:00 2001 From: Kautilya Tripathi Date: Mon, 6 Jan 2025 16:42:16 +0530 Subject: [PATCH 8/9] backend: Change COMPLETE message error to info COMPLETE messages are not critical for functionality since the client will continue to receive updates even if the COMPLETE message fails. Changed the log level to reduce noise in error logs while maintaining visibility through info logs. Signed-off-by: Kautilya Tripathi --- backend/cmd/multiplexer.go | 9 ++--- backend/cmd/multiplexer_test.go | 62 ++++++++++++++++++++++++++++++++- 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/backend/cmd/multiplexer.go b/backend/cmd/multiplexer.go index 9d3204e007..e83236821e 100644 --- a/backend/cmd/multiplexer.go +++ b/backend/cmd/multiplexer.go @@ -583,7 +583,6 @@ 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 @@ -602,9 +601,11 @@ func (m *Multiplexer) sendCompleteMessage(conn *Connection, clientConn *websocke conn.writeMu.Lock() defer conn.writeMu.Unlock() - if err := clientConn.WriteJSON(completeMsg); err != nil { - logger.Log(logger.LevelError, nil, err, "writing complete message") - return err + 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 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 { From c49aada3020852157cbbc4c4d919b1b3694ef9d4 Mon Sep 17 00:00:00 2001 From: Evangelos Skopelitis Date: Mon, 16 Dec 2024 18:01:22 -0500 Subject: [PATCH 9/9] frontend: TopBar: Hide menu on mobile when empty This change hides the top bar menu on mobile when there are no actions to display. Fixes: #2066 Signed-off-by: Evangelos Skopelitis --- frontend/src/components/App/TopBar.tsx | 28 +++++++++++++++++--------- 1 file changed, 18 insertions(+), 10 deletions(-) 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 && ( + + + + )} ) : ( <>