diff --git a/docs/plans/ssl_card_pending_fix.md b/docs/plans/ssl_card_pending_fix.md new file mode 100644 index 00000000..1713f531 --- /dev/null +++ b/docs/plans/ssl_card_pending_fix.md @@ -0,0 +1,501 @@ +# Bug Fix: SSL Card Shows "Waiting for 14 Certs" When All Certificates Are Loaded + +**Status:** Ready for Implementation +**Created:** December 12, 2025 +**Priority:** High (User-facing display bug) + +--- + +## Issue Summary + +The SSL card on the Dashboard incorrectly shows "waiting for 14 certs" (or similar) when all SSL certificates have actually been provisioned and are available. This is a **logic bug in the "pending" certificate detection**. + +--- + +## Root Cause Analysis + +### The Current Logic + +In [frontend/src/components/CertificateStatusCard.tsx](../../frontend/src/components/CertificateStatusCard.tsx#L16-L19): + +```tsx +// Pending = hosts with ssl_forced and enabled but no certificate_id +const sslHosts = hosts.filter(h => h.ssl_forced && h.enabled) +const hostsWithCerts = sslHosts.filter(h => h.certificate_id != null) +const pendingCount = sslHosts.length - hostsWithCerts.length +``` + +### Why This Logic Is Wrong + +**The fundamental misunderstanding**: The code assumes `certificate_id` on a `ProxyHost` indicates whether that host has a valid SSL certificate. **This is incorrect for ACME (Let's Encrypt/ZeroSSL) managed certificates.** + +**How Caddy/Charon actually works:** + +1. **ACME certificates (Let's Encrypt, ZeroSSL)**: Caddy **automatically** provisions and manages these certificates. The `certificate_id` field on `ProxyHost` is **NOT** used for ACME certs - it's only for custom uploaded certificates. + +2. **Custom certificates**: Only when a user uploads a custom certificate is it stored in `ssl_certificates` table and linked via `certificate_id` on the proxy host. + +3. **Backend evidence** - From [backend/internal/api/routes/routes.go](../../backend/internal/api/routes/routes.go#L70-L73): + ```go + // Let's Encrypt certs are auto-managed by Caddy and should not be assigned via certificate_id + ``` + +4. **Certificate service** - From [backend/internal/services/certificate_service.go](../../backend/internal/services/certificate_service.go#L97-L105): + The certificate service scans `data/certificates/` directory for `.crt` files and syncs them to the database. These certificates are discovered **by domain name**, not by any host relationship. + +### The Actual Bug + +When a user has: +- 14 proxy hosts with `ssl_forced: true` +- All 14 hosts have ACME-managed certificates (Let's Encrypt/ZeroSSL) +- **None** of these hosts have `certificate_id` set (because ACME certs don't use this field) + +The current logic sees 14 SSL hosts with no `certificate_id` → reports "14 hosts awaiting certificate" ❌ + +### Data Flow Diagram + +``` +ProxyHost SSLCertificate (DB) +┌─────────────────────┐ ┌──────────────────────┐ +│ uuid: "abc123" │ │ domain: "example.com"│ +│ domain_names: │ NO LINK │ provider: "letsencrypt" +│ "example.com" │ ══════════════ │ status: "valid" │ +│ ssl_forced: true │ (for ACME) │ expires_at: ... │ +│ certificate_id: null│ └──────────────────────┘ +└─────────────────────┘ + ↓ + Current logic sees certificate_id: null + ↓ + Incorrectly reports as "pending" +``` + +--- + +## Correct Solution + +### New Logic: Match by Domain Name + +Instead of checking `certificate_id`, we should check if a certificate **exists for the host's domain(s)**: + +```tsx +// A host is "pending" if: +// 1. ssl_forced is true AND enabled is true +// 2. AND there's no certificate with a matching domain + +// Build a set of all domains that have certificates +const certifiedDomains = new Set() +certificates.forEach(cert => { + // Certificate domains can be comma-separated + cert.domain.split(',').forEach(d => { + certifiedDomains.add(d.trim().toLowerCase()) + }) +}) + +// Check each SSL host +const sslHosts = hosts.filter(h => h.ssl_forced && h.enabled) +const pendingHosts = sslHosts.filter(host => { + // Check if ANY of the host's domains have a certificate + const hostDomains = host.domain_names.split(',').map(d => d.trim().toLowerCase()) + return !hostDomains.some(domain => certifiedDomains.has(domain)) +}) + +const pendingCount = pendingHosts.length +``` + +--- + +## Files to Modify + +### Frontend Changes + +| File | Change | +|------|--------| +| [frontend/src/components/CertificateStatusCard.tsx](../../frontend/src/components/CertificateStatusCard.tsx) | Update pending detection logic to match by domain | +| [frontend/src/components/__tests__/CertificateStatusCard.test.tsx](../../frontend/src/components/__tests__/CertificateStatusCard.test.tsx) | Update tests for new domain-matching logic | + +### No Backend Changes Required + +The backend already provides: +- `ProxyHost.domain_names` - comma-separated list of domains +- `Certificate.domain` - the domain(s) covered by the certificate + +--- + +## Implementation Details + +### Updated CertificateStatusCard.tsx + +```tsx +import { useMemo } from 'react' +import { Link } from 'react-router-dom' +import { Loader2 } from 'lucide-react' +import type { Certificate } from '../api/certificates' +import type { ProxyHost } from '../api/proxyHosts' + +interface CertificateStatusCardProps { + certificates: Certificate[] + hosts: ProxyHost[] +} + +export default function CertificateStatusCard({ certificates, hosts }: CertificateStatusCardProps) { + const validCount = certificates.filter(c => c.status === 'valid').length + const expiringCount = certificates.filter(c => c.status === 'expiring').length + const untrustedCount = certificates.filter(c => c.status === 'untrusted').length + + // Build a set of all domains that have certificates (case-insensitive) + const certifiedDomains = useMemo(() => { + const domains = new Set() + certificates.forEach(cert => { + // Certificate domain field can be comma-separated + cert.domain.split(',').forEach(d => { + const trimmed = d.trim().toLowerCase() + if (trimmed) domains.add(trimmed) + }) + }) + return domains + }, [certificates]) + + // Calculate pending hosts: SSL-enabled hosts without any domain covered by a certificate + const { pendingCount, totalSSLHosts, hostsWithCerts } = useMemo(() => { + const sslHosts = hosts.filter(h => h.ssl_forced && h.enabled) + + let withCerts = 0 + sslHosts.forEach(host => { + // Check if any of the host's domains have a certificate + const hostDomains = host.domain_names.split(',').map(d => d.trim().toLowerCase()) + if (hostDomains.some(domain => certifiedDomains.has(domain))) { + withCerts++ + } + }) + + return { + pendingCount: sslHosts.length - withCerts, + totalSSLHosts: sslHosts.length, + hostsWithCerts: withCerts, + } + }, [hosts, certifiedDomains]) + + const hasProvisioning = pendingCount > 0 + const progressPercent = totalSSLHosts > 0 + ? Math.round((hostsWithCerts / totalSSLHosts) * 100) + : 100 + + return ( + +
SSL Certificates
+
{certificates.length}
+ + {/* Status breakdown */} +
+ {validCount} valid + {expiringCount > 0 && {expiringCount} expiring} + {untrustedCount > 0 && {untrustedCount} staging} +
+ + {/* Pending indicator */} + {hasProvisioning && ( +
+
+ + {pendingCount} host{pendingCount !== 1 ? 's' : ''} awaiting certificate +
+
+
+
+
{progressPercent}% provisioned
+
+ )} + + ) +} +``` + +--- + +## Updated Test Cases + +### Key Test Changes + +The existing tests rely on `certificate_id` for determining "pending" status. We need to update these to use domain-based matching. + +**File:** `frontend/src/components/__tests__/CertificateStatusCard.test.tsx` + +#### Tests to Keep (Unchanged) +- `shows total certificate count` +- `shows valid certificate count` +- `shows expiring count when certificates are expiring` +- `hides expiring count when no certificates are expiring` +- `shows staging count for untrusted certificates` +- `hides staging count when no untrusted certificates` +- `links to certificates page` +- `handles empty certificates array` +- `shows spinning loader icon when pending` + +#### Tests to Update + +**Remove** tests that check `certificate_id` directly: +- `shows pending indicator when hosts lack certificates` - needs domain matching +- `shows plural for multiple pending hosts` - needs domain matching +- `hides pending indicator when all hosts have certificates` - needs domain matching +- `ignores disabled hosts when calculating pending` - update to use domain +- `ignores hosts without SSL when calculating pending` - update to use domain +- `calculates progress percentage correctly` - update to use domain + +**Add** new domain-based tests: + +```tsx +const mockCertWithDomain = (domain: string, status: 'valid' | 'expiring' | 'expired' | 'untrusted' = 'valid'): Certificate => ({ + id: Math.random(), + name: domain, + domain: domain, + issuer: "Let's Encrypt", + expires_at: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(), + status, + provider: 'letsencrypt', +}) + +describe('CertificateStatusCard - Domain Matching', () => { + it('does not show pending when host domain matches certificate domain', () => { + const certs: Certificate[] = [mockCertWithDomain('example.com')] + const hosts: ProxyHost[] = [ + { ...mockHost, domain_names: 'example.com', ssl_forced: true, certificate_id: null, enabled: true } + ] + renderWithRouter() + + // Should NOT show "awaiting certificate" since domain matches + expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument() + }) + + it('shows pending when host domain has no matching certificate', () => { + const certs: Certificate[] = [mockCertWithDomain('other.com')] + const hosts: ProxyHost[] = [ + { ...mockHost, domain_names: 'example.com', ssl_forced: true, certificate_id: null, enabled: true } + ] + renderWithRouter() + + expect(screen.getByText('1 host awaiting certificate')).toBeInTheDocument() + }) + + it('handles case-insensitive domain matching', () => { + const certs: Certificate[] = [mockCertWithDomain('EXAMPLE.COM')] + const hosts: ProxyHost[] = [ + { ...mockHost, domain_names: 'example.com', ssl_forced: true, certificate_id: null, enabled: true } + ] + renderWithRouter() + + expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument() + }) + + it('handles multi-domain hosts with partial certificate coverage', () => { + // Host has two domains, but only one has a certificate - should be "covered" + const certs: Certificate[] = [mockCertWithDomain('example.com')] + const hosts: ProxyHost[] = [ + { ...mockHost, domain_names: 'example.com, www.example.com', ssl_forced: true, certificate_id: null, enabled: true } + ] + renderWithRouter() + + // Host should be considered "covered" if any domain has a cert + expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument() + }) + + it('handles comma-separated certificate domains', () => { + const certs: Certificate[] = [{ + ...mockCertWithDomain('example.com'), + domain: 'example.com, www.example.com' + }] + const hosts: ProxyHost[] = [ + { ...mockHost, domain_names: 'www.example.com', ssl_forced: true, certificate_id: null, enabled: true } + ] + renderWithRouter() + + expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument() + }) + + it('ignores disabled hosts even without certificate', () => { + const certs: Certificate[] = [] + const hosts: ProxyHost[] = [ + { ...mockHost, domain_names: 'example.com', ssl_forced: true, certificate_id: null, enabled: false } + ] + renderWithRouter() + + expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument() + }) + + it('ignores hosts without SSL forced', () => { + const certs: Certificate[] = [] + const hosts: ProxyHost[] = [ + { ...mockHost, domain_names: 'example.com', ssl_forced: false, certificate_id: null, enabled: true } + ] + renderWithRouter() + + expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument() + }) + + it('calculates progress percentage with domain matching', () => { + const certs: Certificate[] = [ + mockCertWithDomain('a.example.com'), + mockCertWithDomain('b.example.com'), + ] + const hosts: ProxyHost[] = [ + { ...mockHost, uuid: 'h1', domain_names: 'a.example.com', ssl_forced: true, certificate_id: null, enabled: true }, + { ...mockHost, uuid: 'h2', domain_names: 'b.example.com', ssl_forced: true, certificate_id: null, enabled: true }, + { ...mockHost, uuid: 'h3', domain_names: 'c.example.com', ssl_forced: true, certificate_id: null, enabled: true }, + { ...mockHost, uuid: 'h4', domain_names: 'd.example.com', ssl_forced: true, certificate_id: null, enabled: true }, + ] + renderWithRouter() + + // 2 out of 4 hosts have matching certs = 50% + expect(screen.getByText('50% provisioned')).toBeInTheDocument() + expect(screen.getByText('2 hosts awaiting certificate')).toBeInTheDocument() + }) + + it('shows all pending when no certificates exist', () => { + const certs: Certificate[] = [] + const hosts: ProxyHost[] = [ + { ...mockHost, uuid: 'h1', domain_names: 'a.example.com', ssl_forced: true, certificate_id: null, enabled: true }, + { ...mockHost, uuid: 'h2', domain_names: 'b.example.com', ssl_forced: true, certificate_id: null, enabled: true }, + ] + renderWithRouter() + + expect(screen.getByText('2 hosts awaiting certificate')).toBeInTheDocument() + expect(screen.getByText('0% provisioned')).toBeInTheDocument() + }) +}) +``` + +--- + +## Edge Cases to Handle + +| Scenario | Expected Behavior | +|----------|-------------------| +| Multiple domains per host | Covered if **any** domain has a certificate | +| Case sensitivity | Case-insensitive matching (`Example.COM` = `example.com`) | +| Whitespace in domain list | Trim whitespace from both sides | +| Custom certificates | Hosts with `certificate_id` set should still be considered "covered" | +| Disabled hosts | Ignored (not counted as pending) | +| Non-SSL hosts (`ssl_forced: false`) | Ignored (not counted as pending) | +| Empty domain list | Ignored | +| Wildcard certs | Currently not specially handled (future enhancement) | + +--- + +## Testing Plan + +### Unit Tests (Automated) + +1. ✅ Host with matching certificate domain → not pending +2. ✅ Host with no matching certificate → pending +3. ✅ Host with multiple domains, one matched → not pending +4. ✅ Case-insensitive domain matching +5. ✅ Disabled hosts ignored +6. ✅ Non-SSL hosts ignored +7. ✅ Progress percentage calculation with domain matching +8. ✅ Empty certificates list → all SSL hosts pending +9. ✅ Empty hosts list → no pending indicator + +### Manual Testing + +1. Import a Caddyfile with multiple hosts +2. Wait for ACME certificates to be provisioned (1-5 minutes) +3. Verify dashboard SSL card: + - Shows correct number of certificates + - Shows "X valid" count + - Does NOT show "waiting for X certs" when all domains have certs +4. Verify the pending indicator appears only when: + - A new host is added and certificate hasn't been provisioned yet + - Truly no certificate exists for that domain + +--- + +## Dashboard Polling Behavior + +The Dashboard already has conditional polling logic: + +```tsx +// Dashboard.tsx +const hasPendingCerts = useMemo(() => { + const sslHosts = hosts.filter(h => h.ssl_forced && h.enabled) + return sslHosts.some(h => !h.certificate_id) // <-- This also needs updating +}, [hosts]) + +const { certificates } = useCertificates({ + refetchInterval: hasPendingCerts ? 15000 : false, +}) +``` + +**Also update Dashboard.tsx** to use domain-based checking for the polling trigger: + +```tsx +const hasPendingCerts = useMemo(() => { + // Build set of certified domains + const certifiedDomains = new Set() + certificates.forEach(cert => { + cert.domain.split(',').forEach(d => { + certifiedDomains.add(d.trim().toLowerCase()) + }) + }) + + // Check if any SSL host lacks a certificate + const sslHosts = hosts.filter(h => h.ssl_forced && h.enabled) + return sslHosts.some(host => { + const hostDomains = host.domain_names.split(',').map(d => d.trim().toLowerCase()) + return !hostDomains.some(domain => certifiedDomains.has(domain)) + }) +}, [hosts, certificates]) +``` + +--- + +## Rollback Plan + +If issues arise, the fallback approach keeps backward compatibility: + +```tsx +// Fallback: host is "pending" only if: +// 1. No certificate_id AND +// 2. No certificate with matching domain +const pendingHosts = sslHosts.filter(host => { + if (host.certificate_id != null) return false // Has custom cert + const hostDomains = host.domain_names.split(',').map(d => d.trim().toLowerCase()) + return !hostDomains.some(domain => certifiedDomains.has(domain)) +}) +``` + +--- + +## Acceptance Criteria + +- [ ] SSL card does not show "waiting for X certs" when all certificates are provisioned +- [ ] SSL card correctly shows pending count only for hosts without any matching certificate +- [ ] Domain matching is case-insensitive +- [ ] Multi-domain hosts are handled correctly +- [ ] Existing custom certificate workflow still works (hosts with `certificate_id`) +- [ ] Dashboard polling stops correctly once all domains have certificates +- [ ] All unit tests pass +- [ ] No regression in certificate status display + +--- + +## Summary + +| Aspect | Current (Bug) | Fixed | +|--------|---------------|-------| +| Check method | `certificate_id != null` | Domain name matching | +| ACME certs | Always "pending" | Correctly detected as covered | +| Custom certs | Works | Still works | +| Multi-domain | N/A | Covered if any domain matches | +| Case sensitivity | N/A | Case-insensitive | +| Polling | Never stops | Stops when all covered | + +**Root Cause:** Using `certificate_id` (custom cert reference) instead of domain matching for ACME certificates. + +**Fix:** Match host domains against certificate domains to determine coverage. + +**Effort:** ~1 hour implementation + testing diff --git a/frontend/src/components/CertificateStatusCard.tsx b/frontend/src/components/CertificateStatusCard.tsx index daf7012d..304860d8 100644 --- a/frontend/src/components/CertificateStatusCard.tsx +++ b/frontend/src/components/CertificateStatusCard.tsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react' import { Link } from 'react-router-dom' import { Loader2 } from 'lucide-react' import type { Certificate } from '../api/certificates' @@ -13,14 +14,46 @@ export default function CertificateStatusCard({ certificates, hosts }: Certifica const expiringCount = certificates.filter(c => c.status === 'expiring').length const untrustedCount = certificates.filter(c => c.status === 'untrusted').length - // Pending = hosts with ssl_forced and enabled but no certificate_id - const sslHosts = hosts.filter(h => h.ssl_forced && h.enabled) - const hostsWithCerts = sslHosts.filter(h => h.certificate_id != null) - const pendingCount = sslHosts.length - hostsWithCerts.length + // Build a set of all domains that have certificates (case-insensitive) + // ACME certificates (Let's Encrypt) are auto-managed and don't set certificate_id, + // so we match by domain name instead + const certifiedDomains = useMemo(() => { + const domains = new Set() + certificates.forEach(cert => { + // Handle missing or undefined domain field + if (!cert.domain) return + // Certificate domain field can be comma-separated + cert.domain.split(',').forEach(d => { + const trimmed = d.trim().toLowerCase() + if (trimmed) domains.add(trimmed) + }) + }) + return domains + }, [certificates]) + + // Calculate pending hosts: SSL-enabled hosts without any domain covered by a certificate + const { pendingCount, totalSSLHosts, hostsWithCerts } = useMemo(() => { + const sslHosts = hosts.filter(h => h.ssl_forced && h.enabled) + + let withCerts = 0 + sslHosts.forEach(host => { + // Check if any of the host's domains have a certificate + const hostDomains = host.domain_names.split(',').map(d => d.trim().toLowerCase()) + if (hostDomains.some(domain => certifiedDomains.has(domain))) { + withCerts++ + } + }) + + return { + pendingCount: sslHosts.length - withCerts, + totalSSLHosts: sslHosts.length, + hostsWithCerts: withCerts, + } + }, [hosts, certifiedDomains]) const hasProvisioning = pendingCount > 0 - const progressPercent = sslHosts.length > 0 - ? Math.round((hostsWithCerts.length / sslHosts.length) * 100) + const progressPercent = totalSSLHosts > 0 + ? Math.round((hostsWithCerts / totalSSLHosts) * 100) : 100 return ( diff --git a/frontend/src/components/__tests__/CertificateStatusCard.test.tsx b/frontend/src/components/__tests__/CertificateStatusCard.test.tsx index 839c0ff7..8d3b44f5 100644 --- a/frontend/src/components/__tests__/CertificateStatusCard.test.tsx +++ b/frontend/src/components/__tests__/CertificateStatusCard.test.tsx @@ -37,6 +37,19 @@ const mockHost: ProxyHost = { locations: [], } +// Helper to create a certificate with a specific domain +function mockCertWithDomain(domain: string, status: 'valid' | 'expiring' | 'expired' | 'untrusted' = 'valid'): Certificate { + return { + id: Math.floor(Math.random() * 10000), + name: domain, + domain: domain, + issuer: "Let's Encrypt", + expires_at: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(), + status, + provider: 'letsencrypt', + } +} + function renderWithRouter(ui: React.ReactNode) { return render({ui}) } @@ -95,72 +108,9 @@ describe('CertificateStatusCard', () => { expect(screen.queryByText(/staging/)).not.toBeInTheDocument() }) - it('shows pending indicator when hosts lack certificates', () => { - const hosts: ProxyHost[] = [ - { ...mockHost, ssl_forced: true, certificate_id: null, enabled: true }, - { ...mockHost, uuid: 'h2', ssl_forced: true, certificate_id: 1, enabled: true }, - ] - renderWithRouter() - - expect(screen.getByText('1 host awaiting certificate')).toBeInTheDocument() - }) - - it('shows plural for multiple pending hosts', () => { - const hosts: ProxyHost[] = [ - { ...mockHost, uuid: 'h1', ssl_forced: true, certificate_id: null, enabled: true }, - { ...mockHost, uuid: 'h2', ssl_forced: true, certificate_id: null, enabled: true }, - { ...mockHost, uuid: 'h3', ssl_forced: true, certificate_id: null, enabled: true }, - ] - renderWithRouter() - - expect(screen.getByText('3 hosts awaiting certificate')).toBeInTheDocument() - }) - - it('hides pending indicator when all hosts have certificates', () => { - const hosts: ProxyHost[] = [ - { ...mockHost, ssl_forced: true, certificate_id: 1, enabled: true }, - ] - renderWithRouter() - - expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument() - }) - - it('ignores disabled hosts when calculating pending', () => { - const hosts: ProxyHost[] = [ - { ...mockHost, uuid: 'h1', ssl_forced: true, certificate_id: null, enabled: false }, - { ...mockHost, uuid: 'h2', ssl_forced: true, certificate_id: 1, enabled: true }, - ] - renderWithRouter() - - expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument() - }) - - it('ignores hosts without SSL when calculating pending', () => { - const hosts: ProxyHost[] = [ - { ...mockHost, uuid: 'h1', ssl_forced: false, certificate_id: null, enabled: true }, - { ...mockHost, uuid: 'h2', ssl_forced: true, certificate_id: 1, enabled: true }, - ] - renderWithRouter() - - expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument() - }) - - it('calculates progress percentage correctly', () => { - const hosts: ProxyHost[] = [ - { ...mockHost, uuid: 'h1', ssl_forced: true, certificate_id: 1, enabled: true }, - { ...mockHost, uuid: 'h2', ssl_forced: true, certificate_id: null, enabled: true }, - { ...mockHost, uuid: 'h3', ssl_forced: true, certificate_id: 1, enabled: true }, - { ...mockHost, uuid: 'h4', ssl_forced: true, certificate_id: null, enabled: true }, - ] - renderWithRouter() - - // 2 out of 4 = 50% - expect(screen.getByText('50% provisioned')).toBeInTheDocument() - }) - it('shows spinning loader icon when pending', () => { const hosts: ProxyHost[] = [ - { ...mockHost, ssl_forced: true, certificate_id: null, enabled: true }, + { ...mockHost, domain_names: 'other.com', ssl_forced: true, certificate_id: null, enabled: true }, ] const { container } = renderWithRouter( @@ -184,3 +134,188 @@ describe('CertificateStatusCard', () => { expect(screen.getByText('0 valid')).toBeInTheDocument() }) }) + +describe('CertificateStatusCard - Domain Matching', () => { + it('does not show pending when host domain matches certificate domain', () => { + const certs: Certificate[] = [mockCertWithDomain('example.com')] + const hosts: ProxyHost[] = [ + { ...mockHost, domain_names: 'example.com', ssl_forced: true, certificate_id: null, enabled: true } + ] + renderWithRouter() + + // Should NOT show "awaiting certificate" since domain matches + expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument() + }) + + it('shows pending when host domain has no matching certificate', () => { + const certs: Certificate[] = [mockCertWithDomain('other.com')] + const hosts: ProxyHost[] = [ + { ...mockHost, domain_names: 'example.com', ssl_forced: true, certificate_id: null, enabled: true } + ] + renderWithRouter() + + expect(screen.getByText('1 host awaiting certificate')).toBeInTheDocument() + }) + + it('shows plural for multiple pending hosts', () => { + const certs: Certificate[] = [mockCertWithDomain('has-cert.com')] + const hosts: ProxyHost[] = [ + { ...mockHost, uuid: 'h1', domain_names: 'no-cert-1.com', ssl_forced: true, certificate_id: null, enabled: true }, + { ...mockHost, uuid: 'h2', domain_names: 'no-cert-2.com', ssl_forced: true, certificate_id: null, enabled: true }, + { ...mockHost, uuid: 'h3', domain_names: 'no-cert-3.com', ssl_forced: true, certificate_id: null, enabled: true }, + ] + renderWithRouter() + + expect(screen.getByText('3 hosts awaiting certificate')).toBeInTheDocument() + }) + + it('handles case-insensitive domain matching', () => { + const certs: Certificate[] = [mockCertWithDomain('EXAMPLE.COM')] + const hosts: ProxyHost[] = [ + { ...mockHost, domain_names: 'example.com', ssl_forced: true, certificate_id: null, enabled: true } + ] + renderWithRouter() + + expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument() + }) + + it('handles case-insensitive matching with host uppercase', () => { + const certs: Certificate[] = [mockCertWithDomain('example.com')] + const hosts: ProxyHost[] = [ + { ...mockHost, domain_names: 'EXAMPLE.COM', ssl_forced: true, certificate_id: null, enabled: true } + ] + renderWithRouter() + + expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument() + }) + + it('handles multi-domain hosts with partial certificate coverage', () => { + // Host has two domains, but only one has a certificate - should be "covered" + const certs: Certificate[] = [mockCertWithDomain('example.com')] + const hosts: ProxyHost[] = [ + { ...mockHost, domain_names: 'example.com, www.example.com', ssl_forced: true, certificate_id: null, enabled: true } + ] + renderWithRouter() + + // Host should be considered "covered" if any domain has a cert + expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument() + }) + + it('handles comma-separated certificate domains', () => { + const certs: Certificate[] = [{ + ...mockCertWithDomain('example.com'), + domain: 'example.com, www.example.com' + }] + const hosts: ProxyHost[] = [ + { ...mockHost, domain_names: 'www.example.com', ssl_forced: true, certificate_id: null, enabled: true } + ] + renderWithRouter() + + expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument() + }) + + it('ignores disabled hosts even without certificate', () => { + const certs: Certificate[] = [] + const hosts: ProxyHost[] = [ + { ...mockHost, domain_names: 'example.com', ssl_forced: true, certificate_id: null, enabled: false } + ] + renderWithRouter() + + expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument() + }) + + it('ignores hosts without SSL forced', () => { + const certs: Certificate[] = [] + const hosts: ProxyHost[] = [ + { ...mockHost, domain_names: 'example.com', ssl_forced: false, certificate_id: null, enabled: true } + ] + renderWithRouter() + + expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument() + }) + + it('calculates progress percentage with domain matching', () => { + const certs: Certificate[] = [ + mockCertWithDomain('a.example.com'), + mockCertWithDomain('b.example.com'), + ] + const hosts: ProxyHost[] = [ + { ...mockHost, uuid: 'h1', domain_names: 'a.example.com', ssl_forced: true, certificate_id: null, enabled: true }, + { ...mockHost, uuid: 'h2', domain_names: 'b.example.com', ssl_forced: true, certificate_id: null, enabled: true }, + { ...mockHost, uuid: 'h3', domain_names: 'c.example.com', ssl_forced: true, certificate_id: null, enabled: true }, + { ...mockHost, uuid: 'h4', domain_names: 'd.example.com', ssl_forced: true, certificate_id: null, enabled: true }, + ] + renderWithRouter() + + // 2 out of 4 hosts have matching certs = 50% + expect(screen.getByText('50% provisioned')).toBeInTheDocument() + expect(screen.getByText('2 hosts awaiting certificate')).toBeInTheDocument() + }) + + it('shows all pending when no certificates exist', () => { + const certs: Certificate[] = [] + const hosts: ProxyHost[] = [ + { ...mockHost, uuid: 'h1', domain_names: 'a.example.com', ssl_forced: true, certificate_id: null, enabled: true }, + { ...mockHost, uuid: 'h2', domain_names: 'b.example.com', ssl_forced: true, certificate_id: null, enabled: true }, + ] + renderWithRouter() + + expect(screen.getByText('2 hosts awaiting certificate')).toBeInTheDocument() + expect(screen.getByText('0% provisioned')).toBeInTheDocument() + }) + + it('shows 100% provisioned when all SSL hosts have matching certificates', () => { + const certs: Certificate[] = [ + mockCertWithDomain('a.example.com'), + mockCertWithDomain('b.example.com'), + ] + const hosts: ProxyHost[] = [ + { ...mockHost, uuid: 'h1', domain_names: 'a.example.com', ssl_forced: true, certificate_id: null, enabled: true }, + { ...mockHost, uuid: 'h2', domain_names: 'b.example.com', ssl_forced: true, certificate_id: null, enabled: true }, + ] + renderWithRouter() + + // Should NOT show awaiting indicator when all hosts are covered + expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument() + expect(screen.queryByText(/provisioned/)).not.toBeInTheDocument() + }) + + it('handles whitespace in domain names', () => { + const certs: Certificate[] = [mockCertWithDomain('example.com')] + const hosts: ProxyHost[] = [ + { ...mockHost, domain_names: ' example.com ', ssl_forced: true, certificate_id: null, enabled: true } + ] + renderWithRouter() + + expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument() + }) + + it('handles whitespace in certificate domains', () => { + const certs: Certificate[] = [{ + ...mockCertWithDomain('example.com'), + domain: ' example.com ' + }] + const hosts: ProxyHost[] = [ + { ...mockHost, domain_names: 'example.com', ssl_forced: true, certificate_id: null, enabled: true } + ] + renderWithRouter() + + expect(screen.queryByText(/awaiting certificate/)).not.toBeInTheDocument() + }) + + it('correctly counts mix of covered and uncovered hosts', () => { + const certs: Certificate[] = [mockCertWithDomain('covered.com')] + const hosts: ProxyHost[] = [ + { ...mockHost, uuid: 'h1', domain_names: 'covered.com', ssl_forced: true, certificate_id: null, enabled: true }, + { ...mockHost, uuid: 'h2', domain_names: 'uncovered.com', ssl_forced: true, certificate_id: null, enabled: true }, + { ...mockHost, uuid: 'h3', domain_names: 'disabled.com', ssl_forced: true, certificate_id: null, enabled: false }, + { ...mockHost, uuid: 'h4', domain_names: 'no-ssl.com', ssl_forced: false, certificate_id: null, enabled: true }, + ] + renderWithRouter() + + // Only h1 and h2 are SSL hosts that are enabled + // h1 is covered, h2 is not + expect(screen.getByText('1 host awaiting certificate')).toBeInTheDocument() + expect(screen.getByText('50% provisioned')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 551500d2..25dca71f 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,8 +1,8 @@ -import { useMemo } from 'react' +import { useMemo, useEffect } from 'react' import { useProxyHosts } from '../hooks/useProxyHosts' import { useRemoteServers } from '../hooks/useRemoteServers' import { useCertificates } from '../hooks/useCertificates' -import { useQuery } from '@tanstack/react-query' +import { useQuery, useQueryClient } from '@tanstack/react-query' import { checkHealth } from '../api/health' import { Link } from 'react-router-dom' import UptimeWidget from '../components/UptimeWidget' @@ -11,17 +11,43 @@ import CertificateStatusCard from '../components/CertificateStatusCard' export default function Dashboard() { const { hosts } = useProxyHosts() const { servers } = useRemoteServers() + const queryClient = useQueryClient() - // Detect if there are pending certificates (hosts with ssl_forced but no certificate_id) + // Fetch certificates (polling interval managed via effect below) + const { certificates } = useCertificates() + + // Build set of certified domains for pending detection + // ACME certificates (Let's Encrypt) are auto-managed and don't set certificate_id, + // so we match by domain name instead const hasPendingCerts = useMemo(() => { - const sslHosts = hosts.filter(h => h.ssl_forced && h.enabled) - return sslHosts.some(h => !h.certificate_id) - }, [hosts]) + const certifiedDomains = new Set() + certificates.forEach(cert => { + // Handle missing or undefined domain field + if (!cert.domain) return + cert.domain.split(',').forEach(d => { + const trimmed = d.trim().toLowerCase() + if (trimmed) certifiedDomains.add(trimmed) + }) + }) - // Poll certificates more frequently when there are pending certs - const { certificates } = useCertificates({ - refetchInterval: hasPendingCerts ? 15000 : false, // Poll every 15s when pending - }) + // Check if any SSL host lacks a certificate + const sslHosts = hosts.filter(h => h.ssl_forced && h.enabled) + return sslHosts.some(host => { + const hostDomains = host.domain_names.split(',').map(d => d.trim().toLowerCase()) + return !hostDomains.some(domain => certifiedDomains.has(domain)) + }) + }, [hosts, certificates]) + + // Poll certificates every 15s when there are pending certs + useEffect(() => { + if (!hasPendingCerts) return + + const interval = setInterval(() => { + queryClient.invalidateQueries({ queryKey: ['certificates'] }) + }, 15000) + + return () => clearInterval(interval) + }, [hasPendingCerts, queryClient]) // Use React Query for health check - benefits from global caching const { data: health } = useQuery({