diff --git a/cert-manager/locales/en/translation.json b/cert-manager/locales/en/translation.json index 4a1c9cbc2c..a8b038aa96 100644 --- a/cert-manager/locales/en/translation.json +++ b/cert-manager/locales/en/translation.json @@ -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", diff --git a/cert-manager/src/components/certificates/CertificateExpiryLabel.tsx b/cert-manager/src/components/certificates/CertificateExpiryLabel.tsx new file mode 100644 index 0000000000..bcc61f0b5e --- /dev/null +++ b/cert-manager/src/components/certificates/CertificateExpiryLabel.tsx @@ -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 ( + + + {formatExpiryLabel(expiry, t)} + + + ); +} diff --git a/cert-manager/src/components/certificates/Detail.tsx b/cert-manager/src/components/certificates/Detail.tsx index 617938c03f..40957fc411 100644 --- a/cert-manager/src/components/certificates/Detail.tsx +++ b/cert-manager/src/components/certificates/Detail.tsx @@ -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(); @@ -70,7 +76,9 @@ export function CertificateDetail() { }, { name: t('Secret Name'), - value: item.spec.secretName, + value: ( + + ), }, { name: t('Issuer Ref'), @@ -237,7 +245,7 @@ export function CertificateDetail() { }, { name: t('Not After'), - value: item.status?.notAfter, + value: , }, { name: t('Renewal Time'), diff --git a/cert-manager/src/components/certificates/List.stories.tsx b/cert-manager/src/components/certificates/List.stories.tsx index 09d6e0ba6d..17acf451c9 100644 --- a/cert-manager/src/components/certificates/List.stories.tsx +++ b/cert-manager/src/components/certificates/List.stories.tsx @@ -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 { @@ -74,9 +75,10 @@ export function PureCertificatesList({ getter: (item: MockCertificate) => item.spec.secretName, }, { - label: 'Expires In (Not After)', - getter: (item: MockCertificate) => - item.status?.notAfter ? : '-', + label: 'Expires In', + getter: (item: MockCertificate) => ( + + ), }, { label: 'Age', diff --git a/cert-manager/src/components/certificates/List.tsx b/cert-manager/src/components/certificates/List.tsx index cfca933cdc..9141298803 100644 --- a/cert-manager/src/components/certificates/List.tsx +++ b/cert-manager/src/components/certificates/List.tsx @@ -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(); @@ -25,20 +25,22 @@ export function CertificatesList() { id: 'secret', label: t('Secret'), getValue: item => item.spec.secretName, + render: item => ( + + ), }, { id: 'expiresIn', - label: t('Expires In (Not After)'), - render: item => { - return item?.status?.notAfter ? ( - - ) : null; - }, + label: t('Expires In'), + render: item => , 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', diff --git a/cert-manager/src/components/certificates/Overview.stories.tsx b/cert-manager/src/components/certificates/Overview.stories.tsx new file mode 100644 index 0000000000..e8b0a60753 --- /dev/null +++ b/cert-manager/src/components/certificates/Overview.stories.tsx @@ -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 ( + + + {title} + {value} + {subtitle} + + + ); +} + +function ExpiryChip({ notAfter }: { notAfter?: string }) { + if (!notAfter) return ; + const ms = Date.parse(notAfter); + if (Number.isNaN(ms)) return ; + const days = Math.floor((ms - Date.now()) / (24 * 60 * 60 * 1000)); + if (days < 0) return ; + if (days < 7) return ; + if (days <= 30) return ; + return ; +} + +function PureCertificatesOverview({ totalCount, expiringSoon, expired }: PureOverviewProps) { + return ( + + cert-manager Overview + + + + + + + + + + + + + Certificates expiring soon + {expiringSoon.length === 0 ? ( + No certificates expiring soon + ) : ( + + + + + Name + Namespace + Expires In + + + + {expiringSoon.map(item => ( + + {item.metadata.name} + {item.metadata.namespace} + + + ))} + +
+
+ )} +
+ ); +} + +export default { + title: "cert-manager/Certificates/Overview", + component: PureCertificatesOverview, +} as Meta; + +const Template: StoryFn = 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 }; diff --git a/cert-manager/src/components/certificates/Overview.tsx b/cert-manager/src/components/certificates/Overview.tsx new file mode 100644 index 0000000000..2aa215f52a --- /dev/null +++ b/cert-manager/src/components/certificates/Overview.tsx @@ -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 ( + + + + {title} + + + {value} + + + {subtitle} + + + + ); +} + +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 ; + } + + if (isLoading) { + return ( + + + {t('Loading certificates')} + + ); + } + + return ( + <> + + + + + + + + + + + + + ( + + {item.metadata.name} + + ), + }, + { + label: t('Namespace'), + getter: (item: Certificate) => item.metadata.namespace, + }, + { + label: t('Expires In'), + getter: (item: Certificate) => ( + + ), + }, + ]} + data={expiringSoon} + emptyMessage={t('No certificates expiring soon')} + /> + + + ); +} diff --git a/cert-manager/src/components/common/CommonComponents.tsx b/cert-manager/src/components/common/CommonComponents.tsx index 58914726a5..0965cc2391 100644 --- a/cert-manager/src/components/common/CommonComponents.tsx +++ b/cert-manager/src/components/common/CommonComponents.tsx @@ -145,6 +145,22 @@ export function ConditionsTable({ conditions }: ConditionsTableProps) { ); } +export function SecretNameLink({ name, namespace }: { name?: string; namespace?: string }) { + if (!name) { + return null; + } + + if (!namespace) { + return <>{name}; + } + + return ( + + {name} + + ); +} + interface SecretKeySelectorProps { selector: SecretKeySelector; namespace?: string; @@ -157,16 +173,7 @@ export function SecretKeySelectorComponent({ selector, namespace }: SecretKeySel rows={[ { name: t('Name'), - value: namespace ? ( - - {selector.name} - - ) : ( - selector.name - ), + value: , }, { name: t('Key'), diff --git a/cert-manager/src/index.tsx b/cert-manager/src/index.tsx index 982d56a39e..e56453b25d 100644 --- a/cert-manager/src/index.tsx +++ b/cert-manager/src/index.tsx @@ -3,6 +3,7 @@ import { CertificateRequestDetail } from './components/certificateRequests/Detai import { CertificateRequestsList } from './components/certificateRequests/List'; import { CertificateDetail } from './components/certificates/Detail'; import { CertificatesList } from './components/certificates/List'; +import { CertificatesOverview } from './components/certificates/Overview'; import { ChallengeDetail } from './components/challenges/Detail'; import { ChallengesList } from './components/challenges/List'; import { ClusterIssuerDetail } from './components/clusterIssuers/Detail'; @@ -51,12 +52,27 @@ function registerCertManagerResource(config: ResourceRegistrationConfig) { // Main Cert-manager sidebar entry registerSidebarEntry({ name: 'Cert-manager', - url: '/cert-manager/certificates', + url: '/cert-manager/overview', icon: 'mdi:certificate', parent: '', label: 'cert-manager', }); +registerSidebarEntry({ + name: 'Overview', + url: '/cert-manager/overview', + parent: 'Cert-manager', + label: 'Overview', +}); + +registerRoute({ + path: '/cert-manager/overview', + sidebar: 'Overview', + name: 'Overview', + exact: true, + component: () => , +}); + // Register all resources registerCertManagerResource({ name: 'Certificates', diff --git a/cert-manager/src/utils/certificateExpiry.test.ts b/cert-manager/src/utils/certificateExpiry.test.ts new file mode 100644 index 0000000000..b89f262fda --- /dev/null +++ b/cert-manager/src/utils/certificateExpiry.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import { formatExpiryLabel, getCertificateExpiry, needsExpiryAttention } from './certificateExpiry'; + +const NOW = Date.parse('2026-08-19T12:00:00.000Z'); + +function isoFromDays(days: number): string { + return new Date(NOW + days * 24 * 60 * 60 * 1000).toISOString(); +} + +describe('getCertificateExpiry', () => { + it('returns unknown when notAfter is missing or invalid', () => { + expect(getCertificateExpiry(undefined, NOW).level).toBe('unknown'); + expect(getCertificateExpiry('not-a-date', NOW).level).toBe('unknown'); + }); + + it('marks certificates with more than 30 days left as ok', () => { + const expiry = getCertificateExpiry(isoFromDays(45), NOW); + expect(expiry.level).toBe('ok'); + expect(expiry.daysRemaining).toBe(45); + expect(expiry.statusLabelStatus).toBe('success'); + }); + + it('marks certificates with fewer than 30 days left as warning', () => { + const expiry = getCertificateExpiry(isoFromDays(12), NOW); + expect(expiry.level).toBe('warning'); + expect(expiry.statusLabelStatus).toBe('warning'); + }); + + it('marks certificates with fewer than 7 days left as critical', () => { + const expiry = getCertificateExpiry(isoFromDays(3), NOW); + expect(expiry.level).toBe('critical'); + expect(expiry.statusLabelStatus).toBe('error'); + }); + + it('treats exactly 7 days as warning, not critical', () => { + expect(getCertificateExpiry(isoFromDays(7), NOW).level).toBe('warning'); + }); + + it('treats exactly 30 days as warning', () => { + expect(getCertificateExpiry(isoFromDays(30), NOW).level).toBe('warning'); + }); + + it('marks past notAfter as expired', () => { + const expiry = getCertificateExpiry(isoFromDays(-1), NOW); + expect(expiry.level).toBe('expired'); + expect(expiry.statusLabelStatus).toBe('error'); + }); +}); + +describe('needsExpiryAttention', () => { + it('is true for warning, critical, and expired certificates', () => { + expect(needsExpiryAttention(getCertificateExpiry(isoFromDays(45), NOW))).toBe(false); + expect(needsExpiryAttention(getCertificateExpiry(isoFromDays(12), NOW))).toBe(true); + expect(needsExpiryAttention(getCertificateExpiry(isoFromDays(1), NOW))).toBe(true); + expect(needsExpiryAttention(getCertificateExpiry(isoFromDays(-2), NOW))).toBe(true); + }); +}); + +describe('formatExpiryLabel', () => { + const t = (key: string, options?: Record) => + key === '{{count}} days' ? `${options?.count} days` : key; + + it('formats unknown, expired, and remaining days', () => { + expect(formatExpiryLabel(getCertificateExpiry(undefined, NOW), t)).toBe('Unknown'); + expect(formatExpiryLabel(getCertificateExpiry(isoFromDays(-1), NOW), t)).toBe('Expired'); + expect(formatExpiryLabel(getCertificateExpiry(isoFromDays(1), NOW), t)).toBe('1 day'); + expect(formatExpiryLabel(getCertificateExpiry(isoFromDays(12), NOW), t)).toBe('12 days'); + }); +}); diff --git a/cert-manager/src/utils/certificateExpiry.ts b/cert-manager/src/utils/certificateExpiry.ts new file mode 100644 index 0000000000..4f9c79c06b --- /dev/null +++ b/cert-manager/src/utils/certificateExpiry.ts @@ -0,0 +1,60 @@ +export type CertificateExpiryLevel = 'ok' | 'warning' | 'critical' | 'expired' | 'unknown'; + +export type StatusLabelStatus = 'success' | 'warning' | 'error' | ''; + +export interface CertificateExpiry { + level: CertificateExpiryLevel; + daysRemaining: number | null; + statusLabelStatus: StatusLabelStatus; +} + +const MS_PER_DAY = 24 * 60 * 60 * 1000; +const WARNING_DAYS = 30; +const CRITICAL_DAYS = 7; + +export function getCertificateExpiry( + notAfter: string | undefined, + now = Date.now() +): CertificateExpiry { + if (!notAfter) { + return { level: 'unknown', daysRemaining: null, statusLabelStatus: '' }; + } + + const expiry = Date.parse(notAfter); + if (Number.isNaN(expiry)) { + return { level: 'unknown', daysRemaining: null, statusLabelStatus: '' }; + } + + const daysRemaining = Math.floor((expiry - now) / MS_PER_DAY); + + if (daysRemaining < 0) { + return { level: 'expired', daysRemaining, statusLabelStatus: 'error' }; + } + if (daysRemaining < CRITICAL_DAYS) { + return { level: 'critical', daysRemaining, statusLabelStatus: 'error' }; + } + if (daysRemaining <= WARNING_DAYS) { + return { level: 'warning', daysRemaining, statusLabelStatus: 'warning' }; + } + return { level: 'ok', daysRemaining, statusLabelStatus: 'success' }; +} + +export function needsExpiryAttention(expiry: CertificateExpiry): boolean { + return expiry.level === 'warning' || expiry.level === 'critical' || expiry.level === 'expired'; +} + +export function formatExpiryLabel( + expiry: CertificateExpiry, + t: (key: string, options?: Record) => string +): string { + if (expiry.level === 'unknown' || expiry.daysRemaining === null) { + return t('Unknown'); + } + if (expiry.level === 'expired') { + return t('Expired'); + } + if (expiry.daysRemaining === 1) { + return t('1 day'); + } + return t('{{count}} days', { count: expiry.daysRemaining }); +}