diff --git a/app/(dashboard)/certificates/page.tsx b/app/(dashboard)/certificates/page.tsx index 4931262f..717a0290 100644 --- a/app/(dashboard)/certificates/page.tsx +++ b/app/(dashboard)/certificates/page.tsx @@ -91,7 +91,7 @@ export default async function CertificatesPage({ searchParams }: PageProps) { ]); const mtlsRoles = await listMtlsRoles().catch(() => []); - const [acmeRows, acmeTotal, certRows, usageRows] = await Promise.all([ + const [allAcmeRows, certRows, usageRows] = await Promise.all([ db .select({ id: proxyHosts.id, @@ -102,14 +102,7 @@ export default async function CertificatesPage({ searchParams }: PageProps) { }) .from(proxyHosts) .where(isNull(proxyHosts.certificateId)) - .orderBy(proxyHosts.name) - .limit(PER_PAGE) - .offset(offset), - db - .select({ value: count() }) - .from(proxyHosts) - .where(isNull(proxyHosts.certificateId)) - .then(([r]) => r?.value ?? 0), + .orderBy(proxyHosts.name), db.select().from(certificates), db .select({ @@ -122,7 +115,7 @@ export default async function CertificatesPage({ searchParams }: PageProps) { .where(isNotNull(proxyHosts.certificateId)), ]); - const acmeHosts: AcmeHost[] = acmeRows.map(r => ({ + const allAcmeHosts: AcmeHost[] = allAcmeRows.map(r => ({ id: r.id, name: r.name, domains: JSON.parse(r.domains) as string[], @@ -159,9 +152,8 @@ export default async function CertificatesPage({ searchParams }: PageProps) { // Filter out ACME hosts whose domains are fully covered by an existing certificate's wildcard, // and attribute them to that certificate's usedBy list instead. - let adjustedAcmeTotal = acmeTotal; const filteredAcmeHosts: AcmeHost[] = []; - for (const host of acmeHosts) { + for (const host of allAcmeHosts) { let coveredByCertId: number | null = null; for (const [certId, certDomains] of certDomainMap) { if (host.domains.every(d => isDomainCoveredByCert(d, certDomains))) { @@ -174,7 +166,6 @@ export default async function CertificatesPage({ searchParams }: PageProps) { const hosts = usageMap.get(coveredByCertId) ?? []; hosts.push({ id: host.id, name: host.name, domains: host.domains }); usageMap.set(coveredByCertId, hosts); - adjustedAcmeTotal--; } else { filteredAcmeHosts.push(host); } @@ -195,13 +186,15 @@ export default async function CertificatesPage({ searchParams }: PageProps) { const coveredByWildcard = wildcardDomainSets.some(wcDomains => host.domains.every(d => isDomainCoveredByCert(d, wcDomains)) ); - if (coveredByWildcard) { - adjustedAcmeTotal--; - } else { + if (!coveredByWildcard) { deduplicatedAcmeHosts.push(host); } } + // Paginate the deduplicated ACME hosts + const adjustedAcmeTotal = deduplicatedAcmeHosts.length; + const paginatedAcmeHosts = deduplicatedAcmeHosts.slice(offset, offset + PER_PAGE); + const importedCerts: ImportedCertView[] = []; const managedCerts: ManagedCertView[] = []; const issuedByCa = issuedClientCerts.reduce>((map, cert) => { @@ -236,7 +229,7 @@ export default async function CertificatesPage({ searchParams }: PageProps) { return ( { - const [proxyHostCountResult, acmeCertCountResult, importedCertCountResult, accessListCountResult] = + const [proxyHostCountResult, acmeRows, certRows, importedCertCountResult, accessListCountResult] = await Promise.all([ db.select({ value: count() }).from(proxyHosts), - // Proxy hosts with no explicit cert → Caddy auto-issues one ACME cert per host - db.select({ value: count() }).from(proxyHosts).where(isNull(proxyHosts.certificateId)), + // All proxy hosts with no explicit cert (for ACME deduplication) + db.select({ domains: proxyHosts.domains }).from(proxyHosts).where(isNull(proxyHosts.certificateId)), + // All certs (for wildcard coverage check) + db.select({ id: certificates.id, type: certificates.type, domainNames: certificates.domainNames, certificatePem: certificates.certificatePem }).from(certificates), // Imported certs with actual PEM data (valid, user-managed) db.select({ value: count() }).from(certificates).where( sql`${certificates.type} = 'imported' AND ${certificates.certificatePem} IS NOT NULL` ), db.select({ value: count() }).from(accessLists) ]); + + // Build cert domain map for wildcard coverage checks + const certDomainMap = new Map(); + for (const cert of certRows) { + certDomainMap.set(cert.id, JSON.parse(cert.domainNames) as string[]); + } + + // Deduplicate ACME hosts: remove those covered by a cert's wildcard or another ACME wildcard + const acmeHostDomains = acmeRows.map(r => JSON.parse(r.domains) as string[]); + const wildcardAcmeDomainSets = acmeHostDomains.filter(domains => domains.some((d: string) => d.startsWith('*.'))); + + let acmeCount = 0; + for (const domains of acmeHostDomains) { + // Check if covered by an existing certificate's wildcard + let covered = false; + for (const [, certDomains] of certDomainMap) { + if (domains.every((d: string) => isDomainCoveredByCert(d, certDomains))) { + covered = true; + break; + } + } + // Check if this non-wildcard host is covered by a wildcard ACME host + if (!covered && !domains.some((d: string) => d.startsWith('*.'))) { + covered = wildcardAcmeDomainSets.some(wcDomains => + domains.every((d: string) => isDomainCoveredByCert(d, wcDomains)) + ); + } + if (!covered) acmeCount++; + } + const proxyHostsCount = proxyHostCountResult[0]?.value ?? 0; - const certificatesCount = (acmeCertCountResult[0]?.value ?? 0) + (importedCertCountResult[0]?.value ?? 0); + const certificatesCount = acmeCount + (importedCertCountResult[0]?.value ?? 0); const accessListsCount = accessListCountResult[0]?.value ?? 0; return [ diff --git a/tests/e2e/certificates.spec.ts b/tests/e2e/certificates.spec.ts index 2b0840d4..b93f4965 100644 --- a/tests/e2e/certificates.spec.ts +++ b/tests/e2e/certificates.spec.ts @@ -107,8 +107,8 @@ test.describe('Certificates', () => { await page.getByRole('tab', { name: /acme/i }).click(); const acmeTab = page.locator('[role="tabpanel"]'); - // The wildcard host should be visible - await expect(acmeTab.getByText(`*.${domain}`)).toBeVisible({ timeout: 5_000 }); + // The wildcard host should be visible (use .first() as DataTable renders both mobile card and desktop table) + await expect(acmeTab.getByText(`*.${domain}`).first()).toBeVisible({ timeout: 5_000 }); // The subdomain host should NOT appear as a separate entry await expect(acmeTab.getByText(`sub.${domain}`)).not.toBeVisible({ timeout: 5_000 }); } finally {