chore: move source to repo root and standardize config

Phase 1 — Structural overhaul:
- Move all source from headlamp-sealed-secrets/ subdirectory to repo root
- Delete 23 AI-generated docs, 8 pre-built tarballs, release snapshots dir
- Remove all working-directory refs from CI/release workflows
- Update install-plugin.sh and typedoc.json paths

Phase 2 — Config standardization:
- Create .eslintrc.js and .prettierrc.js (standard Headlamp configs)
- Remove inline eslintConfig/prettier from package.json (drop jsx-a11y, prettier extends)
- Rewrite tsconfig.json (package name extend, add compilerOptions.types)
- Create vitest.config.mts and vitest.setup.ts (standard from polaris)
- Replace headlamp-plugin CLI scripts with direct tool invocation
- Rewrite .gitignore with standard baseline

Phase 3 — MCP & Claude settings:
- Create .mcp.json with github/kubernetes/flux/playwright servers
- Create .claude/settings.local.json
- Remove 7 specialized agents, keep 3 meta-orchestration agents

Phase 4 — Documentation:
- Rewrite CLAUDE.md (remove subdirectory refs, standard format)
- Add ArtifactHub badge, Architecture section, standardized install methods to README.md
- Create CONTRIBUTING.md and SECURITY.md
- Fix pre-existing test bugs in validators.test.ts (isValidNamespace returns boolean,
  not ValidationResult; error message string mismatches)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
DevContainer User
2026-03-03 21:31:12 +00:00
parent 604fe06f9c
commit af95c3795c
108 changed files with 704 additions and 14041 deletions
+84
View File
@@ -0,0 +1,84 @@
/**
* Controller Status Indicator
*
* Displays the health status of the sealed-secrets controller,
* including reachability, response time, and version information.
*/
import { Icon } from '@iconify/react';
import { Box, Chip, Tooltip, Typography } from '@mui/material';
import React from 'react';
import { useControllerHealth } from '../hooks/useControllerHealth';
import { ControllerHealthSkeleton } from './LoadingSkeletons';
interface ControllerStatusProps {
/** Whether to auto-refresh the status */
autoRefresh?: boolean;
/** Refresh interval in milliseconds (default: 30000 = 30s) */
refreshIntervalMs?: number;
/** Show detailed info (latency, version) */
showDetails?: boolean;
}
/**
* Controller status indicator component
*/
export function ControllerStatus({
autoRefresh = false,
refreshIntervalMs = 30000,
showDetails = true,
}: ControllerStatusProps) {
const { health: status, loading } = useControllerHealth(autoRefresh, refreshIntervalMs);
// Show skeleton while loading
if (loading || !status) {
return <ControllerHealthSkeleton />;
}
// Build status message and icon
let statusColor: 'success' | 'error' | 'warning' = 'error';
let statusIcon = 'mdi:alert-circle';
let statusLabel = 'Unreachable';
let tooltipText = status.error || 'Controller is unreachable';
if (status.healthy) {
statusColor = 'success';
statusIcon = 'mdi:check-circle';
statusLabel = 'Healthy';
tooltipText = `Controller is healthy${status.version ? ` (${status.version})` : ''}`;
} else if (status.reachable) {
statusColor = 'warning';
statusIcon = 'mdi:alert';
statusLabel = 'Unhealthy';
tooltipText = status.error || 'Controller responded but is not healthy';
}
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Tooltip title={tooltipText}>
<Chip
icon={<Icon icon={statusIcon} />}
label={statusLabel}
color={statusColor}
size="small"
variant="outlined"
/>
</Tooltip>
{showDetails && status.healthy && (
<>
{status.latencyMs !== undefined && (
<Typography variant="caption" color="text.secondary">
{status.latencyMs}ms
</Typography>
)}
{status.version && (
<Typography variant="caption" color="text.secondary">
v{status.version}
</Typography>
)}
</>
)}
</Box>
);
}
+189
View File
@@ -0,0 +1,189 @@
/**
* Decrypt Dialog
*
* Shows the decrypted value of a secret key by reading the resulting
* Kubernetes Secret (requires RBAC permissions to read secrets)
*/
import { Icon } from '@iconify/react';
import { K8s } from '@kinvolk/headlamp-plugin/lib';
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
TextField,
Typography,
} from '@mui/material';
import { useSnackbar } from 'notistack';
import React from 'react';
import { SealedSecret } from '../lib/SealedSecretCRD';
interface DecryptDialogProps {
sealedSecret: SealedSecret;
secretKey: string;
onClose: () => void;
}
/**
* Decrypt dialog component
*/
export function DecryptDialog({ sealedSecret, secretKey, onClose }: DecryptDialogProps) {
const [secret] = K8s.ResourceClasses.Secret.useGet(
sealedSecret.metadata.name,
sealedSecret.metadata.namespace
);
const [showValue, setShowValue] = React.useState(false);
const [countdown, setCountdown] = React.useState(30);
const { enqueueSnackbar } = useSnackbar();
// Auto-hide after 30 seconds
React.useEffect(() => {
const timer = setInterval(() => {
setCountdown(prev => {
if (prev <= 1) {
onClose();
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(timer);
}, [onClose]);
const handleCopy = () => {
if (secret && secret.data?.[secretKey]) {
const decoded = atob(secret.data[secretKey]);
navigator.clipboard.writeText(decoded);
enqueueSnackbar('Copied to clipboard', { variant: 'success' });
}
};
// Check if secret exists
if (!secret) {
return (
<Dialog
open
onClose={onClose}
aria-labelledby="decrypt-error-title"
aria-describedby="decrypt-error-description"
>
<DialogTitle id="decrypt-error-title">Secret Not Found</DialogTitle>
<DialogContent>
<Typography id="decrypt-error-description">
The Kubernetes Secret for this SealedSecret has not been created yet, or you don't have
permission to read it.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={onClose} aria-label="Close dialog">Close</Button>
</DialogActions>
</Dialog>
);
}
// Check if key exists
const encodedValue = secret.data?.[secretKey];
if (!encodedValue) {
return (
<Dialog
open
onClose={onClose}
aria-labelledby="decrypt-key-error-title"
aria-describedby="decrypt-key-error-description"
>
<DialogTitle id="decrypt-key-error-title">Key Not Found</DialogTitle>
<DialogContent>
<Typography id="decrypt-key-error-description">
The key <strong>{secretKey}</strong> was not found in the Secret.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={onClose} aria-label="Close dialog">Close</Button>
</DialogActions>
</Dialog>
);
}
const decodedValue = atob(encodedValue);
return (
<Dialog
open
onClose={onClose}
maxWidth="sm"
fullWidth
aria-labelledby="decrypt-dialog-title"
aria-describedby="decrypt-dialog-description"
>
<DialogTitle id="decrypt-dialog-title">
Decrypted Value: {secretKey}
<Typography
variant="caption"
display="block"
color="text.secondary"
aria-live="polite"
aria-atomic="true"
>
Auto-closing in {countdown} seconds
</Typography>
</DialogTitle>
<DialogContent>
<Box sx={{ mt: 2 }} id="decrypt-dialog-description">
<TextField
fullWidth
multiline
minRows={3}
maxRows={10}
value={decodedValue}
type={showValue ? 'text' : 'password'}
inputProps={{
'aria-label': `Decrypted value for ${secretKey}`,
readOnly: true,
}}
InputProps={{
readOnly: true,
endAdornment: (
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<IconButton
onClick={() => setShowValue(!showValue)}
size="small"
aria-label={showValue ? 'Hide secret value' : 'Show secret value'}
title={showValue ? 'Hide secret value' : 'Show secret value'}
>
{showValue ? <Icon icon="mdi:eye-off" /> : <Icon icon="mdi:eye" />}
</IconButton>
<IconButton
onClick={handleCopy}
size="small"
aria-label="Copy value to clipboard"
title="Copy value to clipboard"
>
<Icon icon="mdi:content-copy" />
</IconButton>
</Box>
),
}}
/>
<Box
sx={{ mt: 2, p: 2, bgcolor: 'warning.light', borderRadius: 1 }}
role="alert"
aria-live="polite"
>
<Typography variant="body2">
<strong>Security Warning:</strong> This value is sensitive. Ensure no one is looking
over your shoulder.
</Typography>
</Box>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose} aria-label="Close dialog">Close</Button>
</DialogActions>
</Dialog>
);
}
+278
View File
@@ -0,0 +1,278 @@
/**
* Encryption Dialog
*
* Dialog for creating new SealedSecrets by encrypting secret values
* client-side using the controller's public certificate
*/
import { Icon } from '@iconify/react';
import { K8s } from '@kinvolk/headlamp-plugin/lib';
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControl,
IconButton,
InputLabel,
MenuItem,
Select,
TextField,
Typography,
} from '@mui/material';
import { useSnackbar } from 'notistack';
import React from 'react';
import { useSealedSecretEncryption } from '../hooks/useSealedSecretEncryption';
import { SealedSecret } from '../lib/SealedSecretCRD';
import { SealedSecretScope, SecretKeyValue } from '../types';
interface EncryptDialogProps {
open: boolean;
onClose: () => void;
}
/**
* Encrypt dialog component
*/
export function EncryptDialog({ open, onClose }: EncryptDialogProps) {
const [name, setName] = React.useState('');
const [namespace, setNamespace] = React.useState('default');
const [scope, setScope] = React.useState<SealedSecretScope>('strict');
const [keyValues, setKeyValues] = React.useState<(SecretKeyValue & { showValue: boolean })[]>([
{ key: '', value: '', showValue: false },
]);
const { enqueueSnackbar } = useSnackbar();
const { encrypt, encrypting } = useSealedSecretEncryption();
const [namespaces] = K8s.ResourceClasses.Namespace.useList();
// Memoize callbacks to prevent re-renders
const handleAddKeyValue = React.useCallback(() => {
setKeyValues(prev => [...prev, { key: '', value: '', showValue: false }]);
}, []);
const handleRemoveKeyValue = React.useCallback((index: number) => {
setKeyValues(prev => prev.filter((_, i) => i !== index));
}, []);
const handleKeyChange = React.useCallback((index: number, key: string) => {
setKeyValues(prev => {
const updated = [...prev];
updated[index] = { ...updated[index], key };
return updated;
});
}, []);
const handleValueChange = React.useCallback((index: number, value: string) => {
setKeyValues(prev => {
const updated = [...prev];
updated[index] = { ...updated[index], value };
return updated;
});
}, []);
const toggleShowValue = React.useCallback((index: number) => {
setKeyValues(prev => {
const updated = [...prev];
updated[index] = { ...updated[index], showValue: !updated[index].showValue };
return updated;
});
}, []);
const handleCreate = async () => {
// Filter out empty rows
const validKeyValues = keyValues.filter(kv => kv.key || kv.value).map(kv => ({
key: kv.key,
value: kv.value,
}));
// Use the encryption hook
const result = await encrypt({
name,
namespace,
scope,
keyValues: validKeyValues,
});
// If encryption failed, the hook already showed the error
if (result.ok === false) {
return;
}
try {
// Apply the SealedSecret to the cluster
await SealedSecret.apiEndpoint.post(result.value.sealedSecretData);
enqueueSnackbar('SealedSecret created successfully', { variant: 'success' });
// Clear form and close
setName('');
setNamespace('default');
setScope('strict');
setKeyValues([{ key: '', value: '', showValue: false }]);
onClose();
} catch (error: any) {
enqueueSnackbar(`Failed to create SealedSecret: ${error.message}`, { variant: 'error' });
}
};
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="md"
fullWidth
aria-labelledby="encrypt-dialog-title"
aria-describedby="encrypt-dialog-description"
>
<DialogTitle id="encrypt-dialog-title">Create Sealed Secret</DialogTitle>
<DialogContent>
<Box sx={{ pt: 2 }} id="encrypt-dialog-description">
<TextField
fullWidth
label="Secret Name"
value={name}
onChange={e => setName(e.target.value)}
margin="normal"
required
inputProps={{
'aria-label': 'Secret name',
'aria-required': true,
}}
helperText="Must be a valid Kubernetes resource name (lowercase alphanumeric, hyphens)"
/>
<FormControl fullWidth margin="normal" required>
<InputLabel id="encrypt-namespace-label">Namespace</InputLabel>
<Select
value={namespace}
label="Namespace"
onChange={e => setNamespace(e.target.value)}
labelId="encrypt-namespace-label"
inputProps={{
'aria-label': 'Namespace for the SealedSecret',
'aria-required': true,
}}
>
{namespaces?.map(ns => (
<MenuItem key={ns.metadata.name} value={ns.metadata.name}>
{ns.metadata.name}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth margin="normal" required>
<InputLabel id="encrypt-scope-label">Scope</InputLabel>
<Select
value={scope}
label="Scope"
onChange={e => setScope(e.target.value as SealedSecretScope)}
labelId="encrypt-scope-label"
inputProps={{
'aria-label': 'Encryption scope for the SealedSecret',
'aria-required': true,
}}
>
<MenuItem value="strict">Strict (name + namespace bound)</MenuItem>
<MenuItem value="namespace-wide">Namespace-wide (namespace bound)</MenuItem>
<MenuItem value="cluster-wide">Cluster-wide (no binding)</MenuItem>
</Select>
</FormControl>
<Typography variant="h6" sx={{ mt: 3, mb: 2 }}>
Secret Data
</Typography>
{keyValues.map((kv, index) => (
<Box
key={index}
sx={{ display: 'flex', gap: 1, mb: 2, alignItems: 'flex-start' }}
role="group"
aria-label={`Secret key-value pair ${index + 1}`}
>
<TextField
label="Key Name"
value={kv.key}
onChange={e => handleKeyChange(index, e.target.value)}
sx={{ flex: 1 }}
inputProps={{
'aria-label': `Key name ${index + 1}`,
}}
helperText={index === 0 ? 'Alphanumeric, hyphens, underscores, or dots' : undefined}
/>
<TextField
label="Secret Value"
type={kv.showValue ? 'text' : 'password'}
value={kv.value}
onChange={e => handleValueChange(index, e.target.value)}
sx={{ flex: 2 }}
autoComplete="off"
spellCheck={false}
inputProps={{
'aria-label': `Secret value for ${kv.key || `key ${index + 1}`}`,
}}
InputProps={{
endAdornment: (
<IconButton
onClick={() => toggleShowValue(index)}
edge="end"
aria-label={kv.showValue ? 'Hide password' : 'Show password'}
tabIndex={0}
>
{kv.showValue ? <Icon icon="mdi:eye-off" /> : <Icon icon="mdi:eye" />}
</IconButton>
),
}}
/>
<IconButton
onClick={() => handleRemoveKeyValue(index)}
disabled={keyValues.length === 1}
color="error"
aria-label={`Remove key-value pair ${index + 1}`}
title={keyValues.length === 1 ? 'At least one key-value pair is required' : `Remove key-value pair ${index + 1}`}
>
<Icon icon="mdi:delete" />
</IconButton>
</Box>
))}
<Button
startIcon={<Icon icon="mdi:plus" />}
onClick={handleAddKeyValue}
aria-label="Add another key-value pair"
>
Add Another Key
</Button>
<Box
sx={{ mt: 2, p: 2, bgcolor: 'info.light', borderRadius: 1 }}
role="note"
aria-live="polite"
>
<Typography variant="body2">
<strong>Security Note:</strong> Secret values are encrypted entirely in your browser
using the controller's public key. The plaintext values never leave your machine.
</Typography>
</Box>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={encrypting} aria-label="Cancel creation">
Cancel
</Button>
<Button
onClick={handleCreate}
variant="contained"
disabled={encrypting}
aria-busy={encrypting}
aria-label={encrypting ? 'Encrypting and creating SealedSecret' : 'Create SealedSecret'}
>
{encrypting ? 'Encrypting & Creating...' : 'Create'}
</Button>
</DialogActions>
</Dialog>
);
}
+217
View File
@@ -0,0 +1,217 @@
/**
* Error Boundary Components
*
* Provides graceful error handling for crypto and API operations
*/
import { Icon } from '@iconify/react';
import { Alert, Box, Button, Typography } from '@mui/material';
import React, { Component, ReactNode } from 'react';
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode;
onReset?: () => void;
}
interface ErrorBoundaryState {
hasError: boolean;
error?: Error;
errorInfo?: React.ErrorInfo;
}
/**
* Base error boundary component
*/
abstract class BaseErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
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 });
}
handleReset = () => {
this.setState({ hasError: false, error: undefined, errorInfo: undefined });
if (this.props.onReset) {
this.props.onReset();
}
};
abstract renderError(): ReactNode;
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return this.renderError();
}
return this.props.children;
}
}
/**
* Error boundary for cryptographic operations
*
* Catches errors during encryption/decryption and provides
* helpful context about what might have gone wrong.
*/
export class CryptoErrorBoundary extends BaseErrorBoundary {
renderError() {
return (
<Box p={3}>
<Alert
severity="error"
icon={<Icon icon="mdi:alert-circle-outline" />}
action={
<Button color="inherit" size="small" onClick={this.handleReset}>
Retry
</Button>
}
>
<Typography variant="h6" gutterBottom>
Cryptographic Operation Failed
</Typography>
<Typography variant="body2" paragraph>
An error occurred during encryption or decryption. This might indicate:
</Typography>
<ul style={{ margin: 0, paddingLeft: 20 }}>
<li>Invalid or expired controller certificate</li>
<li>Browser cryptography compatibility issue</li>
<li>Malformed secret data</li>
<li>Controller not reachable or misconfigured</li>
</ul>
{this.state.error && (
<Typography
variant="body2"
sx={{ mt: 2, fontFamily: 'monospace', fontSize: '0.875rem' }}
>
{(() => {
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>
</Box>
);
}
}
/**
* Error boundary for API operations
*
* Catches errors during Kubernetes API calls and provides
* guidance for troubleshooting connectivity issues.
*/
export class ApiErrorBoundary extends BaseErrorBoundary {
renderError() {
return (
<Box p={3}>
<Alert
severity="error"
icon={<Icon icon="mdi:alert-circle-outline" />}
action={
<Button color="inherit" size="small" onClick={this.handleReset}>
Retry
</Button>
}
>
<Typography variant="h6" gutterBottom>
API Communication Error
</Typography>
<Typography variant="body2" paragraph>
Failed to communicate with the Kubernetes API or Sealed Secrets controller.
</Typography>
<Typography variant="body2" paragraph>
Please verify:
</Typography>
<ul style={{ margin: 0, paddingLeft: 20 }}>
<li>Kubernetes cluster is accessible</li>
<li>Sealed Secrets controller is running</li>
<li>Controller configuration is correct (name, namespace, port)</li>
<li>Network connectivity to the cluster</li>
</ul>
{this.state.error && (
<Typography
variant="body2"
sx={{ mt: 2, fontFamily: 'monospace', fontSize: '0.875rem' }}
>
{(() => {
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>
</Box>
);
}
}
/**
* Generic error boundary for general component errors
*
* Provides a fallback UI when components encounter unexpected errors.
*/
export class GenericErrorBoundary extends BaseErrorBoundary {
renderError() {
return (
<Box p={3}>
<Alert
severity="error"
icon={<Icon icon="mdi:alert-circle-outline" />}
action={
<Button color="inherit" size="small" onClick={this.handleReset}>
Reload
</Button>
}
>
<Typography variant="h6" gutterBottom>
Something Went Wrong
</Typography>
<Typography variant="body2" paragraph>
An unexpected error occurred. Please try reloading the page or contact your administrator
if the problem persists.
</Typography>
{this.state.error && (
<Typography
variant="body2"
sx={{ mt: 2, fontFamily: 'monospace', fontSize: '0.875rem' }}
>
{(() => {
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>
</Box>
);
}
}
+111
View File
@@ -0,0 +1,111 @@
/**
* Loading Skeleton Components
*
* Provides visual feedback during data loading with skeleton screens
* to improve perceived performance and user experience.
*/
import { Box, Skeleton } from '@mui/material';
import React from 'react';
/**
* Skeleton for SealedSecrets list view
*
* Shows placeholder rows while data is loading
*/
export function SealedSecretListSkeleton() {
return (
<Box p={2}>
{[1, 2, 3, 4, 5].map(i => (
<Skeleton
key={i}
variant="rectangular"
height={60}
sx={{ mb: 1, borderRadius: 1 }}
animation="wave"
/>
))}
</Box>
);
}
/**
* Skeleton for SealedSecret detail view
*
* Shows placeholder sections while resource is loading
*/
export function SealedSecretDetailSkeleton() {
return (
<Box p={3}>
{/* Title */}
<Skeleton variant="text" width="40%" height={40} sx={{ mb: 3 }} animation="wave" />
{/* Metadata section */}
<Skeleton variant="rectangular" height={200} sx={{ mb: 2, borderRadius: 1 }} animation="wave" />
{/* Encrypted data section */}
<Skeleton variant="rectangular" height={150} sx={{ mb: 2, borderRadius: 1 }} animation="wave" />
{/* Actions section */}
<Box sx={{ display: 'flex', gap: 2 }}>
<Skeleton variant="rectangular" width={120} height={36} sx={{ borderRadius: 1 }} animation="wave" />
<Skeleton variant="rectangular" width={120} height={36} sx={{ borderRadius: 1 }} animation="wave" />
</Box>
</Box>
);
}
/**
* Skeleton for sealing keys list view
*
* Shows placeholder for certificate information
*/
export function SealingKeysListSkeleton() {
return (
<Box p={2}>
{[1, 2].map(i => (
<Box key={i} sx={{ mb: 3 }}>
<Skeleton variant="text" width="30%" height={32} sx={{ mb: 1 }} animation="wave" />
<Skeleton variant="rectangular" height={100} sx={{ borderRadius: 1, mb: 1 }} animation="wave" />
<Box sx={{ display: 'flex', gap: 1 }}>
<Skeleton variant="rectangular" width={100} height={28} sx={{ borderRadius: 1 }} animation="wave" />
<Skeleton variant="rectangular" width={100} height={28} sx={{ borderRadius: 1 }} animation="wave" />
</Box>
</Box>
))}
</Box>
);
}
/**
* Skeleton for certificate information
*
* Shows placeholder for certificate metadata
*/
export function CertificateInfoSkeleton() {
return (
<Box>
<Skeleton variant="text" width="60%" animation="wave" />
<Skeleton variant="text" width="40%" animation="wave" />
<Skeleton variant="text" width="50%" animation="wave" />
<Skeleton variant="text" width="45%" animation="wave" />
</Box>
);
}
/**
* Skeleton for controller health status
*
* Shows placeholder for health check information
*/
export function ControllerHealthSkeleton() {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Skeleton variant="circular" width={40} height={40} animation="wave" />
<Box sx={{ flex: 1 }}>
<Skeleton variant="text" width="40%" animation="wave" />
<Skeleton variant="text" width="60%" animation="wave" />
</Box>
</Box>
);
}
+350
View File
@@ -0,0 +1,350 @@
/**
* SealedSecret Detail View
*
* Shows detailed information about a specific SealedSecret including
* 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 {
NameValueTable,
SectionBox,
SimpleTable,
StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
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';
import { usePermissions } from '../hooks/usePermissions';
import { getPluginConfig, rotateSealedSecret } from '../lib/controller';
import { canDecryptSecrets } from '../lib/rbac';
import { SealedSecret } from '../lib/SealedSecretCRD';
import { SealedSecretScope } from '../types';
import { DecryptDialog } from './DecryptDialog';
import { SealedSecretDetailSkeleton } from './LoadingSkeletons';
/**
* Format scope for display
*/
function formatScope(scope: SealedSecretScope): string {
switch (scope) {
case 'strict':
return 'Strict';
case 'namespace-wide':
return 'Namespace-wide';
case 'cluster-wide':
return 'Cluster-wide';
default:
return scope;
}
}
/**
* SealedSecret detail view component
*/
export function SealedSecretDetail() {
const { namespace = '', name = '' } = useParams<{ namespace: string; name: string }>();
const [sealedSecret, error] = SealedSecret.useGet(name || undefined, namespace || undefined);
const [secret] = K8s.ResourceClasses.Secret.useGet(name || undefined, namespace || undefined);
const [decryptKey, setDecryptKey] = React.useState<string | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false);
const [rotating, setRotating] = React.useState(false);
const [canDecrypt, setCanDecrypt] = React.useState(false);
const { enqueueSnackbar } = useSnackbar();
const { permissions } = usePermissions(namespace || undefined);
// Check if user can decrypt secrets (requires get permission on Secrets)
React.useEffect(() => {
if (namespace) {
canDecryptSecrets(namespace).then(setCanDecrypt);
}
}, [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
if (!sealedSecret) {
return <SealedSecretDetailSkeleton />;
}
// Memoize callbacks to prevent re-renders
const handleDelete = React.useCallback(async () => {
try {
await sealedSecret.delete();
enqueueSnackbar('SealedSecret deleted successfully', { variant: 'success' });
window.history.back();
} catch (error: any) {
enqueueSnackbar(`Failed to delete SealedSecret: ${error.message}`, { variant: 'error' });
}
setDeleteDialogOpen(false);
}, [sealedSecret, enqueueSnackbar]);
const handleRotate = React.useCallback(async () => {
setRotating(true);
try {
const config = getPluginConfig();
const yaml = JSON.stringify(sealedSecret.jsonData);
await rotateSealedSecret(config, yaml);
enqueueSnackbar('SealedSecret re-encrypted successfully', { variant: 'success' });
// The resource will auto-refresh via the watch
} catch (error: any) {
enqueueSnackbar(`Failed to re-encrypt: ${error.message}`, { variant: 'error' });
} finally {
setRotating(false);
}
}, [sealedSecret, enqueueSnackbar]);
// 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 (
<>
<Drawer
anchor="right"
open
onClose={handleClose}
PaperProps={{
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 && (
<Button
variant="outlined"
onClick={handleRotate}
disabled={rotating}
sx={{ mr: 1 }}
>
{rotating ? 'Re-encrypting...' : 'Re-encrypt'}
</Button>
)}
{permissions?.canDelete && (
<Button
variant="outlined"
color="error"
onClick={() => setDeleteDialogOpen(true)}
>
Delete
</Button>
)}
</Box>
</Box>
}
>
<NameValueTable
rows={[
{
name: 'Name',
value: String(sealedSecret.metadata.name || ''),
},
{
name: 'Namespace',
value: String(sealedSecret.metadata.namespace || ''),
},
{
name: 'Scope',
value: String(formatScope(sealedSecret.scope)),
},
{
name: 'Sync Status',
value: (
<StatusLabel status={sealedSecret.isSynced ? 'success' : 'error'}>
{sealedSecret.isSynced ? 'Synced' : 'Not Synced'}
</StatusLabel>
),
},
{
name: 'Status Message',
value: String(sealedSecret.syncMessage || 'Unknown'),
hide: !sealedSecret.syncCondition,
},
{
name: 'Age',
value: String(sealedSecret.getAge() || ''),
},
{
name: 'Created',
value: sealedSecret.metadata.creationTimestamp
? new Date(sealedSecret.metadata.creationTimestamp).toLocaleString()
: 'Unknown',
},
]}
/>
</SectionBox>
<SectionBox title="Encrypted Data">
<SimpleTable
data={encryptedKeys.map(key => ({
key,
value: sealedSecret.spec.encryptedData[key],
}))}
columns={[
{
label: 'Key',
getter: (row: any) => row.key,
},
{
label: 'Encrypted Value',
getter: (row: any) => {
const val = row.value;
return val.length > 40 ? val.substring(0, 40) + '...' : val;
},
},
{
label: 'Actions',
getter: (row: any) =>
canDecrypt ? (
<Button size="small" onClick={() => setDecryptKey(row.key)}>
Decrypt
</Button>
) : (
<Button size="small" disabled title="No permission to access Secrets">
Decrypt
</Button>
),
},
]}
/>
</SectionBox>
{sealedSecret.spec.template && (
<SectionBox title="Template">
<NameValueTable
rows={[
{
name: 'Secret Type',
value: String(sealedSecret.spec.template.type || 'Opaque'),
},
{
name: 'Labels',
value: String(JSON.stringify(sealedSecret.spec.template.metadata?.labels || {})),
hide: !sealedSecret.spec.template.metadata?.labels,
},
{
name: 'Annotations',
value: String(
JSON.stringify(sealedSecret.spec.template.metadata?.annotations || {})
),
hide: !sealedSecret.spec.template.metadata?.annotations,
},
]}
/>
</SectionBox>
)}
<SectionBox title="Resulting Secret">
{secret ? (
<NameValueTable
rows={[
{
name: 'Status',
value: <StatusLabel status="success">Secret exists</StatusLabel>,
},
{
name: 'Keys',
value: String(Object.keys(secret.data || {}).join(', ') || 'None'),
},
{
name: 'Link',
value: (
<Link
routeName="secret"
params={{
namespace: String(secret.metadata.namespace || ''),
name: String(secret.metadata.name || ''),
}}
>
View Secret
</Link>
),
},
]}
/>
) : (
<Box p={2}>
<StatusLabel status="warning">Secret not yet created</StatusLabel>
<p>The controller will create the Secret once it processes this SealedSecret.</p>
</Box>
)}
</SectionBox>
</Box>
{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>
</Drawer>
</>
);
}
+169
View File
@@ -0,0 +1,169 @@
/**
* SealedSecrets List View
*
* Displays all SealedSecrets in the cluster with filtering and navigation
*/
import { Link } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import {
SectionBox,
SectionFilterHeader,
SimpleTable,
StatusLabel,
} 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';
/**
* Format scope for display
*/
function formatScope(scope: SealedSecretScope): string {
switch (scope) {
case 'strict':
return 'Strict';
case 'namespace-wide':
return 'Namespace-wide';
case 'cluster-wide':
return 'Cluster-wide';
default:
return scope;
}
}
/**
* 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');
// Memoize callbacks to prevent re-renders
const handleOpenDialog = React.useCallback(() => {
setCreateDialogOpen(true);
}, []);
const handleCloseDialog = React.useCallback(() => {
setCreateDialogOpen(false);
}, []);
// Memoize column definitions (stable reference for table)
const columns = React.useMemo(
() => [
{
label: 'Name',
getter: (ss: SealedSecret) => (
<Link
routeName="sealedsecret"
params={{
namespace: ss.metadata.namespace,
name: ss.metadata.name,
}}
>
{ss.metadata.name}
</Link>
),
},
{
label: 'Namespace',
getter: (ss: SealedSecret) => ss.metadata.namespace,
},
{
label: 'Encrypted Keys',
getter: (ss: SealedSecret) => ss.encryptedKeysCount,
},
{
label: 'Scope',
getter: (ss: SealedSecret) => formatScope(ss.scope),
},
{
label: 'Sync Status',
getter: (ss: SealedSecret) => (
<StatusLabel status={ss.isSynced ? 'success' : 'error'}>
{ss.isSynced ? 'Synced' : 'Not Synced'}
</StatusLabel>
),
},
{
label: 'Age',
getter: (ss: SealedSecret) => ss.getAge(),
},
],
[]
);
// Memoize actions array (stable reference)
const actions = React.useMemo(
() =>
canCreate
? [
<Button key="create" variant="contained" color="primary" onClick={handleOpenDialog}>
Create Sealed Secret
</Button>,
]
: [],
[canCreate, handleOpenDialog]
);
// Show loading skeleton while data is being fetched
if (loading) {
return (
<SectionBox title="Sealed Secrets">
<SealedSecretListSkeleton />
</SectionBox>
);
}
// Show error if CRD is not installed
if (error) {
return (
<SectionBox
title="Sealed Secrets"
>
<Box p={2}>
<StatusLabel status="error">Error</StatusLabel>
<Box mt={2}>
{error.message.includes('404') ? (
<>
<p>
Sealed Secrets CRD not found. Please ensure Sealed Secrets is installed on your
cluster.
</p>
<p>
Install with: <code>kubectl apply -f https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.24.0/controller.yaml</code>
</p>
</>
) : (
<p>Failed to load Sealed Secrets: {error.message}</p>
)}
</Box>
</Box>
</SectionBox>
);
}
return (
<>
<SectionBox
title="Sealed Secrets"
>
<VersionWarning autoDetect showDetails={false} />
<SectionFilterHeader title="" noNamespaceFilter={false} actions={actions} />
<SimpleTable data={sealedSecrets} columns={columns} />
</SectionBox>
<EncryptDialog open={createDialogOpen} onClose={handleCloseDialog} />
{namespace && name && <SealedSecretDetail />}
</>
);
}
+199
View File
@@ -0,0 +1,199 @@
/**
* Sealing Keys Management View
*
* Lists all sealing key pairs (TLS Secrets) used by the controller
*/
import { K8s } from '@kinvolk/headlamp-plugin/lib';
import { SectionBox, SimpleTable, StatusLabel } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import { Box, Button, Chip } from '@mui/material';
import { useSnackbar } from 'notistack';
import React from 'react';
import { fetchPublicCertificate, getPluginConfig } from '../lib/controller';
import { isCertificateExpiringSoon, parseCertificateInfo } from '../lib/crypto';
import { CertificateInfo, PEMCertificate } from '../types';
import { ControllerStatus } from './ControllerStatus';
import { SealingKeysListSkeleton } from './LoadingSkeletons';
interface SealingKey {
name: string;
status: 'active' | 'compromised';
created: string;
certInfo?: CertificateInfo;
}
/**
* Sealing keys management view
*/
export function SealingKeysView() {
const config = getPluginConfig();
const [secrets, , loading] = K8s.ResourceClasses.Secret.useList({ namespace: config.controllerNamespace });
const { enqueueSnackbar } = useSnackbar();
// Filter for sealing key secrets
const sealingKeys: SealingKey[] = React.useMemo(() => {
if (!secrets) return [];
return secrets
.filter(secret => {
const labelValue = secret.metadata.labels?.['sealedsecrets.bitnami.com/sealed-secrets-key'];
return labelValue === 'active' || labelValue === 'compromised';
})
.map(secret => {
const status = secret.metadata.labels?.['sealedsecrets.bitnami.com/sealed-secrets-key'] as
| 'active'
| 'compromised';
const certPem = secret.data?.['tls.crt'] ? atob(secret.data['tls.crt']) : '';
let certInfo: CertificateInfo | undefined;
if (certPem) {
const infoResult = parseCertificateInfo(PEMCertificate(certPem));
if (infoResult.ok) {
certInfo = infoResult.value;
}
}
return {
name: secret.metadata.name!,
status,
created: secret.metadata.creationTimestamp!,
certInfo,
};
})
.sort((a, b) => {
// Sort active keys first, then by creation date (newest first)
if (a.status !== b.status) {
return a.status === 'active' ? -1 : 1;
}
return new Date(b.created).getTime() - new Date(a.created).getTime();
});
}, [secrets]);
const handleDownloadCert = async () => {
const result = await fetchPublicCertificate(config);
if (result.ok === false) {
enqueueSnackbar(`Failed to download certificate: ${result.error}`, { variant: 'error' });
return;
}
try {
const blob = new Blob([result.value], { type: 'application/x-pem-file' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'sealed-secrets-cert.pem';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
enqueueSnackbar('Certificate downloaded', { variant: 'success' });
} catch (error: any) {
enqueueSnackbar(`Failed to create download: ${error.message}`, { variant: 'error' });
}
};
// Show loading skeleton while data is being fetched
if (loading) {
return (
<SectionBox title="Sealing Keys">
<SealingKeysListSkeleton />
</SectionBox>
);
}
return (
<SectionBox
title="Sealing Keys"
headerProps={{
actions: [
<ControllerStatus key="status" autoRefresh refreshIntervalMs={60000} showDetails />,
<Button key="download" variant="contained" onClick={handleDownloadCert}>
Download Public Certificate
</Button>,
],
}}
>
{sealingKeys.length === 0 ? (
<Box p={2}>
<p>No sealing keys found in namespace {config.controllerNamespace}.</p>
<p>
Ensure the Sealed Secrets controller is installed and running in the{' '}
<strong>{config.controllerNamespace}</strong> namespace.
</p>
</Box>
) : (
<>
<Box p={2}>
<p>
Sealing keys are TLS key pairs used by the controller to decrypt SealedSecrets. The
active key is used for new encryptions. Old keys are kept to decrypt existing
SealedSecrets.
</p>
</Box>
<SimpleTable
data={sealingKeys}
columns={[
{
label: 'Key Name',
getter: (key: SealingKey) => key.name,
},
{
label: 'Status',
getter: (key: SealingKey) => (
<StatusLabel status={key.status === 'active' ? 'success' : 'warning'}>
{key.status === 'active' ? 'Active' : 'Compromised'}
</StatusLabel>
),
},
{
label: 'Created',
getter: (key: SealingKey) => new Date(key.created).toLocaleString(),
},
{
label: 'Certificate Expiry',
getter: (key: SealingKey) => {
if (!key.certInfo) return 'N/A';
const { certInfo } = key;
const expiryDate = certInfo.validTo.toLocaleDateString();
if (certInfo.isExpired) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip label="Expired" color="error" size="small" />
<span>{expiryDate}</span>
</Box>
);
}
if (isCertificateExpiringSoon(certInfo, 30)) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip
label={`${certInfo.daysUntilExpiry} days left`}
color="warning"
size="small"
/>
<span>{expiryDate}</span>
</Box>
);
}
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<span>{expiryDate}</span>
<span style={{ color: '#666', fontSize: '0.9em' }}>
({certInfo.daysUntilExpiry} days)
</span>
</Box>
);
},
},
]}
/>
</>
)}
</SectionBox>
);
}
+71
View File
@@ -0,0 +1,71 @@
/**
* Secret Details Section
*
* Additional section shown in the Secret detail view if the Secret
* is owned by a SealedSecret
*/
import { Link } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import { NameValueTable, SectionBox, StatusLabel } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React from 'react';
import { SealedSecret } from '../lib/SealedSecretCRD';
interface SecretDetailsSectionProps {
resource: any; // The Secret resource
}
/**
* Secret details section component
*/
export function SecretDetailsSection({ resource }: SecretDetailsSectionProps) {
// Check if this Secret is owned by a SealedSecret
const ownerRef = resource.metadata?.ownerReferences?.find(
(ref: any) => ref.kind === 'SealedSecret' && ref.apiVersion === 'bitnami.com/v1alpha1'
);
if (!ownerRef) {
return null;
}
// Fetch the parent SealedSecret
const [sealedSecret] = SealedSecret.useGet(ownerRef.name, resource.metadata.namespace);
return (
<SectionBox title="Sealed Secret">
{sealedSecret ? (
<NameValueTable
rows={[
{
name: 'Parent SealedSecret',
value: (
<Link
routeName="sealedsecret"
params={{
namespace: sealedSecret.metadata.namespace,
name: sealedSecret.metadata.name,
}}
>
{sealedSecret.metadata.name}
</Link>
),
},
{
name: 'Scope',
value: sealedSecret.scope,
},
{
name: 'Sync Status',
value: (
<StatusLabel status={sealedSecret.isSynced ? 'success' : 'error'}>
{sealedSecret.isSynced ? 'Synced' : 'Not Synced'}
</StatusLabel>
),
},
]}
/>
) : (
<p>Loading SealedSecret information...</p>
)}
</SectionBox>
);
}
+183
View File
@@ -0,0 +1,183 @@
/**
* Settings Page
*
* 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 { useSnackbar } from 'notistack';
import React from 'react';
import { getPluginConfig, savePluginConfig } from '../lib/controller';
import { PluginConfig } from '../types';
import { ControllerStatus } from './ControllerStatus';
import { VersionWarning } from './VersionWarning';
interface PluginSettingsProps {
data?: { [key: string]: string | number | boolean };
onDataChange?: (data: { [key: string]: string | number | boolean }) => void;
}
/**
* Settings page component
*/
export function SettingsPage(props: PluginSettingsProps) {
const { data, onDataChange } = props;
const storedConfig = getPluginConfig();
const [config, setConfig] = React.useState<PluginConfig>({
controllerName: (data?.controllerName as string) ?? storedConfig.controllerName,
controllerNamespace: (data?.controllerNamespace as string) ?? storedConfig.controllerNamespace,
controllerPort: (data?.controllerPort as number) ?? storedConfig.controllerPort,
});
const { enqueueSnackbar } = useSnackbar();
const handleSave = () => {
savePluginConfig(config);
onDataChange?.(config as unknown as { [key: string]: string | number | boolean });
enqueueSnackbar('Settings saved successfully', { variant: 'success' });
};
const handleReset = () => {
const defaultConfig: PluginConfig = {
controllerName: 'sealed-secrets-controller',
controllerNamespace: 'kube-system',
controllerPort: 8080,
};
setConfig(defaultConfig);
};
return (
<SectionBox title="Sealed Secrets Plugin Settings">
<Box p={3}>
<Typography variant="body1" paragraph id="settings-description">
Configure the connection to your Sealed Secrets controller. These settings are stored in
your browser's local storage.
</Typography>
{/* API Version Detection */}
<VersionWarning autoDetect showDetails />
{/* Controller Health Status */}
<Box
mb={3}
p={2}
bgcolor="background.paper"
borderRadius={1}
border={1}
borderColor="divider"
role="status"
aria-live="polite"
>
<Typography variant="subtitle2" gutterBottom id="controller-status-label">
Controller Status
</Typography>
<ControllerStatus autoRefresh showDetails />
</Box>
<Divider sx={{ mb: 3 }} role="separator" />
<form aria-labelledby="settings-form-title">
<Typography variant="h6" id="settings-form-title" sx={{ mb: 2 }} className="sr-only">
Controller Configuration
</Typography>
<TextField
fullWidth
label="Controller Name"
value={config.controllerName}
onChange={e => {
const newConfig = { ...config, controllerName: e.target.value };
setConfig(newConfig);
onDataChange?.(newConfig as unknown as { [key: string]: string | number | boolean });
}}
margin="normal"
helperText="Name of the sealed-secrets-controller deployment/service"
inputProps={{
'aria-label': 'Controller name',
'aria-describedby': 'controller-name-help',
}}
FormHelperTextProps={{
id: 'controller-name-help',
}}
/>
<TextField
fullWidth
label="Controller Namespace"
value={config.controllerNamespace}
onChange={e => {
const newConfig = { ...config, controllerNamespace: e.target.value };
setConfig(newConfig);
onDataChange?.(newConfig as unknown as { [key: string]: string | number | boolean });
}}
margin="normal"
helperText="Namespace where the controller is installed"
inputProps={{
'aria-label': 'Controller namespace',
'aria-describedby': 'controller-namespace-help',
}}
FormHelperTextProps={{
id: 'controller-namespace-help',
}}
/>
<TextField
fullWidth
label="Controller Port"
type="number"
value={config.controllerPort}
onChange={e => {
const newConfig = { ...config, controllerPort: parseInt(e.target.value, 10) };
setConfig(newConfig);
onDataChange?.(newConfig as unknown as { [key: string]: string | number | boolean });
}}
margin="normal"
helperText="HTTP port of the controller service"
inputProps={{
'aria-label': 'Controller port',
'aria-describedby': 'controller-port-help',
min: 1,
max: 65535,
}}
FormHelperTextProps={{
id: 'controller-port-help',
}}
/>
<Box mt={3} display="flex" gap={2} role="group" aria-label="Settings actions">
<Button
variant="contained"
onClick={handleSave}
aria-label="Save configuration settings"
>
Save Settings
</Button>
<Button
variant="outlined"
onClick={handleReset}
aria-label="Reset settings to default values"
>
Reset to Defaults
</Button>
</Box>
</form>
<Box mt={4} p={2} bgcolor="info.light" borderRadius={1} role="note">
<Typography variant="h6" gutterBottom>
Default Values
</Typography>
<Typography variant="body2" component="dl">
<dt style={{ display: 'inline', fontWeight: 'bold' }}>Controller Name:</dt>{' '}
<dd style={{ display: 'inline', margin: 0 }}>sealed-secrets-controller</dd>
<br />
<dt style={{ display: 'inline', fontWeight: 'bold' }}>Controller Namespace:</dt>{' '}
<dd style={{ display: 'inline', margin: 0 }}>kube-system</dd>
<br />
<dt style={{ display: 'inline', fontWeight: 'bold' }}>Controller Port:</dt>{' '}
<dd style={{ display: 'inline', margin: 0 }}>8080</dd>
</Typography>
</Box>
</Box>
</SectionBox>
);
}
+140
View File
@@ -0,0 +1,140 @@
/**
* Version Warning Component
*
* Displays warnings about API version compatibility and issues.
*/
import { Alert, Box, Button, Link } from '@mui/material';
import React from 'react';
import { SealedSecret } from '../lib/SealedSecretCRD';
export interface VersionWarningProps {
/** Whether to auto-detect version on mount */
autoDetect?: boolean;
/** Whether to show detailed version information */
showDetails?: boolean;
}
/**
* Component that detects and displays API version information
*
* Shows warnings if:
* - CRD is not installed
* - Version detection fails
* - Using non-default version (informational)
*/
export function VersionWarning({ autoDetect = true, showDetails = false }: VersionWarningProps) {
const [loading, setLoading] = React.useState(true);
const [detectedVersion, setDetectedVersion] = React.useState<string | null>(null);
const [error, setError] = React.useState<string | null>(null);
const detectVersion = React.useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await SealedSecret.detectApiVersion();
if (result.ok) {
setDetectedVersion(result.value);
setError(null);
} else if (result.ok === false) {
setDetectedVersion(null);
// Ensure error is always a string
const errorMessage = typeof result.error === 'string'
? result.error
: String(result.error);
setError(errorMessage);
}
} catch (e) {
// Catch any unexpected errors
setDetectedVersion(null);
setError(e instanceof Error ? e.message : String(e));
}
setLoading(false);
}, []);
React.useEffect(() => {
if (autoDetect) {
detectVersion();
}
}, [autoDetect, detectVersion]);
// Don't show anything while loading
if (loading) {
return null;
}
// Show error if detection failed
if (error) {
return (
<Box mb={2}>
<Alert severity="error" action={
<Button color="inherit" size="small" onClick={detectVersion}>
Retry
</Button>
}>
<strong>API Version Detection Failed</strong>
<br />
{String(error)}
{String(error).includes('not found') && (
<>
<br />
<br />
Install Sealed Secrets with:{' '}
<code style={{ fontSize: '0.875em' }}>
kubectl apply -f https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.24.0/controller.yaml
</code>
<br />
Or visit:{' '}
<Link
href="https://github.com/bitnami-labs/sealed-secrets"
target="_blank"
rel="noopener noreferrer"
color="inherit"
>
github.com/bitnami-labs/sealed-secrets
</Link>
</>
)}
</Alert>
</Box>
);
}
// Show informational message if using non-default version
if (detectedVersion && detectedVersion !== SealedSecret.DEFAULT_VERSION) {
return (
<Box mb={2}>
<Alert severity="info">
<strong>API Version Detected</strong>
<br />
Using API version: <code>{detectedVersion}</code>
{showDetails && (
<>
<br />
Default version: <code>{SealedSecret.DEFAULT_VERSION}</code>
</>
)}
</Alert>
</Box>
);
}
// Show success if explicitly showing details
if (showDetails && detectedVersion) {
return (
<Box mb={2}>
<Alert severity="success">
<strong>API Version Detected</strong>
<br />
Using API version: <code>{detectedVersion}</code>
</Alert>
</Box>
);
}
// Default: show nothing (version detected successfully)
return null;
}