feat: implement skeleton loading screens for all views (Phase 3.5)
Created comprehensive skeleton components providing visual feedback during data loading. This improves perceived performance and provides a better user experience with consistent loading states across all views. Changes: - NEW: src/components/LoadingSkeletons.tsx (+105 lines) - SealedSecretListSkeleton - 5 placeholder rows - SealedSecretDetailSkeleton - title + sections + actions - SealingKeysListSkeleton - 2 certificate placeholders - CertificateInfoSkeleton - metadata lines - ControllerHealthSkeleton - chip + info layout - All use wave animation and realistic layouts - UPDATED: SealedSecretList.tsx - Use loading state from useList() hook - Show skeleton during data fetch - Smooth transition to real data - UPDATED: SealedSecretDetail.tsx - Replace Headlamp Loader with custom skeleton - Better layout matching - No layout shift - UPDATED: SealingKeysView.tsx - Add loading state detection - Show skeleton for certificates - Professional loading UX - UPDATED: ControllerStatus.tsx - Replace CircularProgress with skeleton - Match chip + info layout - Consistent with other components Benefits: - Improved perceived performance - Reduced layout shift (skeletons match real components) - Consistent loading experience (wave animation) - Better user feedback during data loading Build: 356.44 kB (98.01 kB gzipped) - +1.52 kB (+0.4%) Time: 4.78s Progress: 11/14 phases complete (79%) Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
@@ -6,9 +6,10 @@
|
||||
*/
|
||||
|
||||
import { CheckCircle, Error as ErrorIcon, Warning } from '@mui/icons-material';
|
||||
import { Box, Chip, CircularProgress, Tooltip, Typography } from '@mui/material';
|
||||
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 */
|
||||
@@ -29,15 +30,9 @@ export function ControllerStatus({
|
||||
}: ControllerStatusProps) {
|
||||
const { health: status, loading } = useControllerHealth(autoRefresh, refreshIntervalMs);
|
||||
|
||||
// Show skeleton while loading
|
||||
if (loading || !status) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CircularProgress size={16} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Checking controller...
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
return <ControllerHealthSkeleton />;
|
||||
}
|
||||
|
||||
// Build status message and icon
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { K8s } from '@kinvolk/headlamp-plugin/lib';
|
||||
import { Link, Loader } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import { Link } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import {
|
||||
NameValueTable,
|
||||
SectionBox,
|
||||
@@ -23,6 +23,7 @@ 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
|
||||
@@ -61,8 +62,9 @@ export function SealedSecretDetail() {
|
||||
}
|
||||
}, [namespace]);
|
||||
|
||||
// Show loading skeleton while data is being fetched
|
||||
if (!sealedSecret) {
|
||||
return <Loader title="Loading SealedSecret..." />;
|
||||
return <SealedSecretDetailSkeleton />;
|
||||
}
|
||||
|
||||
// Memoize callbacks to prevent re-renders
|
||||
|
||||
@@ -17,6 +17,7 @@ import { usePermission } from '../hooks/usePermissions';
|
||||
import { SealedSecret } from '../lib/SealedSecretCRD';
|
||||
import { SealedSecretScope } from '../types';
|
||||
import { EncryptDialog } from './EncryptDialog';
|
||||
import { SealedSecretListSkeleton } from './LoadingSkeletons';
|
||||
import { VersionWarning } from './VersionWarning';
|
||||
|
||||
/**
|
||||
@@ -39,7 +40,7 @@ function formatScope(scope: SealedSecretScope): string {
|
||||
* SealedSecrets list view component
|
||||
*/
|
||||
export function SealedSecretList() {
|
||||
const [sealedSecrets, error] = SealedSecret.useList();
|
||||
const [sealedSecrets, error, loading] = SealedSecret.useList();
|
||||
const [createDialogOpen, setCreateDialogOpen] = React.useState(false);
|
||||
const { allowed: canCreate } = usePermission(undefined, 'canCreate');
|
||||
|
||||
@@ -110,6 +111,15 @@ export function SealedSecretList() {
|
||||
[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 (
|
||||
|
||||
@@ -13,6 +13,7 @@ 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;
|
||||
@@ -26,7 +27,7 @@ interface SealingKey {
|
||||
*/
|
||||
export function SealingKeysView() {
|
||||
const config = getPluginConfig();
|
||||
const [secrets] = K8s.ResourceClasses.Secret.useList({ namespace: config.controllerNamespace });
|
||||
const [secrets, , loading] = K8s.ResourceClasses.Secret.useList({ namespace: config.controllerNamespace });
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
// Filter for sealing key secrets
|
||||
@@ -92,6 +93,15 @@ export function SealingKeysView() {
|
||||
}
|
||||
};
|
||||
|
||||
// Show loading skeleton while data is being fetched
|
||||
if (loading) {
|
||||
return (
|
||||
<SectionBox title="Sealing Keys">
|
||||
<SealingKeysListSkeleton />
|
||||
</SectionBox>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionBox
|
||||
title="Sealing Keys"
|
||||
|
||||
Reference in New Issue
Block a user