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>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -35,6 +35,10 @@ abstract class BaseErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoun
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.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 });
|
||||
}
|
||||
|
||||
@@ -95,7 +99,14 @@ export class CryptoErrorBoundary 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]';
|
||||
}
|
||||
})()}
|
||||
</Typography>
|
||||
)}
|
||||
</Alert>
|
||||
@@ -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]';
|
||||
}
|
||||
})()}
|
||||
</Typography>
|
||||
)}
|
||||
</Alert>
|
||||
@@ -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]';
|
||||
}
|
||||
})()}
|
||||
</Typography>
|
||||
)}
|
||||
</Alert>
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false);
|
||||
@@ -62,6 +74,22 @@ export function SealedSecretDetail() {
|
||||
}
|
||||
}, [namespace]);
|
||||
|
||||
// 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
|
||||
if (!sealedSecret) {
|
||||
return <SealedSecretDetailSkeleton />;
|
||||
@@ -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 <SealedSecretDetailSkeleton />;
|
||||
}
|
||||
|
||||
const encryptedKeys = Object.keys(sealedSecret.spec.encryptedData);
|
||||
|
||||
const handleClose = () => {
|
||||
window.history.back();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box>
|
||||
<SectionBox
|
||||
title={
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between">
|
||||
<span>{sealedSecret.metadata.name}</span>
|
||||
<Box>
|
||||
<Drawer
|
||||
anchor="right"
|
||||
open
|
||||
onClose={handleClose}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
width: { xs: '100%', sm: '600px', md: '800px' },
|
||||
maxWidth: '100%',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<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 && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
@@ -131,15 +184,15 @@ export function SealedSecretDetail() {
|
||||
rows={[
|
||||
{
|
||||
name: 'Name',
|
||||
value: sealedSecret.metadata.name,
|
||||
value: String(sealedSecret.metadata.name || ''),
|
||||
},
|
||||
{
|
||||
name: 'Namespace',
|
||||
value: sealedSecret.metadata.namespace,
|
||||
value: String(sealedSecret.metadata.namespace || ''),
|
||||
},
|
||||
{
|
||||
name: 'Scope',
|
||||
value: formatScope(sealedSecret.scope),
|
||||
value: String(formatScope(sealedSecret.scope)),
|
||||
},
|
||||
{
|
||||
name: 'Sync Status',
|
||||
@@ -151,16 +204,18 @@ export function SealedSecretDetail() {
|
||||
},
|
||||
{
|
||||
name: 'Status Message',
|
||||
value: sealedSecret.syncMessage,
|
||||
value: String(sealedSecret.syncMessage || 'Unknown'),
|
||||
hide: !sealedSecret.syncCondition,
|
||||
},
|
||||
{
|
||||
name: 'Age',
|
||||
value: sealedSecret.getAge(),
|
||||
value: String(sealedSecret.getAge() || ''),
|
||||
},
|
||||
{
|
||||
name: 'Created',
|
||||
value: new Date(sealedSecret.metadata.creationTimestamp!).toLocaleString(),
|
||||
value: sealedSecret.metadata.creationTimestamp
|
||||
? new Date(sealedSecret.metadata.creationTimestamp).toLocaleString()
|
||||
: 'Unknown',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -207,16 +262,18 @@ export function SealedSecretDetail() {
|
||||
rows={[
|
||||
{
|
||||
name: 'Secret Type',
|
||||
value: sealedSecret.spec.template.type || 'Opaque',
|
||||
value: String(sealedSecret.spec.template.type || 'Opaque'),
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
]}
|
||||
@@ -234,7 +291,7 @@ export function SealedSecretDetail() {
|
||||
},
|
||||
{
|
||||
name: 'Keys',
|
||||
value: Object.keys(secret.data || {}).join(', '),
|
||||
value: String(Object.keys(secret.data || {}).join(', ') || 'None'),
|
||||
},
|
||||
{
|
||||
name: 'Link',
|
||||
@@ -242,8 +299,8 @@ export function SealedSecretDetail() {
|
||||
<Link
|
||||
routeName="secret"
|
||||
params={{
|
||||
namespace: secret.metadata.namespace,
|
||||
name: secret.metadata.name,
|
||||
namespace: String(secret.metadata.namespace || ''),
|
||||
name: String(secret.metadata.name || ''),
|
||||
}}
|
||||
>
|
||||
View Secret
|
||||
@@ -259,29 +316,30 @@ export function SealedSecretDetail() {
|
||||
</Box>
|
||||
)}
|
||||
</SectionBox>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{decryptKey && (
|
||||
<DecryptDialog
|
||||
sealedSecret={sealedSecret}
|
||||
secretKey={decryptKey}
|
||||
onClose={() => setDecryptKey(null)}
|
||||
/>
|
||||
)}
|
||||
{decryptKey && (
|
||||
<DecryptDialog
|
||||
sealedSecret={sealedSecret}
|
||||
secretKey={decryptKey}
|
||||
onClose={() => setDecryptKey(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}>
|
||||
<DialogTitle>Delete SealedSecret?</DialogTitle>
|
||||
<DialogContent>
|
||||
Are you sure you want to delete the SealedSecret <strong>{name}</strong>? This will also
|
||||
delete the resulting Kubernetes Secret.
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleDelete} color="error">
|
||||
Delete
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}>
|
||||
<DialogTitle>Delete SealedSecret?</DialogTitle>
|
||||
<DialogContent>
|
||||
Are you sure you want to delete the SealedSecret <strong>{name}</strong>? This will also
|
||||
delete the resulting Kubernetes Secret.
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleDelete} color="error">
|
||||
Delete
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
</SectionBox>
|
||||
|
||||
<EncryptDialog open={createDialogOpen} onClose={handleCloseDialog} />
|
||||
|
||||
{namespace && name && <SealedSecretDetail />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: () => (
|
||||
<ApiErrorBoundary>
|
||||
@@ -78,18 +70,6 @@ registerRoute({
|
||||
</ApiErrorBoundary>
|
||||
),
|
||||
exact: true,
|
||||
});
|
||||
|
||||
// Detail view
|
||||
registerRoute({
|
||||
path: '/sealedsecrets/:namespace/:name',
|
||||
sidebar: 'sealed-secrets-list',
|
||||
component: () => (
|
||||
<ApiErrorBoundary>
|
||||
<SealedSecretDetail />
|
||||
</ApiErrorBoundary>
|
||||
),
|
||||
exact: true,
|
||||
name: 'sealedsecret',
|
||||
});
|
||||
|
||||
@@ -105,18 +85,6 @@ registerRoute({
|
||||
exact: true,
|
||||
});
|
||||
|
||||
// Settings page
|
||||
registerRoute({
|
||||
path: '/sealedsecrets/settings',
|
||||
sidebar: 'sealed-secrets-settings',
|
||||
component: () => (
|
||||
<GenericErrorBoundary>
|
||||
<SettingsPage />
|
||||
</GenericErrorBoundary>
|
||||
),
|
||||
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',
|
||||
() => (
|
||||
<GenericErrorBoundary>
|
||||
<SettingsPage />
|
||||
</GenericErrorBoundary>
|
||||
),
|
||||
true // Display save button
|
||||
);
|
||||
|
||||
@@ -101,7 +101,9 @@ export class SealedSecret extends KubeObject<SealedSecretInterface> {
|
||||
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<SealedSecretInterface> {
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user