Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions cert-manager/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,20 @@
"Next Private Key Secret": "Next Private Key Secret",
"Certificates": "Certificates",
"Secret": "Secret",
"Expires In": "Expires In",
"Expires In (Not After)": "Expires In (Not After)",
"Overview": "Overview",
"Expiring soon": "Expiring soon",
"Expired": "Expired",
"Unknown": "Unknown",
"1 day": "1 day",
"{{count}} days": "{{count}} days",
"Certificates expiring soon": "Certificates expiring soon",
"Total certificates": "Total certificates",
"Within 30 days or already expired": "Within 30 days or already expired",
"Past Not After": "Past Not After",
"No certificates expiring soon": "No certificates expiring soon",
"Loading certificates": "Loading certificates",
"DNS Name": "DNS Name",
"Authorization URL": "Authorization URL",
"Type": "Type",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { useTranslation } from '@kinvolk/headlamp-plugin/lib';
import { StatusLabel } from '@kinvolk/headlamp-plugin/lib/components/common';
import { Tooltip } from '@mui/material';
import { formatExpiryLabel, getCertificateExpiry } from '../../utils/certificateExpiry';

export function CertificateExpiryLabel({ notAfter }: { notAfter?: string }) {
const { t } = useTranslation();
const expiry = getCertificateExpiry(notAfter);

return (
<Tooltip title={notAfter || t('Unknown')}>
<span>
<StatusLabel status={expiry.statusLabelStatus}>{formatExpiryLabel(expiry, t)}</StatusLabel>
</span>
</Tooltip>
);
}
14 changes: 11 additions & 3 deletions cert-manager/src/components/certificates/Detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@ import {
import { useParams } from 'react-router-dom';
import { useCertManagerInstalled } from '../../hooks/useCertManagerInstalled';
import { Certificate } from '../../resources/certificate';
import { IssuerRef, NotInstalledBanner, StringArray } from '../common/CommonComponents';
import {
IssuerRef,
NotInstalledBanner,
SecretNameLink,
StringArray,
} from '../common/CommonComponents';
import { CertificateExpiryLabel } from './CertificateExpiryLabel';

export function CertificateDetail() {
const { t } = useTranslation();
Expand Down Expand Up @@ -70,7 +76,9 @@ export function CertificateDetail() {
},
{
name: t('Secret Name'),
value: item.spec.secretName,
value: (
<SecretNameLink name={item.spec.secretName} namespace={item.metadata.namespace} />
),
},
{
name: t('Issuer Ref'),
Expand Down Expand Up @@ -237,7 +245,7 @@ export function CertificateDetail() {
},
{
name: t('Not After'),
value: item.status?.notAfter,
value: <CertificateExpiryLabel notAfter={item.status?.notAfter} />,
},
{
name: t('Renewal Time'),
Expand Down
8 changes: 5 additions & 3 deletions cert-manager/src/components/certificates/List.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
import { Box } from '@mui/material';
import { Meta, StoryFn } from '@storybook/react';
import { NotInstalledBanner } from '../common/CommonComponents';
import { CertificateExpiryLabel } from './CertificateExpiryLabel';

// Mock certificate data structure for stories
interface MockCertificate {
Expand Down Expand Up @@ -74,9 +75,10 @@ export function PureCertificatesList({
getter: (item: MockCertificate) => item.spec.secretName,
},
{
label: 'Expires In (Not After)',
getter: (item: MockCertificate) =>
item.status?.notAfter ? <DateLabel date={item.status.notAfter} format="mini" /> : '-',
label: 'Expires In',
getter: (item: MockCertificate) => (
<CertificateExpiryLabel notAfter={item.status?.notAfter} />
),
},
{
label: 'Age',
Expand Down
24 changes: 13 additions & 11 deletions cert-manager/src/components/certificates/List.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { useTranslation } from '@kinvolk/headlamp-plugin/lib';
import { ResourceListView } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import { DateLabel } from '@kinvolk/headlamp-plugin/lib/components/common';
import { useCertManagerInstalled } from '../../hooks/useCertManagerInstalled';
import { Certificate } from '../../resources/certificate';
import { NotInstalledBanner } from '../common/CommonComponents';
import { NotInstalledBanner, SecretNameLink } from '../common/CommonComponents';
import { CertificateExpiryLabel } from './CertificateExpiryLabel';

export function CertificatesList() {
const { t } = useTranslation();
Expand All @@ -25,20 +25,22 @@ export function CertificatesList() {
id: 'secret',
label: t('Secret'),
getValue: item => item.spec.secretName,
render: item => (
<SecretNameLink name={item?.spec?.secretName} namespace={item?.metadata?.namespace} />
),
},
{
id: 'expiresIn',
label: t('Expires In (Not After)'),
render: item => {
return item?.status?.notAfter ? (
<DateLabel date={item.status.notAfter} format="mini" />
) : null;
},
label: t('Expires In'),
render: item => <CertificateExpiryLabel notAfter={item?.status?.notAfter} />,
getValue: item => item.status?.notAfter ?? '',
sort: (a, b) => {
const dateA = new Date(a.status?.notAfter);
const dateB = new Date(b.status?.notAfter);
return dateA.getTime() - dateB.getTime();
const dateA = Date.parse(a.status?.notAfter || '');
const dateB = Date.parse(b.status?.notAfter || '');
return (
(Number.isNaN(dateA) ? Number.POSITIVE_INFINITY : dateA) -
(Number.isNaN(dateB) ? Number.POSITIVE_INFINITY : dateB)
);
},
},
'age',
Expand Down
116 changes: 116 additions & 0 deletions cert-manager/src/components/certificates/Overview.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import {
Box, Card, CardContent, Chip, Grid,
Paper, Table, TableBody, TableCell,
TableContainer, TableHead, TableRow, Typography
} from "@mui/material";
import { Meta, StoryFn } from "@storybook/react";

interface MockCertificate {
metadata: { name: string; namespace: string };
status?: { notAfter?: string };
}

interface PureOverviewProps {
totalCount: number;
expiringSoon: MockCertificate[];
expired: MockCertificate[];
}

function SummaryCard({ title, value, subtitle }: { title: string; value: number; subtitle: string }) {
return (
<Card variant="outlined" sx={{ height: "100%" }}>
<CardContent>
<Typography variant="subtitle2" color="text.secondary">{title}</Typography>
<Typography variant="h4" sx={{ fontWeight: 700, my: 1 }}>{value}</Typography>
<Typography variant="caption" color="text.secondary">{subtitle}</Typography>
</CardContent>
</Card>
);
}

function ExpiryChip({ notAfter }: { notAfter?: string }) {
if (!notAfter) return <Chip label="Unknown" size="small" />;
const ms = Date.parse(notAfter);
if (Number.isNaN(ms)) return <Chip label="Unknown" size="small" />;
const days = Math.floor((ms - Date.now()) / (24 * 60 * 60 * 1000));
if (days < 0) return <Chip label="Expired" size="small" color="error" />;
if (days < 7) return <Chip label={`${days} days`} size="small" color="error" />;
if (days <= 30) return <Chip label={`${days} days`} size="small" color="warning" />;
return <Chip label={`${days} days`} size="small" color="success" />;
}

function PureCertificatesOverview({ totalCount, expiringSoon, expired }: PureOverviewProps) {
return (
<Box sx={{ p: 3 }}>
<Typography variant="h5" sx={{ mb: 3, fontWeight: 600 }}>cert-manager Overview</Typography>
<Grid container spacing={2} sx={{ mb: 4 }}>
<Grid item xs={12} sm={4}>
<SummaryCard title="Certificates" value={totalCount} subtitle="Total certificates" />
</Grid>
<Grid item xs={12} sm={4}>
<SummaryCard title="Expiring soon" value={expiringSoon.length} subtitle="Within 30 days or already expired" />
</Grid>
<Grid item xs={12} sm={4}>
<SummaryCard title="Expired" value={expired.length} subtitle="Past Not After" />
</Grid>
</Grid>

<Typography variant="h6" sx={{ mb: 2 }}>Certificates expiring soon</Typography>
{expiringSoon.length === 0 ? (
<Typography color="text.secondary">No certificates expiring soon</Typography>
) : (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell><strong>Name</strong></TableCell>
<TableCell><strong>Namespace</strong></TableCell>
<TableCell><strong>Expires In</strong></TableCell>
</TableRow>
</TableHead>
<TableBody>
{expiringSoon.map(item => (
<TableRow key={item.metadata.name}>
<TableCell>{item.metadata.name}</TableCell>
<TableCell>{item.metadata.namespace}</TableCell>
<TableCell><ExpiryChip notAfter={item.status?.notAfter} /></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</Box>
);
}

export default {
title: "cert-manager/Certificates/Overview",
component: PureCertificatesOverview,
} as Meta;

const Template: StoryFn<PureOverviewProps> = args => <PureCertificatesOverview {...args} />;

const in3Days = new Date(Date.now() + 3 * 864e5).toISOString();
const in15Days = new Date(Date.now() + 15 * 864e5).toISOString();
const yesterday = new Date(Date.now() - 1 * 864e5).toISOString();
const lastMonth = new Date(Date.now() - 30 * 864e5).toISOString();

const expiringSoonMock: MockCertificate[] = [
{ metadata: { name: "api-tls", namespace: "production" }, status: { notAfter: in3Days } },
{ metadata: { name: "web-tls", namespace: "default" }, status: { notAfter: in15Days } },
{ metadata: { name: "old-cert", namespace: "staging" }, status: { notAfter: yesterday } },
{ metadata: { name: "legacy-cert", namespace: "default" }, status: { notAfter: lastMonth } },
];
const expiredMock = expiringSoonMock.filter(
c => c.status?.notAfter && c.status.notAfter < new Date().toISOString()
);

export const WithExpiringSoon = Template.bind({});
WithExpiringSoon.args = { totalCount: 12, expiringSoon: expiringSoonMock, expired: expiredMock };

export const AllHealthy = Template.bind({});
AllHealthy.args = { totalCount: 8, expiringSoon: [], expired: [] };

export const OnlyExpired = Template.bind({});
OnlyExpired.args = { totalCount: 3, expiringSoon: expiredMock, expired: expiredMock };
122 changes: 122 additions & 0 deletions cert-manager/src/components/certificates/Overview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { useTranslation } from '@kinvolk/headlamp-plugin/lib';
import { Link, SectionBox, SimpleTable } from '@kinvolk/headlamp-plugin/lib/components/common';
import { Box, Card, CardContent, CircularProgress, Grid, Typography } from '@mui/material';
import { useMemo } from 'react';
import { useCertManagerInstalled } from '../../hooks/useCertManagerInstalled';
import { Certificate } from '../../resources/certificate';
import { getCertificateExpiry, needsExpiryAttention } from '../../utils/certificateExpiry';
import { NotInstalledBanner } from '../common/CommonComponents';
import { CertificateExpiryLabel } from './CertificateExpiryLabel';

function SummaryCard({
title,
value,
subtitle,
}: {
title: string;
value: number;
subtitle: string;
}) {
return (
<Card variant="outlined" sx={{ height: '100%' }}>
<CardContent>
<Typography variant="subtitle2" color="text.secondary">
{title}
</Typography>
<Typography variant="h4" sx={{ fontWeight: 700, my: 1 }}>
{value}
</Typography>
<Typography variant="caption" color="text.secondary">
{subtitle}
</Typography>
</CardContent>
</Card>
);
}

export function CertificatesOverview() {
const { t } = useTranslation();
const { isManagerInstalled, isCertManagerCheckLoading } = useCertManagerInstalled();
const [certificates, certificatesError] = Certificate.useList();

const isLoading = certificates === null && !certificatesError;
const items = certificates || [];

const { expiringSoon, expired } = useMemo(() => {
const expiringSoonItems = items.filter(item =>
needsExpiryAttention(getCertificateExpiry(item.status?.notAfter))
);
return {
expiringSoon: expiringSoonItems,
expired: expiringSoonItems.filter(
item => getCertificateExpiry(item.status?.notAfter).level === 'expired'
),
};
}, [items]);

if (!isManagerInstalled) {
return <NotInstalledBanner isLoading={isCertManagerCheckLoading} />;
}

if (isLoading) {
return (
<Box display="flex" alignItems="center" justifyContent="center" minHeight="240px">
<CircularProgress />
<Typography sx={{ ml: 2 }}>{t('Loading certificates')}</Typography>
</Box>
);
}

return (
<>
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid item xs={12} sm={4}>
<SummaryCard
title={t('Certificates')}
value={items.length}
subtitle={t('Total certificates')}
/>
</Grid>
<Grid item xs={12} sm={4}>
<SummaryCard
title={t('Expiring soon')}
value={expiringSoon.length}
subtitle={t('Within 30 days or already expired')}
/>
</Grid>
<Grid item xs={12} sm={4}>
<SummaryCard title={t('Expired')} value={expired.length} subtitle={t('Past Not After')} />
</Grid>
</Grid>
<SectionBox title={t('Certificates expiring soon')}>
<SimpleTable
columns={[
{
label: t('Name'),
getter: (item: Certificate) => (
<Link
routeName={Certificate.detailsRoute}
params={{ namespace: item.metadata.namespace, name: item.metadata.name }}
>
{item.metadata.name}
</Link>
),
},
{
label: t('Namespace'),
getter: (item: Certificate) => item.metadata.namespace,
},
{
label: t('Expires In'),
getter: (item: Certificate) => (
<CertificateExpiryLabel notAfter={item.status?.notAfter} />
),
},
]}
data={expiringSoon}
emptyMessage={t('No certificates expiring soon')}
/>
</SectionBox>
</>
);
}
Loading