Compare commits

...

9 Commits

Author SHA1 Message Date
github-actions[bot] c63afb1461 chore: release v0.2.14 2026-02-13 12:39:41 +00:00
Chris Farhood 3429b32625 chore: bump version to 0.2.14 2026-02-13 07:38:45 -05:00
Chris Farhood 5cf360b591 fix: enable drawer scrolling, fix blank settings page, and eliminate retry button requirement
- Add overflow: auto to drawer Box wrapper for vertical scrolling
- Remove unnecessary SectionBox wrapper from SettingsPage (Headlamp provides container)
- Add param guard to prevent race condition on initial detail view load

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-02-13 07:38:45 -05:00
github-actions[bot] 889504962d chore: release v0.2.13 2026-02-13 11:34:16 +00:00
Chris Farhood 7b51df5ce5 chore: bump version to 0.2.13 2026-02-13 06:33:22 -05:00
Chris Farhood a3b860c1f5 fix: use friendly name 'Sealed Secrets' in settings UI
Changed plugin settings registration name from 'headlamp-sealed-secrets'
to 'Sealed Secrets' for better user experience in Settings → Plugins.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-02-13 06:33:22 -05:00
github-actions[bot] 4efe88cf6e chore: release v0.2.12 2026-02-13 02:41:59 +00:00
Chris Farhood 0ded85fe23 chore: bump version to 0.2.12 2026-02-12 21:41:08 -05:00
Chris Farhood b08df4fb76 feat: improve UX with drawer detail view and proper settings placement
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 <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-02-12 21:41:08 -05:00
8 changed files with 167 additions and 104 deletions
+4 -4
View File
@@ -1,13 +1,13 @@
# Artifact Hub package metadata file # Artifact Hub package metadata file
# https://github.com/artifacthub/hub/blob/master/docs/metadata/artifacthub-pkg.yml # https://github.com/artifacthub/hub/blob/master/docs/metadata/artifacthub-pkg.yml
version: 0.2.10 version: 0.2.14
name: headlamp-sealed-secrets name: headlamp-sealed-secrets
displayName: Sealed Secrets Plugin for Headlamp displayName: Sealed Secrets Plugin for Headlamp
createdAt: "2026-02-12T00:00:00Z" createdAt: "2026-02-12T00:00:00Z"
description: A comprehensive Headlamp plugin for managing Bitnami Sealed Secrets with client-side encryption and RBAC-aware UI description: A comprehensive Headlamp plugin for managing Bitnami Sealed Secrets with client-side encryption and RBAC-aware UI
license: Apache-2.0 license: Apache-2.0
homeURL: https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin homeURL: https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin
appVersion: 0.2.10 appVersion: 0.2.14
containersImages: containersImages:
- name: sealed-secrets-controller - name: sealed-secrets-controller
image: docker.io/bitnami/sealed-secrets-controller:v0.24.0 image: docker.io/bitnami/sealed-secrets-controller:v0.24.0
@@ -19,8 +19,8 @@ keywords:
- encryption - encryption
- security - security
annotations: annotations:
headlamp/plugin/archive-url: "https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/releases/download/v0.2.10/headlamp-sealed-secrets-0.2.10.tar.gz" headlamp/plugin/archive-url: "https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/releases/download/v0.2.14/headlamp-sealed-secrets-0.2.14.tar.gz"
headlamp/plugin/archive-checksum: sha256:f78b772929d9e26dcbb34a52c233f79219ee010619ab34469e3c9bf12ee81435 headlamp/plugin/archive-checksum: sha256:58443a68eb8169b02da77f4822edb88b7831990442c62fe394c54e462edfdbcf
headlamp/plugin/version-compat: ">=0.13.0" headlamp/plugin/version-compat: ">=0.13.0"
headlamp/plugin/distro-compat: "desktop,in-cluster,web,docker-desktop" headlamp/plugin/distro-compat: "desktop,in-cluster,web,docker-desktop"
links: links:
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "headlamp-sealed-secrets", "name": "headlamp-sealed-secrets",
"version": "0.2.10", "version": "0.2.14",
"description": "Headlamp plugin for Bitnami Sealed Secrets - manage encrypted Kubernetes secrets", "description": "Headlamp plugin for Bitnami Sealed Secrets - manage encrypted Kubernetes secrets",
"files": [ "files": [
"dist", "dist",
@@ -35,6 +35,10 @@ abstract class BaseErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoun
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Error caught by boundary:', error, errorInfo); console.error('Error caught by boundary:', error, errorInfo);
console.error('Error type:', typeof error);
console.error('Error keys:', Object.keys(error));
console.error('Error message:', error.message);
console.error('Error toString:', String(error));
this.setState({ errorInfo }); this.setState({ errorInfo });
} }
@@ -95,7 +99,14 @@ export class CryptoErrorBoundary extends BaseErrorBoundary {
variant="body2" variant="body2"
sx={{ mt: 2, fontFamily: 'monospace', fontSize: '0.875rem' }} 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]';
}
})()}
</Typography> </Typography>
)} )}
</Alert> </Alert>
@@ -143,7 +154,14 @@ export class ApiErrorBoundary extends BaseErrorBoundary {
variant="body2" variant="body2"
sx={{ mt: 2, fontFamily: 'monospace', fontSize: '0.875rem' }} 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]';
}
})()}
</Typography> </Typography>
)} )}
</Alert> </Alert>
@@ -182,7 +200,14 @@ export class GenericErrorBoundary extends BaseErrorBoundary {
variant="body2" variant="body2"
sx={{ mt: 2, fontFamily: 'monospace', fontSize: '0.875rem' }} 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]';
}
})()}
</Typography> </Typography>
)} )}
</Alert> </Alert>
@@ -5,6 +5,7 @@
* encrypted data, template, resulting Secret, and actions * encrypted data, template, resulting Secret, and actions
*/ */
import { Icon } from '@iconify/react';
import { K8s } from '@kinvolk/headlamp-plugin/lib'; import { K8s } from '@kinvolk/headlamp-plugin/lib';
import { Link } from '@kinvolk/headlamp-plugin/lib/CommonComponents'; import { Link } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import { import {
@@ -13,7 +14,18 @@ import {
SimpleTable, SimpleTable,
StatusLabel, StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents'; } 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 { useSnackbar } from 'notistack';
import React from 'react'; import React from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
@@ -46,7 +58,7 @@ function formatScope(scope: SealedSecretScope): string {
*/ */
export function SealedSecretDetail() { export function SealedSecretDetail() {
const { namespace, name } = useParams<{ namespace: string; name: string }>(); 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 [secret] = K8s.ResourceClasses.Secret.useGet(name, namespace);
const [decryptKey, setDecryptKey] = React.useState<string | null>(null); const [decryptKey, setDecryptKey] = React.useState<string | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false);
@@ -62,6 +74,27 @@ export function SealedSecretDetail() {
} }
}, [namespace]); }, [namespace]);
// Wait for required params before rendering
if (!namespace || !name) {
return <SealedSecretDetailSkeleton />;
}
// Show error if fetch failed
if (error) {
return (
<Box p={3}>
<Alert severity="error">
<Typography variant="h6" gutterBottom>
Failed to load SealedSecret
</Typography>
<Typography variant="body2">
{String(error)}
</Typography>
</Alert>
</Box>
);
}
// Show loading skeleton while data is being fetched // Show loading skeleton while data is being fetched
if (!sealedSecret) { if (!sealedSecret) {
return <SealedSecretDetailSkeleton />; return <SealedSecretDetailSkeleton />;
@@ -94,16 +127,41 @@ export function SealedSecretDetail() {
} }
}, [sealedSecret, enqueueSnackbar]); }, [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 <SealedSecretDetailSkeleton />;
}
const encryptedKeys = Object.keys(sealedSecret.spec.encryptedData);
const handleClose = () => {
window.history.back();
};
return ( return (
<> <>
<Box> <Drawer
<SectionBox anchor="right"
title={ open
<Box display="flex" alignItems="center" justifyContent="space-between"> onClose={handleClose}
<span>{sealedSecret.metadata.name}</span> PaperProps={{
<Box> sx: {
width: { xs: '100%', sm: '600px', md: '800px' },
maxWidth: '100%',
},
}}
>
<Box sx={{ height: '100%', overflow: 'auto' }}>
<SectionBox
title={
<Box display="flex" alignItems="center" justifyContent="space-between">
<Box display="flex" alignItems="center" gap={1}>
<IconButton onClick={handleClose} edge="start" size="small">
<Icon icon="mdi:close" />
</IconButton>
<span>{sealedSecret.metadata.name}</span>
</Box>
<Box>
{permissions?.canUpdate && ( {permissions?.canUpdate && (
<Button <Button
variant="outlined" variant="outlined"
@@ -131,15 +189,15 @@ export function SealedSecretDetail() {
rows={[ rows={[
{ {
name: 'Name', name: 'Name',
value: sealedSecret.metadata.name, value: String(sealedSecret.metadata.name || ''),
}, },
{ {
name: 'Namespace', name: 'Namespace',
value: sealedSecret.metadata.namespace, value: String(sealedSecret.metadata.namespace || ''),
}, },
{ {
name: 'Scope', name: 'Scope',
value: formatScope(sealedSecret.scope), value: String(formatScope(sealedSecret.scope)),
}, },
{ {
name: 'Sync Status', name: 'Sync Status',
@@ -151,16 +209,18 @@ export function SealedSecretDetail() {
}, },
{ {
name: 'Status Message', name: 'Status Message',
value: sealedSecret.syncMessage, value: String(sealedSecret.syncMessage || 'Unknown'),
hide: !sealedSecret.syncCondition, hide: !sealedSecret.syncCondition,
}, },
{ {
name: 'Age', name: 'Age',
value: sealedSecret.getAge(), value: String(sealedSecret.getAge() || ''),
}, },
{ {
name: 'Created', name: 'Created',
value: new Date(sealedSecret.metadata.creationTimestamp!).toLocaleString(), value: sealedSecret.metadata.creationTimestamp
? new Date(sealedSecret.metadata.creationTimestamp).toLocaleString()
: 'Unknown',
}, },
]} ]}
/> />
@@ -207,16 +267,18 @@ export function SealedSecretDetail() {
rows={[ rows={[
{ {
name: 'Secret Type', name: 'Secret Type',
value: sealedSecret.spec.template.type || 'Opaque', value: String(sealedSecret.spec.template.type || 'Opaque'),
}, },
{ {
name: 'Labels', name: 'Labels',
value: JSON.stringify(sealedSecret.spec.template.metadata?.labels || {}), value: String(JSON.stringify(sealedSecret.spec.template.metadata?.labels || {})),
hide: !sealedSecret.spec.template.metadata?.labels, hide: !sealedSecret.spec.template.metadata?.labels,
}, },
{ {
name: 'Annotations', name: 'Annotations',
value: JSON.stringify(sealedSecret.spec.template.metadata?.annotations || {}), value: String(
JSON.stringify(sealedSecret.spec.template.metadata?.annotations || {})
),
hide: !sealedSecret.spec.template.metadata?.annotations, hide: !sealedSecret.spec.template.metadata?.annotations,
}, },
]} ]}
@@ -234,7 +296,7 @@ export function SealedSecretDetail() {
}, },
{ {
name: 'Keys', name: 'Keys',
value: Object.keys(secret.data || {}).join(', '), value: String(Object.keys(secret.data || {}).join(', ') || 'None'),
}, },
{ {
name: 'Link', name: 'Link',
@@ -242,8 +304,8 @@ export function SealedSecretDetail() {
<Link <Link
routeName="secret" routeName="secret"
params={{ params={{
namespace: secret.metadata.namespace, namespace: String(secret.metadata.namespace || ''),
name: secret.metadata.name, name: String(secret.metadata.name || ''),
}} }}
> >
View Secret View Secret
@@ -259,29 +321,30 @@ export function SealedSecretDetail() {
</Box> </Box>
)} )}
</SectionBox> </SectionBox>
</Box> </Box>
{decryptKey && ( {decryptKey && (
<DecryptDialog <DecryptDialog
sealedSecret={sealedSecret} sealedSecret={sealedSecret}
secretKey={decryptKey} secretKey={decryptKey}
onClose={() => setDecryptKey(null)} onClose={() => setDecryptKey(null)}
/> />
)} )}
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}> <Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}>
<DialogTitle>Delete SealedSecret?</DialogTitle> <DialogTitle>Delete SealedSecret?</DialogTitle>
<DialogContent> <DialogContent>
Are you sure you want to delete the SealedSecret <strong>{name}</strong>? This will also Are you sure you want to delete the SealedSecret <strong>{name}</strong>? This will also
delete the resulting Kubernetes Secret. delete the resulting Kubernetes Secret.
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button> <Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
<Button onClick={handleDelete} color="error"> <Button onClick={handleDelete} color="error">
Delete Delete
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
</Drawer>
</> </>
); );
} }
@@ -13,11 +13,13 @@ import {
} from '@kinvolk/headlamp-plugin/lib/CommonComponents'; } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import { Box, Button } from '@mui/material'; import { Box, Button } from '@mui/material';
import React from 'react'; import React from 'react';
import { useParams } from 'react-router-dom';
import { usePermission } from '../hooks/usePermissions'; import { usePermission } from '../hooks/usePermissions';
import { SealedSecret } from '../lib/SealedSecretCRD'; import { SealedSecret } from '../lib/SealedSecretCRD';
import { SealedSecretScope } from '../types'; import { SealedSecretScope } from '../types';
import { EncryptDialog } from './EncryptDialog'; import { EncryptDialog } from './EncryptDialog';
import { SealedSecretListSkeleton } from './LoadingSkeletons'; import { SealedSecretListSkeleton } from './LoadingSkeletons';
import { SealedSecretDetail } from './SealedSecretDetail';
import { VersionWarning } from './VersionWarning'; import { VersionWarning } from './VersionWarning';
/** /**
@@ -40,6 +42,7 @@ function formatScope(scope: SealedSecretScope): string {
* SealedSecrets list view component * SealedSecrets list view component
*/ */
export function SealedSecretList() { export function SealedSecretList() {
const { namespace, name } = useParams<{ namespace?: string; name?: string }>();
const [sealedSecrets, error, loading] = SealedSecret.useList(); const [sealedSecrets, error, loading] = SealedSecret.useList();
const [createDialogOpen, setCreateDialogOpen] = React.useState(false); const [createDialogOpen, setCreateDialogOpen] = React.useState(false);
const { allowed: canCreate } = usePermission(undefined, 'canCreate'); const { allowed: canCreate } = usePermission(undefined, 'canCreate');
@@ -159,6 +162,8 @@ export function SealedSecretList() {
</SectionBox> </SectionBox>
<EncryptDialog open={createDialogOpen} onClose={handleCloseDialog} /> <EncryptDialog open={createDialogOpen} onClose={handleCloseDialog} />
{namespace && name && <SealedSecretDetail />}
</> </>
); );
} }
@@ -4,7 +4,6 @@
* Configuration page for the Sealed Secrets plugin * Configuration page for the Sealed Secrets plugin
*/ */
import { SectionBox } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import { Box, Button, Divider, TextField, Typography } from '@mui/material'; import { Box, Button, Divider, TextField, Typography } from '@mui/material';
import { useSnackbar } from 'notistack'; import { useSnackbar } from 'notistack';
import React from 'react'; import React from 'react';
@@ -35,10 +34,7 @@ export function SettingsPage() {
}; };
return ( return (
<SectionBox <Box p={3}>
title="Sealed Secrets Plugin Settings"
>
<Box p={3}>
<Typography variant="body1" paragraph id="settings-description"> <Typography variant="body1" paragraph id="settings-description">
Configure the connection to your Sealed Secrets controller. These settings are stored in Configure the connection to your Sealed Secrets controller. These settings are stored in
your browser's local storage. your browser's local storage.
@@ -156,6 +152,5 @@ export function SettingsPage() {
</Typography> </Typography>
</Box> </Box>
</Box> </Box>
</SectionBox>
); );
} }
+18 -35
View File
@@ -16,12 +16,12 @@
import { import {
registerDetailsViewSection, registerDetailsViewSection,
registerPluginSettings,
registerRoute, registerRoute,
registerSidebarEntry, registerSidebarEntry,
} from '@kinvolk/headlamp-plugin/lib'; } from '@kinvolk/headlamp-plugin/lib';
import React from 'react'; import React from 'react';
import { ApiErrorBoundary, GenericErrorBoundary } from './components/ErrorBoundary'; import { ApiErrorBoundary, GenericErrorBoundary } from './components/ErrorBoundary';
import { SealedSecretDetail } from './components/SealedSecretDetail';
import { SealedSecretList } from './components/SealedSecretList'; import { SealedSecretList } from './components/SealedSecretList';
import { SealingKeysView } from './components/SealingKeysView'; import { SealingKeysView } from './components/SealingKeysView';
import { SecretDetailsSection } from './components/SecretDetailsSection'; import { SecretDetailsSection } from './components/SecretDetailsSection';
@@ -56,21 +56,13 @@ registerSidebarEntry({
url: '/sealedsecrets/keys', url: '/sealedsecrets/keys',
}); });
// "Settings" child entry
registerSidebarEntry({
parent: 'sealed-secrets',
name: 'sealed-secrets-settings',
label: 'Settings',
url: '/sealedsecrets/settings',
});
/** /**
* Register routes * Register routes
*/ */
// List view // List view with optional detail drawer
registerRoute({ registerRoute({
path: '/sealedsecrets', path: '/sealedsecrets/:namespace?/:name?',
sidebar: 'sealed-secrets-list', sidebar: 'sealed-secrets-list',
component: () => ( component: () => (
<ApiErrorBoundary> <ApiErrorBoundary>
@@ -78,18 +70,6 @@ registerRoute({
</ApiErrorBoundary> </ApiErrorBoundary>
), ),
exact: true, exact: true,
});
// Detail view
registerRoute({
path: '/sealedsecrets/:namespace/:name',
sidebar: 'sealed-secrets-list',
component: () => (
<ApiErrorBoundary>
<SealedSecretDetail />
</ApiErrorBoundary>
),
exact: true,
name: 'sealedsecret', name: 'sealedsecret',
}); });
@@ -105,18 +85,6 @@ registerRoute({
exact: true, exact: true,
}); });
// Settings page
registerRoute({
path: '/sealedsecrets/settings',
sidebar: 'sealed-secrets-settings',
component: () => (
<GenericErrorBoundary>
<SettingsPage />
</GenericErrorBoundary>
),
exact: true,
});
/** /**
* Register integration with Secret detail view * Register integration with Secret detail view
* *
@@ -132,3 +100,18 @@ registerDetailsViewSection(({ resource }) => {
} }
return null; return null;
}); });
/**
* Register plugin settings
*
* Settings will appear in Settings → Plugins → Sealed Secrets
*/
registerPluginSettings(
'Sealed Secrets',
() => (
<GenericErrorBoundary>
<SettingsPage />
</GenericErrorBoundary>
),
true // Display save button
);
@@ -101,7 +101,9 @@ export class SealedSecret extends KubeObject<SealedSecretInterface> {
if (!condition) { if (!condition) {
return 'Unknown'; 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<SealedSecretInterface> {
} }
const result = await tryCatchAsync(async () => { const result = await tryCatchAsync(async () => {
// Query the CRD to get available versions // Query the CRD to get available versions using Headlamp's API proxy
const response = await fetch( const crd = await ApiProxy.request(
'/apis/apiextensions.k8s.io/v1/customresourcedefinitions/sealedsecrets.bitnami.com' '/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) // Find the storage version (the version used for persistence)
const storageVersion = crd.spec?.versions?.find((v: any) => v.storage === true); const storageVersion = crd.spec?.versions?.find((v: any) => v.storage === true);