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 });
+}