From b08df4fb76d96e531b936649b0e5d5b2293f473d Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Thu, 12 Feb 2026 21:40:40 -0500 Subject: [PATCH] feat: improve UX with drawer detail view and proper settings placement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major UX improvements: - Changed detail view from full page to drawer (slides from right) - Moved plugin settings from sidebar to Settings → Plugins (proper pattern) - Fixed React error #310 by adding defensive String() wrappers - Fixed syncMessage getter to always return string - Added safety checks for encryptedData access - Added error handling for useGet failures The drawer approach keeps the list visible while viewing details, matching Headlamp's design patterns. Settings are now properly located in the global Settings → Plugins section instead of cluttering the plugin's sidebar navigation. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- headlamp-sealed-secrets/package.json | 2 +- .../src/components/ErrorBoundary.tsx | 31 +++- .../src/components/SealedSecretDetail.tsx | 142 ++++++++++++------ .../src/components/SealedSecretList.tsx | 5 + headlamp-sealed-secrets/src/index.tsx | 53 +++---- .../src/lib/SealedSecretCRD.ts | 18 +-- 6 files changed, 157 insertions(+), 94 deletions(-) diff --git a/headlamp-sealed-secrets/package.json b/headlamp-sealed-secrets/package.json index 618b11f..8a070cf 100644 --- a/headlamp-sealed-secrets/package.json +++ b/headlamp-sealed-secrets/package.json @@ -1,6 +1,6 @@ { "name": "headlamp-sealed-secrets", - "version": "0.2.10", + "version": "0.2.11", "description": "Headlamp plugin for Bitnami Sealed Secrets - manage encrypted Kubernetes secrets", "files": [ "dist", diff --git a/headlamp-sealed-secrets/src/components/ErrorBoundary.tsx b/headlamp-sealed-secrets/src/components/ErrorBoundary.tsx index c2be876..8a5b9dc 100644 --- a/headlamp-sealed-secrets/src/components/ErrorBoundary.tsx +++ b/headlamp-sealed-secrets/src/components/ErrorBoundary.tsx @@ -35,6 +35,10 @@ abstract class BaseErrorBoundary extends Component - Error: {this.state.error.message} + {(() => { + try { + const msg = this.state.error.message || this.state.error.toString(); + return `Error: ${String(msg)}`; + } catch (e) { + return 'Error: [Unable to display error message]'; + } + })()} )} @@ -143,7 +154,14 @@ export class ApiErrorBoundary extends BaseErrorBoundary { variant="body2" sx={{ mt: 2, fontFamily: 'monospace', fontSize: '0.875rem' }} > - Error: {this.state.error.message} + {(() => { + try { + const msg = this.state.error.message || this.state.error.toString(); + return `Error: ${String(msg)}`; + } catch (e) { + return 'Error: [Unable to display error message]'; + } + })()} )} @@ -182,7 +200,14 @@ export class GenericErrorBoundary extends BaseErrorBoundary { variant="body2" sx={{ mt: 2, fontFamily: 'monospace', fontSize: '0.875rem' }} > - Error: {this.state.error.message} + {(() => { + try { + const msg = this.state.error.message || this.state.error.toString(); + return `Error: ${String(msg)}`; + } catch (e) { + return 'Error: [Unable to display error message]'; + } + })()} )} diff --git a/headlamp-sealed-secrets/src/components/SealedSecretDetail.tsx b/headlamp-sealed-secrets/src/components/SealedSecretDetail.tsx index 335c99d..f619e04 100644 --- a/headlamp-sealed-secrets/src/components/SealedSecretDetail.tsx +++ b/headlamp-sealed-secrets/src/components/SealedSecretDetail.tsx @@ -5,6 +5,7 @@ * encrypted data, template, resulting Secret, and actions */ +import { Icon } from '@iconify/react'; import { K8s } from '@kinvolk/headlamp-plugin/lib'; import { Link } from '@kinvolk/headlamp-plugin/lib/CommonComponents'; import { @@ -13,7 +14,18 @@ import { SimpleTable, StatusLabel, } from '@kinvolk/headlamp-plugin/lib/CommonComponents'; -import { Box, Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material'; +import { + Alert, + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Drawer, + IconButton, + Typography, +} from '@mui/material'; import { useSnackbar } from 'notistack'; import React from 'react'; import { useParams } from 'react-router-dom'; @@ -46,7 +58,7 @@ function formatScope(scope: SealedSecretScope): string { */ export function SealedSecretDetail() { const { namespace, name } = useParams<{ namespace: string; name: string }>(); - const [sealedSecret] = SealedSecret.useGet(name, namespace); + const [sealedSecret, error] = SealedSecret.useGet(name, namespace); const [secret] = K8s.ResourceClasses.Secret.useGet(name, namespace); const [decryptKey, setDecryptKey] = React.useState(null); const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false); @@ -62,6 +74,22 @@ export function SealedSecretDetail() { } }, [namespace]); + // Show error if fetch failed + if (error) { + return ( + + + + Failed to load SealedSecret + + + {String(error)} + + + + ); + } + // Show loading skeleton while data is being fetched if (!sealedSecret) { return ; @@ -94,16 +122,41 @@ export function SealedSecretDetail() { } }, [sealedSecret, enqueueSnackbar]); - const encryptedKeys = Object.keys(sealedSecret.spec.encryptedData || {}); + // Safety check - should never happen due to early returns above, but be defensive + if (!sealedSecret?.spec?.encryptedData) { + return ; + } + + const encryptedKeys = Object.keys(sealedSecret.spec.encryptedData); + + const handleClose = () => { + window.history.back(); + }; return ( <> - - - {sealedSecret.metadata.name} - + + + + + + + + {sealedSecret.metadata.name} + + {permissions?.canUpdate && ( - - - + setDeleteDialogOpen(false)}> + Delete SealedSecret? + + Are you sure you want to delete the SealedSecret {name}? This will also + delete the resulting Kubernetes Secret. + + + + + + + ); } diff --git a/headlamp-sealed-secrets/src/components/SealedSecretList.tsx b/headlamp-sealed-secrets/src/components/SealedSecretList.tsx index 56f5528..9c5c9b9 100644 --- a/headlamp-sealed-secrets/src/components/SealedSecretList.tsx +++ b/headlamp-sealed-secrets/src/components/SealedSecretList.tsx @@ -13,11 +13,13 @@ import { } from '@kinvolk/headlamp-plugin/lib/CommonComponents'; import { Box, Button } from '@mui/material'; import React from 'react'; +import { useParams } from 'react-router-dom'; import { usePermission } from '../hooks/usePermissions'; import { SealedSecret } from '../lib/SealedSecretCRD'; import { SealedSecretScope } from '../types'; import { EncryptDialog } from './EncryptDialog'; import { SealedSecretListSkeleton } from './LoadingSkeletons'; +import { SealedSecretDetail } from './SealedSecretDetail'; import { VersionWarning } from './VersionWarning'; /** @@ -40,6 +42,7 @@ function formatScope(scope: SealedSecretScope): string { * SealedSecrets list view component */ export function SealedSecretList() { + const { namespace, name } = useParams<{ namespace?: string; name?: string }>(); const [sealedSecrets, error, loading] = SealedSecret.useList(); const [createDialogOpen, setCreateDialogOpen] = React.useState(false); const { allowed: canCreate } = usePermission(undefined, 'canCreate'); @@ -159,6 +162,8 @@ export function SealedSecretList() { + + {namespace && name && } ); } diff --git a/headlamp-sealed-secrets/src/index.tsx b/headlamp-sealed-secrets/src/index.tsx index 5e5a6e1..f1ee576 100644 --- a/headlamp-sealed-secrets/src/index.tsx +++ b/headlamp-sealed-secrets/src/index.tsx @@ -16,12 +16,12 @@ import { registerDetailsViewSection, + registerPluginSettings, registerRoute, registerSidebarEntry, } from '@kinvolk/headlamp-plugin/lib'; import React from 'react'; import { ApiErrorBoundary, GenericErrorBoundary } from './components/ErrorBoundary'; -import { SealedSecretDetail } from './components/SealedSecretDetail'; import { SealedSecretList } from './components/SealedSecretList'; import { SealingKeysView } from './components/SealingKeysView'; import { SecretDetailsSection } from './components/SecretDetailsSection'; @@ -56,21 +56,13 @@ registerSidebarEntry({ url: '/sealedsecrets/keys', }); -// "Settings" child entry -registerSidebarEntry({ - parent: 'sealed-secrets', - name: 'sealed-secrets-settings', - label: 'Settings', - url: '/sealedsecrets/settings', -}); - /** * Register routes */ -// List view +// List view with optional detail drawer registerRoute({ - path: '/sealedsecrets', + path: '/sealedsecrets/:namespace?/:name?', sidebar: 'sealed-secrets-list', component: () => ( @@ -78,18 +70,6 @@ registerRoute({ ), exact: true, -}); - -// Detail view -registerRoute({ - path: '/sealedsecrets/:namespace/:name', - sidebar: 'sealed-secrets-list', - component: () => ( - - - - ), - exact: true, name: 'sealedsecret', }); @@ -105,18 +85,6 @@ registerRoute({ exact: true, }); -// Settings page -registerRoute({ - path: '/sealedsecrets/settings', - sidebar: 'sealed-secrets-settings', - component: () => ( - - - - ), - exact: true, -}); - /** * Register integration with Secret detail view * @@ -132,3 +100,18 @@ registerDetailsViewSection(({ resource }) => { } return null; }); + +/** + * Register plugin settings + * + * Settings will appear in Settings → Plugins → Sealed Secrets + */ +registerPluginSettings( + 'headlamp-sealed-secrets', + () => ( + + + + ), + true // Display save button +); diff --git a/headlamp-sealed-secrets/src/lib/SealedSecretCRD.ts b/headlamp-sealed-secrets/src/lib/SealedSecretCRD.ts index e82abc1..86677be 100644 --- a/headlamp-sealed-secrets/src/lib/SealedSecretCRD.ts +++ b/headlamp-sealed-secrets/src/lib/SealedSecretCRD.ts @@ -101,7 +101,9 @@ export class SealedSecret extends KubeObject { if (!condition) { return 'Unknown'; } - return condition.message || condition.reason || condition.status; + // Ensure we always return a string, not an object + const message = condition.message || condition.reason || condition.status; + return String(message || 'Unknown'); } /** @@ -120,21 +122,11 @@ export class SealedSecret extends KubeObject { } const result = await tryCatchAsync(async () => { - // Query the CRD to get available versions - const response = await fetch( + // Query the CRD to get available versions using Headlamp's API proxy + const crd = await ApiProxy.request( '/apis/apiextensions.k8s.io/v1/customresourcedefinitions/sealedsecrets.bitnami.com' ); - if (!response.ok) { - if (response.status === 404) { - throw new Error('SealedSecrets CRD not found. Please install Sealed Secrets on the cluster.'); - } - const errorText = await response.text().catch(() => response.statusText); - throw new Error(`Failed to fetch CRD (${response.status} ${response.statusText}): ${errorText}`); - } - - const crd = await response.json(); - // Find the storage version (the version used for persistence) const storageVersion = crd.spec?.versions?.find((v: any) => v.storage === true);