Fix inflated certificate counts in dashboard and certificates page
The dashboard overview counted all proxy hosts without a cert as ACME certs, ignoring wildcard deduplication. The certificates page only deduplicated the current page of results (25 rows) but used a full count(*) for the total, so hosts on other pages covered by wildcards were never subtracted. Now both pages fetch all ACME hosts, apply full deduplication (cert wildcard coverage + ACME wildcard collapsing), then paginate/count from the deduplicated result. Also fixes a strict-mode violation in the E2E test where DataTable's dual mobile/desktop rendering caused getByText to match two elements. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<number, IssuedClientCertificate[]>>((map, cert) => {
|
||||
@@ -236,7 +229,7 @@ export default async function CertificatesPage({ searchParams }: PageProps) {
|
||||
|
||||
return (
|
||||
<CertificatesClient
|
||||
acmeHosts={deduplicatedAcmeHosts}
|
||||
acmeHosts={paginatedAcmeHosts}
|
||||
importedCerts={importedCerts}
|
||||
managedCerts={managedCerts}
|
||||
caCertificates={caCertificateViews}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { count, desc, isNull, sql } from "drizzle-orm";
|
||||
import { ArrowLeftRight, ShieldCheck, KeyRound } from "lucide-react";
|
||||
import { ReactNode } from "react";
|
||||
import { getAnalyticsSummary } from "@/src/lib/analytics-db";
|
||||
import { isDomainCoveredByCert } from "@/src/lib/cert-domain-match";
|
||||
|
||||
type StatCard = {
|
||||
label: string;
|
||||
@@ -20,19 +21,51 @@ type StatCard = {
|
||||
};
|
||||
|
||||
async function loadStats(): Promise<StatCard[]> {
|
||||
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<number, string[]>();
|
||||
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 [
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user