Compare commits

..

5 Commits

Author SHA1 Message Date
shankar0123 a6515b4323 feat(Pre-2.1.0-E): GUI completeness — 5 new pages, clickable nav, verification badges
Wire all remaining backend features to the frontend GUI:

New pages:
- DigestPage: preview digest HTML via iframe + send with confirmation
- ObservabilityPage: health status, metrics gauges, Prometheus config + live output
- JobDetailPage: full job details, verification section, timeline, audit events
- IssuerDetailPage: redacted config, test connection, issued certificates list
- TargetDetailPage: config, agent link, deployment history with verification

Existing page updates:
- JobsPage: clickable job IDs, verification column with VerificationBadge
- IssuersPage: clickable issuer names linking to detail page
- TargetsPage: clickable target names linking to detail page
- Sidebar: Digest and Observability nav items
- 5 new routes in main.tsx

API client: getJob, getIssuer, getTarget, getJobVerification, getPrometheusMetrics
Tests: 7 new Vitest tests (203 total), testing-guide Part 37 (17 manual tests)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 14:10:58 -04:00
shankar0123 11173a74c6 feat(M31): agent work routing — scope jobs to assigned agents
Deployment jobs now set agent_id from target→agent relationship at
creation time. GetPendingWork() uses ListPendingByAgentID() with a
3-way UNION query (direct match, legacy NULL fallback via target JOIN,
AwaitingCSR via cert→target→agent chain) so each agent only receives
its own jobs.

- Added AgentID *string to Job domain struct
- Added agent_id to all job SQL queries (5 SELECTs, INSERT, UPDATE, scanJob)
- New ListPendingByAgentID() repository method
- Rewrote GetPendingWork() from ~25 lines to single scoped query
- 4 new Go tests (3 agent routing + 1 deployment agent_id)
- Frontend: agent_id/target_id on Job type

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 14:10:42 -04:00
shankar0123 ec0e7a3560 feat: wire ARI (RFC 9702) into renewal scheduler
CheckExpiringCertificates() now queries each issuer's ARI endpoint
before creating renewal jobs. If the CA says "not yet" (suggested
window hasn't opened), renewal is deferred. ARI errors fall back
gracefully to threshold-based logic. Audit trail records
renewal_trigger=ari when ARI drives the decision.

4 new unit tests: ShouldRenewNow, NotYet, NilFallback, ErrorFallback.
3 new smoke tests in testing-guide.md Part 35.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 12:11:42 -04:00
shankar0123 a0b9285323 fix(gui): add missing Name field to certificate creation form
The New Certificate modal was missing the required "name" field,
causing all certificate creation attempts to fail with "name is
required". Added Name text input above ID field with client-side
validation matching the backend requirement.

Fixes #GH-issue (name is required on certificate creation)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 07:53:14 -04:00
shankar0123 2655493ac8 fix(docs): correct migration guides — 17 issues found via repo audit
Fixes factual errors, broken links, wrong ports, inaccurate GUI
descriptions, and misleading config formats across all three migration
guides (certbot, acme.sh, cert-manager).

Key fixes:
- Correct server port from 8080/3000 to 8443 across all guides
- Fix HTTPS→HTTP for Docker Compose (not TLS-terminated)
- Fix heartbeat interval: 60 seconds, not 5 minutes
- Fix "50 servers" → "10 servers" (50 certs across 10 servers)
- Replace JSON config blocks with env var format (actual config method)
- Fix policy creation flow to match actual GUI (name/type/severity/config)
- Fix issuer wizard description to match actual 2-step flow
- Fix Vault PKI "coming in v2.1" → "planned" (ships post-2.1.0)
- Fix 5 broken links (cert-manager.md, quickstart anchors, architecture anchor)
- Remove claim of auto-generated suggestions in discovery flow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 01:34:22 -04:00
32 changed files with 1829 additions and 125 deletions
+1
View File
@@ -226,6 +226,7 @@ func main() {
certificateService.SetCAOperationsSvc(caOperationsSvc) certificateService.SetCAOperationsSvc(caOperationsSvc)
certificateService.SetTargetRepo(targetRepo) certificateService.SetTargetRepo(targetRepo)
renewalService := service.NewRenewalService(certificateRepo, jobRepo, renewalPolicyRepo, profileRepo, auditService, notificationService, issuerRegistry, cfg.Keygen.Mode) renewalService := service.NewRenewalService(certificateRepo, jobRepo, renewalPolicyRepo, profileRepo, auditService, notificationService, issuerRegistry, cfg.Keygen.Mode)
renewalService.SetTargetRepo(targetRepo)
deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certificateRepo, auditService, notificationService) deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certificateRepo, auditService, notificationService)
jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger) jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger)
agentService := service.NewAgentService(agentRepo, certificateRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService) agentService := service.NewAgentService(agentRepo, certificateRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService)
+1 -1
View File
@@ -45,7 +45,7 @@ New to certificates? Read the [Concepts Guide](concepts.md) first.
### Design Principles ### Design Principles
1. **Private Key Isolation** — Agents generate ECDSA P-256 keys locally and submit CSRs only. Private keys never touch the control plane. Server-side keygen available via `CERTCTL_KEYGEN_MODE=server` for demo only. 1. **Private Key Isolation** — Agents generate ECDSA P-256 keys locally and submit CSRs only. Private keys never touch the control plane. Server-side keygen available via `CERTCTL_KEYGEN_MODE=server` for demo only.
2. **Pull-Only Deployment** — The server never initiates outbound connections to agents or targets. Agents poll for work. For network appliances and agentless targets, a proxy agent in the same network zone executes deployments via the target's API. This keeps the control plane firewalled off and limits credential scope to the proxy agent's zone. 2. **Pull-Only Deployment** — The server never initiates outbound connections to agents or targets. Agents poll for work and receive only jobs assigned to their targets (routed via `agent_id` on jobs or through target→agent relationships). For network appliances and agentless targets, a proxy agent in the same network zone executes deployments via the target's API. This keeps the control plane firewalled off and limits credential scope to the proxy agent's zone.
3. **Sub-CA Capable** — The Local CA can operate as a subordinate CA under an enterprise root (e.g., ADCS). Load a pre-signed CA cert+key from disk and all issued certs chain to the enterprise trust hierarchy. Self-signed mode remains the default for development/demos. 3. **Sub-CA Capable** — The Local CA can operate as a subordinate CA under an enterprise root (e.g., ADCS). Load a pre-signed CA cert+key from disk and all issued certs chain to the enterprise trust hierarchy. Self-signed mode remains the default for development/demos.
4. **GUI as Primary Interface** — The web dashboard is the operational control plane, not a secondary viewer. Every backend feature ships with its corresponding GUI surface. 4. **GUI as Primary Interface** — The web dashboard is the operational control plane, not a secondary viewer. Every backend feature ships with its corresponding GUI surface.
5. **Decoupled Operations** — Agents operate autonomously; the control plane coordinates but doesn't block agent function 5. **Decoupled Operations** — Agents operate autonomously; the control plane coordinates but doesn't block agent function
+14 -13
View File
@@ -27,7 +27,7 @@ Result:
Deploy certctl control plane once (Docker Compose, Kubernetes Helm chart, or self-hosted). Deploy agents on your VMs, bare metal, and network appliances. One dashboard shows: Deploy certctl control plane once (Docker Compose, Kubernetes Helm chart, or self-hosted). Deploy agents on your VMs, bare metal, and network appliances. One dashboard shows:
- **All cert-manager certs** via discovery scanning (agents find cert-manager-issued certs copied to target machines, or scan the cluster directly) - **All cert-manager certs** via discovery scanning (agents find cert-manager-issued certs copied to target machines, or scan the cluster directly)
- **All certctl-managed certs** issued by shared issuers (ACME, step-ca, Vault PKI (coming in v2.1), private CA) - **All certctl-managed certs** issued by shared issuers (ACME, step-ca, Vault PKI (planned), private CA)
- **Unified renewal and deployment** across both worlds - **Unified renewal and deployment** across both worlds
- **Single pane of glass** with expiration timeline, renewal status, deployment verification, audit trail - **Single pane of glass** with expiration timeline, renewal status, deployment verification, audit trail
@@ -39,8 +39,7 @@ Deploy certctl control plane once (Docker Compose, Kubernetes Helm chart, or sel
```bash ```bash
cd /opt/certctl cd /opt/certctl
docker compose up -d docker compose up -d
# Dashboard: http://localhost:3000 # Dashboard & API: http://localhost:8443
# API: http://localhost:8080
``` ```
**Option B: Kubernetes** (recommended for prod) **Option B: Kubernetes** (recommended for prod)
@@ -60,7 +59,7 @@ chmod +x /usr/local/bin/certctl-agent
# Config # Config
sudo tee /etc/certctl/agent.env > /dev/null <<EOF sudo tee /etc/certctl/agent.env > /dev/null <<EOF
CERTCTL_SERVER_URL=https://certctl-control-plane:8080 CERTCTL_SERVER_URL=http://certctl-control-plane:8443
CERTCTL_API_KEY=your-api-key CERTCTL_API_KEY=your-api-key
CERTCTL_DISCOVERY_DIRS=/etc/nginx/certs,/etc/ssl,/etc/letsencrypt/live CERTCTL_DISCOVERY_DIRS=/etc/nginx/certs,/etc/ssl,/etc/letsencrypt/live
CERTCTL_KEY_DIR=/var/lib/certctl/keys CERTCTL_KEY_DIR=/var/lib/certctl/keys
@@ -83,18 +82,20 @@ Agents scan configured directories and report back all existing certs. In the da
Set up the same issuer certctl uses for non-Kubernetes certs: Set up the same issuer certctl uses for non-Kubernetes certs:
- **ACME** (Let's Encrypt, for public certs) - **ACME** (Let's Encrypt, for public certs)
- **step-ca** (Smallstep, for internal certs) - **step-ca** (Smallstep, for internal certs)
- **Vault PKI** (coming in v2.1) (HashiCorp Vault, for enterprise PKI) - **Vault PKI** (planned) (HashiCorp Vault, for enterprise PKI)
- **Private CA** (your own internal root CA) - **Private CA** (your own internal root CA)
No new CA infrastructure needed. If cert-manager already uses your CA, certctl points to the same one. No new CA infrastructure needed. If cert-manager already uses your CA, certctl points to the same one.
### 5. Create Policies for Non-Kubernetes Certs ### 5. Create Policies for Non-Kubernetes Certs
Go to **Policies****New Policy**: Go to **Policies****+ New Policy** to create enforcement rules:
- Issuer: shared (ACME, step-ca, Vault (coming in v2.1), private CA) - **Name:** e.g., "VM Certificate Policy"
- Profile: serverAuth for NGINX/Apache/HAProxy, clientAuth for mTLS, emailProtection for S/MIME - **Type:** `expiration_window` or `key_algorithm` (enforce renewal thresholds or crypto requirements)
- Renewal Threshold: 30 days (default, adjust per SLA) - **Severity:** `high`
- Scope: agent groups (VMs, bare metal, appliances) - **Config:** set your enforcement parameters
Certificates are linked to issuers and profiles when created or claimed from discovery. Policies add guardrails — enforcing key algorithm requirements, expiration windows, and other compliance rules across your fleet.
### 6. View Unified Inventory ### 6. View Unified Inventory
@@ -114,7 +115,7 @@ Go to **Policies** → **New Policy**:
If cert-manager and certctl both use the same CA: If cert-manager and certctl both use the same CA:
- **ACME**: cert-manager uses ClusterIssuer + certctl uses ACME connector → same Let's Encrypt account, transparent coexistence - **ACME**: cert-manager uses ClusterIssuer + certctl uses ACME connector → same Let's Encrypt account, transparent coexistence
- **step-ca**: cert-manager uses external issuer CRD + certctl uses step-ca connector → same provisioner, shared certificate inventory - **step-ca**: cert-manager uses external issuer CRD + certctl uses step-ca connector → same provisioner, shared certificate inventory
- **Vault PKI** (coming in v2.1): cert-manager uses external issuer CRD + certctl uses Vault connector → same mount, same audit trail - **Vault PKI** (planned): cert-manager uses external issuer CRD + certctl uses Vault connector → same mount, same audit trail
No conflict. They just issue certs through the same CA. certctl's discovery scanning finds cert-manager-issued certs and shows them alongside certctl-managed ones. No conflict. They just issue certs through the same CA. certctl's discovery scanning finds cert-manager-issued certs and shows them alongside certctl-managed ones.
@@ -138,6 +139,6 @@ For now: cert-manager handles Kubernetes, certctl handles everything else. They
## Next Steps ## Next Steps
1. Review [Quick Start](./quickstart.md) for a 5-minute demo 1. Review [Quick Start](./quickstart.md) for a 5-minute demo
2. Explore [Agents and Targets](./architecture.md#agents-and-targets) for deployment architecture 2. Explore [Architecture](./architecture.md#agents) for deployment architecture
3. Read about [Discovery Scanning](./quickstart.md#discovery) to auto-find certs 3. Read about [Discovery Scanning](./quickstart.md#certificate-discovery) to auto-find certs
4. Check [Helm Chart](../deploy/helm/certctl/) for production Kubernetes deployment 4. Check [Helm Chart](../deploy/helm/certctl/) for production Kubernetes deployment
+30 -24
View File
@@ -99,18 +99,23 @@ Environment="CERTCTL_DISCOVERY_DIRS=/etc/acme.sh"
In the **Discovery** page: In the **Discovery** page:
1. Review the "Unmanaged" certificates found by the agent 1. Review the "Unmanaged" certificates found by the agent
2. Click **Claim** on each acme.sh certificate 2. Click **Claim** on each acme.sh certificate
3. Map to the certificate ID (certctl auto-generates suggestions) 3. Enter the managed certificate ID to link it (e.g., `mc-api-prod`)
Once claimed, the certificate appears in the main **Certificates** page with ownership, renewal history, and deployment status. Once claimed, the certificate appears in the main **Certificates** page with ownership, renewal history, and deployment status.
### 5. Create an ACME Issuer ### 5. Create an ACME Issuer
In **Issuers****Configure New Issuer:** In **Issuers****+ New Issuer:**
- **Type:** ACME v2 1. Select **ACME** from the issuer type grid
- **Directory URL:** `https://acme-v02.api.letsencrypt.org/directory` (production) or staging for testing 2. Fill in the type-specific fields: name, directory URL (`https://acme-v02.api.letsencrypt.org/directory`), and config
- **Email:** Same email as your acme.sh account (required for ACME ToS)
- **Challenge Type:** DNS-01 (to match acme.sh's DNS validation) Or configure via environment variables:
```bash
export CERTCTL_ACME_DIRECTORY_URL=https://acme-v02.api.letsencrypt.org/directory
export CERTCTL_ACME_EMAIL=your-email@example.com # same as your acme.sh account
export CERTCTL_ACME_CHALLENGE_TYPE=dns-01
```
### 6. Adapt Your DNS Provider Scripts ### 6. Adapt Your DNS Provider Scripts
@@ -182,26 +187,28 @@ curl -X DELETE "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_record
-H "X-Auth-Key: ${CF_KEY}" -H "X-Auth-Key: ${CF_KEY}"
``` ```
Configure in the ACME issuer: Configure the ACME issuer via environment variables:
```json ```bash
{ export CERTCTL_ACME_DIRECTORY_URL=https://acme-v02.api.letsencrypt.org/directory
"challenge_type": "dns-01", export CERTCTL_ACME_EMAIL=your-email@example.com
"dns_present_script": "/etc/certctl/dns/cloudflare-present.sh", export CERTCTL_ACME_CHALLENGE_TYPE=dns-01
"dns_cleanup_script": "/etc/certctl/dns/cloudflare-cleanup.sh", export CERTCTL_ACME_DNS_PRESENT_SCRIPT=/etc/certctl/dns/cloudflare-present.sh
"dns_propagation_wait": 30 export CERTCTL_ACME_DNS_CLEANUP_SCRIPT=/etc/certctl/dns/cloudflare-cleanup.sh
}
``` ```
Or create the issuer through the dashboard: **Issuers****+ New Issuer** → select **ACME** → fill in the config fields.
### 7. Create Renewal Policies ### 7. Create Renewal Policies
In **Policies:** In **Policies****+ New Policy:**
- **Certificate Profile:** Select the issuer and challenge type from step 5 - **Name:** e.g., "ACME DNS-01 Policy"
- **Renewal Threshold:** 30 days before expiry (or match your acme.sh cron settings) - **Type:** `expiration_window` (enforces renewal thresholds)
- **Agent Group:** Select which agents should renew certificates - **Severity:** `high`
- **Config:** set your renewal window (default: 30 days before expiry)
Set one policy per domain or domain pattern. Renewal scheduling is driven by the certificate's assigned profile and issuer. Policies add enforcement guardrails on top.
### 8. Phase Out acme.sh Cron ### 8. Phase Out acme.sh Cron
@@ -252,11 +259,10 @@ Benefits:
To enable: To enable:
```json ```bash
{ export CERTCTL_ACME_CHALLENGE_TYPE=dns-persist-01
"challenge_type": "dns-persist-01", export CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN=letsencrypt.org
"dns_persist_issuer_domain": "acme-v02.api.letsencrypt.org" export CERTCTL_ACME_DNS_PRESENT_SCRIPT=/etc/certctl/dns/cloudflare-present.sh
}
``` ```
certctl automatically falls back to DNS-01 if the CA doesn't support dns-persist-01 yet. certctl automatically falls back to DNS-01 if the CA doesn't support dns-persist-01 yet.
+29 -19
View File
@@ -22,7 +22,7 @@ Option A: Docker Compose (quickest for evaluation)
```bash ```bash
cd /opt/certctl cd /opt/certctl
docker compose up -d docker compose up -d
# Dashboard & API: https://localhost:8443 # Dashboard & API: http://localhost:8443
# Default API key in logs (grep CERTCTL_API_KEY docker logs certctl-server) # Default API key in logs (grep CERTCTL_API_KEY docker logs certctl-server)
``` ```
@@ -45,7 +45,7 @@ chmod +x /usr/local/bin/certctl-agent
# Create config # Create config
sudo mkdir -p /etc/certctl /var/lib/certctl/keys sudo mkdir -p /etc/certctl /var/lib/certctl/keys
sudo tee /etc/certctl/agent.env > /dev/null <<EOF sudo tee /etc/certctl/agent.env > /dev/null <<EOF
CERTCTL_SERVER_URL=https://certctl-control-plane.example.com:8080 CERTCTL_SERVER_URL=http://certctl-control-plane.example.com:8443
CERTCTL_API_KEY=your-api-key-here CERTCTL_API_KEY=your-api-key-here
CERTCTL_DISCOVERY_DIRS=/etc/letsencrypt/live CERTCTL_DISCOVERY_DIRS=/etc/letsencrypt/live
CERTCTL_KEY_DIR=/var/lib/certctl/keys CERTCTL_KEY_DIR=/var/lib/certctl/keys
@@ -71,24 +71,34 @@ The control plane now knows about all 50 certs and where they live.
### 4. Configure ACME Issuer ### 4. Configure ACME Issuer
Go to **Issuers****Add Issuer**: Go to **Issuers****+ New Issuer**:
- Type: ACME 1. Select **ACME** from the issuer type grid
- Directory URL: `https://acme-v02.api.letsencrypt.org/directory` (production) 2. Fill in the type-specific fields: name, directory URL (`https://acme-v02.api.letsencrypt.org/directory`), and any required config
- Email: your Let's Encrypt account email
- Challenge Type: `http-01` (if you have HTTP access) or `dns-01` (for wildcard/internal certs)
- For DNS-01, provide your DNS provider's script hook (Cloudflare, Route53, Azure DNS, etc.)
Test the connection. certctl uses the same Let's Encrypt account; no new credentials needed. Alternatively, configure via environment variables before starting the server:
```bash
export CERTCTL_ACME_DIRECTORY_URL=https://acme-v02.api.letsencrypt.org/directory
export CERTCTL_ACME_EMAIL=your-email@example.com
export CERTCTL_ACME_CHALLENGE_TYPE=http-01 # or dns-01 for wildcard certs
```
For DNS-01, also set:
```bash
export CERTCTL_ACME_DNS_PRESENT_SCRIPT=/etc/certctl/dns/present.sh
export CERTCTL_ACME_DNS_CLEANUP_SCRIPT=/etc/certctl/dns/cleanup.sh
```
certctl uses the same Let's Encrypt account; no new credentials needed.
### 5. Create Renewal Policies ### 5. Create Renewal Policies
Go to **Policies****New Policy**: Go to **Policies****+ New Policy** to create enforcement rules:
- Profile: ACME (or create a new one with `serverAuth` EKU) - Name: e.g., "ACME Renewal Policy"
- Issuer: the ACME issuer you just created - Type: `expiration_window` (to enforce renewal thresholds)
- Renewal Threshold: 30 days before expiry (default, adjust as needed) - Severity: `high`
- Scope: select agent groups or individual agents managing your servers - Config: set your renewal threshold (default: 30 days before expiry)
Assign this policy to your discovered certs. Renewal scheduling is driven by the certificate's assigned profile and issuer. Policies add enforcement guardrails (key algorithm requirements, expiration windows, etc.).
### 6. Disable Certbot Cron, One Server at a Time ### 6. Disable Certbot Cron, One Server at a Time
@@ -133,11 +143,11 @@ docker compose up -d
# Other options: CERTCTL_TEAMS_WEBHOOK_URL, CERTCTL_PAGERDUTY_ROUTING_KEY, CERTCTL_OPSGENIE_API_KEY # Other options: CERTCTL_TEAMS_WEBHOOK_URL, CERTCTL_PAGERDUTY_ROUTING_KEY, CERTCTL_OPSGENIE_API_KEY
``` ```
Now you get 30/14/7-day warnings before any cert expires, across all 50 servers, in one place. Now you get 30/14/7-day warnings before any cert expires, across all 10 servers, in one place.
## What Changes ## What Changes
- **Renewal**: Agent polls certctl for work instead of Certbot cron triggering locally. Faster failure detection (agent heartbeat every 5 minutes vs. cron running once a day). - **Renewal**: Agent polls certctl for work instead of Certbot cron triggering locally. Faster failure detection (agent heartbeat every 60 seconds vs. cron running once a day).
- **Deployment**: certctl verifies post-deployment by probing the live TLS endpoint and comparing SHA-256 fingerprints. Catches reload failures silently. - **Deployment**: certctl verifies post-deployment by probing the live TLS endpoint and comparing SHA-256 fingerprints. Catches reload failures silently.
- **Audit Trail**: Every renewal, deployment, and alert is logged immutably. Answer "who renewed cert X when and why" within seconds. - **Audit Trail**: Every renewal, deployment, and alert is logged immutably. Answer "who renewed cert X when and why" within seconds.
- **Alerting**: Threshold-based alerts to Slack/email/webhook 30/14/7 days before expiry, not when cert expires. - **Alerting**: Threshold-based alerts to Slack/email/webhook 30/14/7 days before expiry, not when cert expires.
@@ -157,5 +167,5 @@ certctl will stop renewing that cert when the policy is disabled. Certbot resume
## Next Steps ## Next Steps
- Review the [Concepts Guide](./concepts.md) for terminology (profiles, policies, agents, jobs) - Review the [Concepts Guide](./concepts.md) for terminology (profiles, policies, agents, jobs)
- Explore [Network Discovery](./quickstart.md#network-discovery) to find certificates you didn't know about - Explore [Network Discovery](./quickstart.md#network-discovery-agentless) to find certificates you didn't know about
- Set up [Kubernetes cert-manager integration](./cert-manager.md) if you manage in-cluster certs too - Set up [Kubernetes cert-manager integration](./certctl-for-cert-manager-users.md) if you manage in-cluster certs too
+145 -4
View File
@@ -39,6 +39,9 @@ Comprehensive manual testing playbook. Every test has a concrete command, an exp
- [Part 32: Request Body Size Limits](#part-32-request-body-size-limits) - [Part 32: Request Body Size Limits](#part-32-request-body-size-limits)
- [Part 33: Apache & HAProxy Target Connectors](#part-33-apache--haproxy-target-connectors) - [Part 33: Apache & HAProxy Target Connectors](#part-33-apache--haproxy-target-connectors)
- [Part 34: Sub-CA Mode](#part-34-sub-ca-mode) - [Part 34: Sub-CA Mode](#part-34-sub-ca-mode)
- [Part 35: ARI (RFC 9702) Scheduler Integration](#part-35-ari-rfc-9702-scheduler-integration)
- [Part 36: Agent Work Routing (M31)](#part-36-agent-work-routing-m31)
- [Part 37: GUI Completeness (Pre-2.1.0-E)](#part-37-gui-completeness-pre-210-e)
- [Release Sign-Off](#release-sign-off) - [Release Sign-Off](#release-sign-off)
--- ---
@@ -5069,6 +5072,100 @@ openssl crl -in /tmp/subca-crl.der -inform DER -noout -issuer
--- ---
## Part 35: ARI (RFC 9702) Scheduler Integration
Tests that the renewal scheduler consults ARI before creating renewal jobs for ACME-issued certificates.
### 35.1 ARI Defers Renewal When CA Says "Not Yet"
**Prerequisite:** ACME issuer configured with `CERTCTL_ACME_ARI_ENABLED=true`, connected to a CA that supports ARI (e.g., Let's Encrypt staging). Certificate within the 30-day expiry window but the CA's `suggestedWindow.start` is in the future.
```bash
# Check scheduler logs for ARI deferral
docker logs certctl-server 2>&1 | grep "ARI: renewal not yet suggested"
```
**Expected:** Log line showing `ARI: renewal not yet suggested by CA` with `cert_id`, `suggested_start`, `suggested_end`. No renewal job created for that cert.
**PASS if** the scheduler skips renewal job creation when ARI says the window hasn't opened.
### 35.2 ARI Triggers Renewal When CA Says "Now"
**Prerequisite:** Same setup as 35.1, but the certificate's ARI `suggestedWindow.start` is in the past (CA is actively suggesting renewal).
```bash
# Check scheduler logs for ARI-triggered renewal
docker logs certctl-server 2>&1 | grep "ARI: CA suggests renewal now"
# Verify renewal job was created
curl -s -H "Authorization: Bearer $API_KEY" \
"http://localhost:8443/api/v1/jobs?type=renewal" | jq '.data[] | select(.certificate_id == "<cert-id>")'
```
**Expected:** Log line showing `ARI: CA suggests renewal now`. Renewal job created with `renewal_trigger: ari` in the audit trail.
**PASS if** a renewal job is created when ARI indicates the renewal window is open.
### 35.3 ARI Fallback on Error
**Prerequisite:** ACME issuer with `CERTCTL_ACME_ARI_ENABLED=true`, but the ARI endpoint is unreachable or returns an error (e.g., network issue, 500 from CA).
```bash
# Check scheduler logs for ARI fallback
docker logs certctl-server 2>&1 | grep "ARI check failed, falling back"
```
**Expected:** Warning log `ARI check failed, falling back to threshold-based renewal`. Renewal proceeds normally using the configured expiration thresholds.
**PASS if** renewal still works when ARI is unavailable, using threshold-based logic as fallback.
---
## Part 36: Agent Work Routing (M31)
Tests that `GetPendingWork()` returns only jobs scoped to the requesting agent, and that deployment jobs have `agent_id` populated at creation time.
### 36.1 Multi-Agent Routing
**Prerequisite:** Two agents registered (`agent-web-01`, `agent-lb-01`), two targets (one per agent), one certificate mapped to both targets. Trigger renewal to create deployment jobs.
```bash
# Poll as agent-web-01 — should only see its deployment job
curl -s -H "Authorization: Bearer $API_KEY" \
"http://localhost:8443/api/v1/agents/agent-web-01/work" | jq '.[] | .target_id'
# Poll as agent-lb-01 — should only see its deployment job
curl -s -H "Authorization: Bearer $API_KEY" \
"http://localhost:8443/api/v1/agents/agent-lb-01/work" | jq '.[] | .target_id'
```
**Expected:** Each agent receives only the deployment job for its assigned target. Agent-web-01 does NOT see agent-lb-01's job and vice versa.
**PASS if** each agent's work response contains only jobs for targets it owns.
### 36.2 Agent With No Targets Gets Empty Work
**Prerequisite:** Register a new agent with no target assignments.
```bash
curl -s -H "Authorization: Bearer $API_KEY" \
"http://localhost:8443/api/v1/agents/agent-no-targets/work" | jq 'length'
```
**Expected:** Empty array (0 jobs).
**PASS if** the response is an empty list.
### 36.3 Deployment Jobs Have agent_id Populated
**Prerequisite:** Deployment jobs created via renewal or manual trigger.
```bash
# Check that deployment jobs in the system have agent_id set
curl -s -H "Authorization: Bearer $API_KEY" \
"http://localhost:8443/api/v1/jobs" | jq '[.data[] | select(.type == "Deployment") | .agent_id] | map(select(. != null)) | length'
```
**Expected:** All deployment jobs for targets with agent assignments have `agent_id` populated.
**PASS if** deployment jobs have non-null `agent_id` values.
---
## Release Sign-Off ## Release Sign-Off
All tests below must pass before tagging v2.1.0. Each row is one individual test from the guide above. The **Method** column indicates whether `qa-smoke-test.sh` covers the test automatically (**Auto**) or requires hands-on verification (**Manual**). All tests below must pass before tagging v2.1.0. Each row is one individual test from the guide above. The **Method** column indicates whether `qa-smoke-test.sh` covers the test automatically (**Auto**) or requires hands-on verification (**Manual**).
@@ -5082,7 +5179,7 @@ These must be green before starting manual QA:
| CI pipeline green (Go build + vet + race + lint + vuln + tests) | ☐ | | | | CI pipeline green (Go build + vet + race + lint + vuln + tests) | ☐ | | |
| CI pipeline green (Frontend tsc + vitest + vite build) | ☐ | | | | CI pipeline green (Frontend tsc + vitest + vite build) | ☐ | | |
| Coverage thresholds met (service 60%, handler 60%, domain 40%, middleware 50%) | ☐ | | | | Coverage thresholds met (service 60%, handler 60%, domain 40%, middleware 50%) | ☐ | | |
| `qa-smoke-test.sh` — 0 failures | ☑ | 2026-03-30 | 121 pass, 0 fail, 5 skip | | `qa-smoke-test.sh` — 0 failures | ☑ | 2026-03-30 | 124 pass, 0 fail, 5 skip |
### Part 1: Infrastructure & Deployment ### Part 1: Infrastructure & Deployment
@@ -5574,14 +5671,58 @@ These must be green before starting manual QA:
| 34.5 | Sub-CA Key Format Support | Manual | ☐ | | | | 34.5 | Sub-CA Key Format Support | Manual | ☐ | | |
| 34.6 | CRL Signing in Sub-CA Mode | Manual | ☐ | | | | 34.6 | CRL Signing in Sub-CA Mode | Manual | ☐ | | |
### Part 35: ARI (RFC 9702) Scheduler Integration
| Test | Description | Method | Pass? | Date | Notes |
|------|-------------|--------|-------|------|-------|
| 35.a1 | ARI nil fallback — renewal jobs still created | Auto | ☑ | 2026-03-30 | |
| 35.a2 | No ARI errors with Local CA issuer | Auto | ☑ | 2026-03-30 | |
| 35.a3 | Server healthy after ARI wiring (metrics) | Auto | ☑ | 2026-03-30 | |
| 35.1 | ARI defers renewal when CA says "not yet" (requires ACME+ARI) | Manual | ☐ | | |
| 35.2 | ARI triggers renewal when CA says "now" (requires ACME+ARI) | Manual | ☐ | | |
| 35.3 | ARI fallback on error — threshold-based (requires ACME+ARI) | Manual | ☐ | | |
### Part 36: Agent Work Routing (M31)
| Test | Description | Method | Pass? | Date | Notes |
|------|-------------|--------|-------|------|-------|
| 36.a1 | Agent receives only its deployment jobs | Auto | ☐ | | |
| 36.a2 | Agent with no targets gets empty work list | Auto | ☐ | | |
| 36.a3 | Deployment jobs have agent_id populated | Auto | ☐ | | |
| 36.1 | Multi-agent routing with 2 agents, 2 targets | Manual | ☐ | | |
| 36.2 | Agent with no assigned targets gets empty work | Manual | ☐ | | |
| 36.3 | Database agent_id populated on deployment jobs | Manual | ☐ | | |
### Part 37: GUI Completeness (Pre-2.1.0-E)
| Test | Description | Method | Pass? | Date | Notes |
|------|-------------|--------|-------|------|-------|
| 37.1 | DigestPage renders preview iframe | Manual | ☐ | | |
| 37.2 | DigestPage send button with confirmation modal | Manual | ☐ | | |
| 37.3 | ObservabilityPage shows metrics gauges | Manual | ☐ | | |
| 37.4 | ObservabilityPage Prometheus config block | Manual | ☐ | | |
| 37.5 | ObservabilityPage live Prometheus output | Manual | ☐ | | |
| 37.6 | JobDetailPage displays job info and timeline | Manual | ☐ | | |
| 37.7 | JobDetailPage verification section for deployment jobs | Manual | ☐ | | |
| 37.8 | IssuerDetailPage shows redacted config | Manual | ☐ | | |
| 37.9 | IssuerDetailPage test connection button | Manual | ☐ | | |
| 37.10 | IssuerDetailPage issued certificates list | Manual | ☐ | | |
| 37.11 | TargetDetailPage shows config and agent link | Manual | ☐ | | |
| 37.12 | TargetDetailPage deployment history table | Manual | ☐ | | |
| 37.13 | JobsPage — job IDs clickable to /jobs/:id | Manual | ☐ | | |
| 37.14 | JobsPage — verification column for deployment jobs | Manual | ☐ | | |
| 37.15 | IssuersPage — issuer names clickable to /issuers/:id | Manual | ☐ | | |
| 37.16 | TargetsPage — target names clickable to /targets/:id | Manual | ☐ | | |
| 37.17 | Sidebar — Digest and Observability nav items | Manual | ☐ | | |
### Summary ### Summary
| Category | Count | | Category | Count |
|----------|-------| |----------|-------|
| ☑ Auto (passed in `qa-smoke-test.sh`) | 121 | | ☑ Auto (passed in `qa-smoke-test.sh`) | 127 |
| — Skipped (preconditions not met in demo) | 5 | | — Skipped (preconditions not met in demo) | 5 |
| ☐ Manual (requires hands-on verification) | 194 | | ☐ Manual (requires hands-on verification) | 217 |
| **Total** | **320** | | **Total** | **349** |
**Automated tests must also be green.** CI passing is necessary but not sufficient — this manual QA catches integration issues that isolated unit tests miss. **Automated tests must also be green.** CI passing is necessary but not sufficient — this manual QA catches integration issues that isolated unit tests miss.
+1
View File
@@ -11,6 +11,7 @@ type Job struct {
Type JobType `json:"type"` Type JobType `json:"type"`
CertificateID string `json:"certificate_id"` CertificateID string `json:"certificate_id"`
TargetID *string `json:"target_id,omitempty"` TargetID *string `json:"target_id,omitempty"`
AgentID *string `json:"agent_id,omitempty"`
Status JobStatus `json:"status"` Status JobStatus `json:"status"`
Attempts int `json:"attempts"` Attempts int `json:"attempts"`
MaxAttempts int `json:"max_attempts"` MaxAttempts int `json:"max_attempts"`
+14
View File
@@ -662,6 +662,20 @@ func (m *mockJobRepository) GetPendingJobs(ctx context.Context, jobType domain.J
return jobs, nil return jobs, nil
} }
func (m *mockJobRepository) ListPendingByAgentID(ctx context.Context, agentID string) ([]*domain.Job, error) {
var result []*domain.Job
for _, j := range m.jobs {
if j.AgentID != nil && *j.AgentID == agentID {
if j.Status == domain.JobStatusPending && j.Type == domain.JobTypeDeployment {
result = append(result, j)
} else if j.Status == domain.JobStatusAwaitingCSR {
result = append(result, j)
}
}
}
return result, nil
}
type mockAuditRepository struct { type mockAuditRepository struct {
events []*domain.AuditEvent events []*domain.AuditEvent
} }
+2
View File
@@ -111,6 +111,8 @@ type JobRepository interface {
UpdateStatus(ctx context.Context, id string, status domain.JobStatus, errMsg string) error UpdateStatus(ctx context.Context, id string, status domain.JobStatus, errMsg string) error
// GetPendingJobs returns jobs not yet processed of a specific type. // GetPendingJobs returns jobs not yet processed of a specific type.
GetPendingJobs(ctx context.Context, jobType domain.JobType) ([]*domain.Job, error) GetPendingJobs(ctx context.Context, jobType domain.JobType) ([]*domain.Job, error)
// ListPendingByAgentID returns pending deployment jobs and AwaitingCSR jobs for a specific agent.
ListPendingByAgentID(ctx context.Context, agentID string) ([]*domain.Job, error)
} }
// RenewalPolicyRepository defines operations for managing renewal policies. // RenewalPolicyRepository defines operations for managing renewal policies.
+77 -18
View File
@@ -22,7 +22,7 @@ func NewJobRepository(db *sql.DB) *JobRepository {
// List returns all jobs // List returns all jobs
func (r *JobRepository) List(ctx context.Context) ([]*domain.Job, error) { func (r *JobRepository) List(ctx context.Context) ([]*domain.Job, error) {
rows, err := r.db.QueryContext(ctx, ` rows, err := r.db.QueryContext(ctx, `
SELECT id, type, certificate_id, target_id, status, attempts, max_attempts, SELECT id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts,
last_error, scheduled_at, started_at, completed_at, created_at last_error, scheduled_at, started_at, completed_at, created_at
FROM jobs FROM jobs
ORDER BY created_at DESC ORDER BY created_at DESC
@@ -52,7 +52,7 @@ func (r *JobRepository) List(ctx context.Context) ([]*domain.Job, error) {
// Get retrieves a job by ID // Get retrieves a job by ID
func (r *JobRepository) Get(ctx context.Context, id string) (*domain.Job, error) { func (r *JobRepository) Get(ctx context.Context, id string) (*domain.Job, error) {
row := r.db.QueryRowContext(ctx, ` row := r.db.QueryRowContext(ctx, `
SELECT id, type, certificate_id, target_id, status, attempts, max_attempts, SELECT id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts,
last_error, scheduled_at, started_at, completed_at, created_at last_error, scheduled_at, started_at, completed_at, created_at
FROM jobs FROM jobs
WHERE id = $1 WHERE id = $1
@@ -77,11 +77,11 @@ func (r *JobRepository) Create(ctx context.Context, job *domain.Job) error {
err := r.db.QueryRowContext(ctx, ` err := r.db.QueryRowContext(ctx, `
INSERT INTO jobs ( INSERT INTO jobs (
id, type, certificate_id, target_id, status, attempts, max_attempts, id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts,
last_error, scheduled_at, started_at, completed_at, created_at last_error, scheduled_at, started_at, completed_at, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING id RETURNING id
`, job.ID, job.Type, job.CertificateID, job.TargetID, job.Status, job.Attempts, `, job.ID, job.Type, job.CertificateID, job.TargetID, job.AgentID, job.Status, job.Attempts,
job.MaxAttempts, job.LastError, job.ScheduledAt, job.StartedAt, job.CompletedAt, job.MaxAttempts, job.LastError, job.ScheduledAt, job.StartedAt, job.CompletedAt,
job.CreatedAt).Scan(&job.ID) job.CreatedAt).Scan(&job.ID)
@@ -99,15 +99,16 @@ func (r *JobRepository) Update(ctx context.Context, job *domain.Job) error {
type = $1, type = $1,
certificate_id = $2, certificate_id = $2,
target_id = $3, target_id = $3,
status = $4, agent_id = $4,
attempts = $5, status = $5,
max_attempts = $6, attempts = $6,
last_error = $7, max_attempts = $7,
scheduled_at = $8, last_error = $8,
started_at = $9, scheduled_at = $9,
completed_at = $10 started_at = $10,
WHERE id = $11 completed_at = $11
`, job.Type, job.CertificateID, job.TargetID, job.Status, job.Attempts, WHERE id = $12
`, job.Type, job.CertificateID, job.TargetID, job.AgentID, job.Status, job.Attempts,
job.MaxAttempts, job.LastError, job.ScheduledAt, job.StartedAt, job.MaxAttempts, job.LastError, job.ScheduledAt, job.StartedAt,
job.CompletedAt, job.ID) job.CompletedAt, job.ID)
@@ -150,7 +151,7 @@ func (r *JobRepository) Delete(ctx context.Context, id string) error {
// ListByStatus returns jobs with a specific status // ListByStatus returns jobs with a specific status
func (r *JobRepository) ListByStatus(ctx context.Context, status domain.JobStatus) ([]*domain.Job, error) { func (r *JobRepository) ListByStatus(ctx context.Context, status domain.JobStatus) ([]*domain.Job, error) {
rows, err := r.db.QueryContext(ctx, ` rows, err := r.db.QueryContext(ctx, `
SELECT id, type, certificate_id, target_id, status, attempts, max_attempts, SELECT id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts,
last_error, scheduled_at, started_at, completed_at, created_at last_error, scheduled_at, started_at, completed_at, created_at
FROM jobs FROM jobs
WHERE status = $1 WHERE status = $1
@@ -181,7 +182,7 @@ func (r *JobRepository) ListByStatus(ctx context.Context, status domain.JobStatu
// ListByCertificate returns all jobs for a certificate // ListByCertificate returns all jobs for a certificate
func (r *JobRepository) ListByCertificate(ctx context.Context, certID string) ([]*domain.Job, error) { func (r *JobRepository) ListByCertificate(ctx context.Context, certID string) ([]*domain.Job, error) {
rows, err := r.db.QueryContext(ctx, ` rows, err := r.db.QueryContext(ctx, `
SELECT id, type, certificate_id, target_id, status, attempts, max_attempts, SELECT id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts,
last_error, scheduled_at, started_at, completed_at, created_at last_error, scheduled_at, started_at, completed_at, created_at
FROM jobs FROM jobs
WHERE certificate_id = $1 WHERE certificate_id = $1
@@ -239,7 +240,7 @@ func (r *JobRepository) UpdateStatus(ctx context.Context, id string, status doma
// GetPendingJobs returns jobs not yet processed of a specific type // GetPendingJobs returns jobs not yet processed of a specific type
func (r *JobRepository) GetPendingJobs(ctx context.Context, jobType domain.JobType) ([]*domain.Job, error) { func (r *JobRepository) GetPendingJobs(ctx context.Context, jobType domain.JobType) ([]*domain.Job, error) {
rows, err := r.db.QueryContext(ctx, ` rows, err := r.db.QueryContext(ctx, `
SELECT id, type, certificate_id, target_id, status, attempts, max_attempts, SELECT id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts,
last_error, scheduled_at, started_at, completed_at, created_at last_error, scheduled_at, started_at, completed_at, created_at
FROM jobs FROM jobs
WHERE type = $1 AND status = $2 WHERE type = $1 AND status = $2
@@ -267,13 +268,71 @@ func (r *JobRepository) GetPendingJobs(ctx context.Context, jobType domain.JobTy
return jobs, nil return jobs, nil
} }
// ListPendingByAgentID returns pending deployment jobs and AwaitingCSR jobs for a specific agent.
// Deployment jobs are matched by agent_id directly (set at creation time), with a fallback
// for legacy jobs where agent_id is NULL but target_id resolves to the agent via deployment_targets.
// AwaitingCSR jobs are matched through certificate → target mappings → agent ownership.
func (r *JobRepository) ListPendingByAgentID(ctx context.Context, agentID string) ([]*domain.Job, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts,
last_error, scheduled_at, started_at, completed_at, created_at
FROM jobs
WHERE agent_id = $1 AND status = 'Pending' AND type = 'Deployment'
UNION ALL
SELECT j.id, j.type, j.certificate_id, j.target_id, j.agent_id, j.status, j.attempts, j.max_attempts,
j.last_error, j.scheduled_at, j.started_at, j.completed_at, j.created_at
FROM jobs j
INNER JOIN deployment_targets dt ON j.target_id = dt.id
WHERE j.agent_id IS NULL AND j.status = 'Pending' AND j.type = 'Deployment'
AND dt.agent_id = $1
UNION ALL
SELECT j.id, j.type, j.certificate_id, j.target_id, j.agent_id, j.status, j.attempts, j.max_attempts,
j.last_error, j.scheduled_at, j.started_at, j.completed_at, j.created_at
FROM jobs j
WHERE j.status = 'AwaitingCSR'
AND j.type IN ('Renewal', 'Issuance')
AND EXISTS (
SELECT 1 FROM certificate_target_mappings ctm
INNER JOIN deployment_targets dt ON ctm.target_id = dt.id
WHERE ctm.certificate_id = j.certificate_id
AND dt.agent_id = $1
)
ORDER BY created_at ASC
`, agentID)
if err != nil {
return nil, fmt.Errorf("failed to query pending jobs for agent: %w", err)
}
defer rows.Close()
var jobs []*domain.Job
for rows.Next() {
job, err := scanJob(rows)
if err != nil {
return nil, err
}
jobs = append(jobs, job)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating pending agent job rows: %w", err)
}
return jobs, nil
}
// scanJob scans a job from a row or rows // scanJob scans a job from a row or rows
func scanJob(scanner interface { func scanJob(scanner interface {
Scan(...interface{}) error Scan(...interface{}) error
}) (*domain.Job, error) { }) (*domain.Job, error) {
var job domain.Job var job domain.Job
err := scanner.Scan(&job.ID, &job.Type, &job.CertificateID, &job.TargetID, err := scanner.Scan(&job.ID, &job.Type, &job.CertificateID, &job.TargetID,
&job.Status, &job.Attempts, &job.MaxAttempts, &job.LastError, &job.AgentID, &job.Status, &job.Attempts, &job.MaxAttempts, &job.LastError,
&job.ScheduledAt, &job.StartedAt, &job.CompletedAt, &job.CreatedAt) &job.ScheduledAt, &job.StartedAt, &job.CompletedAt, &job.CreatedAt)
if err != nil { if err != nil {
+5 -26
View File
@@ -251,38 +251,17 @@ func (s *AgentService) GetCertificateForAgent(ctx context.Context, agentID strin
// GetPendingWork returns actionable jobs for an agent: deployment jobs (Pending) and // GetPendingWork returns actionable jobs for an agent: deployment jobs (Pending) and
// renewal/issuance jobs awaiting CSR submission (AwaitingCSR). // renewal/issuance jobs awaiting CSR submission (AwaitingCSR).
// Jobs are scoped to the requesting agent via agent_id (set at job creation) or
// through target→agent relationships for legacy jobs and AwaitingCSR routing.
func (s *AgentService) GetPendingWork(ctx context.Context, agentID string) ([]*domain.Job, error) { func (s *AgentService) GetPendingWork(ctx context.Context, agentID string) ([]*domain.Job, error) {
// Fetch agent to verify it exists // Verify agent exists
_, err := s.agentRepo.Get(ctx, agentID) _, err := s.agentRepo.Get(ctx, agentID)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to fetch agent: %w", err) return nil, fmt.Errorf("failed to fetch agent: %w", err)
} }
var workForAgent []*domain.Job // Return only jobs assigned to this agent (via agent_id or target→agent relationship)
return s.jobRepo.ListPendingByAgentID(ctx, agentID)
// Get pending deployment jobs
pendingJobs, err := s.jobRepo.ListByStatus(ctx, domain.JobStatusPending)
if err != nil {
return nil, fmt.Errorf("failed to list pending jobs: %w", err)
}
for _, job := range pendingJobs {
if job.Type == domain.JobTypeDeployment {
workForAgent = append(workForAgent, job)
}
}
// Get AwaitingCSR jobs (agent keygen mode — agent needs to generate key + submit CSR)
awaitingJobs, err := s.jobRepo.ListByStatus(ctx, domain.JobStatusAwaitingCSR)
if err != nil {
return nil, fmt.Errorf("failed to list awaiting CSR jobs: %w", err)
}
for _, job := range awaitingJobs {
if job.Type == domain.JobTypeRenewal || job.Type == domain.JobTypeIssuance {
workForAgent = append(workForAgent, job)
}
}
return workForAgent, nil
} }
// ReportJobStatus updates a job's status based on agent feedback. // ReportJobStatus updates a job's status based on agent feedback.
+127 -4
View File
@@ -131,8 +131,9 @@ func TestHeartbeat_NotFound(t *testing.T) {
func TestGetPendingWork(t *testing.T) { func TestGetPendingWork(t *testing.T) {
ctx := context.Background() ctx := context.Background()
now := time.Now() now := time.Now()
agentID := "agent-001"
agent := &domain.Agent{ agent := &domain.Agent{
ID: "agent-001", ID: agentID,
Name: "prod-agent", Name: "prod-agent",
Hostname: "server-01", Hostname: "server-01",
Status: domain.AgentStatusOnline, Status: domain.AgentStatusOnline,
@@ -146,6 +147,7 @@ func TestGetPendingWork(t *testing.T) {
Type: domain.JobTypeDeployment, Type: domain.JobTypeDeployment,
CertificateID: "cert-001", CertificateID: "cert-001",
Status: domain.JobStatusPending, Status: domain.JobStatusPending,
AgentID: &agentID,
CreatedAt: now, CreatedAt: now,
} }
job2 := &domain.Job{ job2 := &domain.Job{
@@ -157,7 +159,7 @@ func TestGetPendingWork(t *testing.T) {
} }
agentRepo := &mockAgentRepo{ agentRepo := &mockAgentRepo{
Agents: map[string]*domain.Agent{"agent-001": agent}, Agents: map[string]*domain.Agent{agentID: agent},
HeartbeatUpdates: make(map[string]time.Time), HeartbeatUpdates: make(map[string]time.Time),
} }
certRepo := &mockCertRepo{ certRepo := &mockCertRepo{
@@ -177,7 +179,7 @@ func TestGetPendingWork(t *testing.T) {
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil) agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
jobs, err := agentService.GetPendingWork(ctx, "agent-001") jobs, err := agentService.GetPendingWork(ctx, agentID)
if err != nil { if err != nil {
t.Fatalf("GetPendingWork failed: %v", err) t.Fatalf("GetPendingWork failed: %v", err)
} }
@@ -185,11 +187,132 @@ func TestGetPendingWork(t *testing.T) {
if len(jobs) != 1 { if len(jobs) != 1 {
t.Errorf("expected 1 deployment job, got %d", len(jobs)) t.Errorf("expected 1 deployment job, got %d", len(jobs))
} }
if jobs[0].Type != domain.JobTypeDeployment { if len(jobs) > 0 && jobs[0].Type != domain.JobTypeDeployment {
t.Errorf("expected JobTypeDeployment, got %s", jobs[0].Type) t.Errorf("expected JobTypeDeployment, got %s", jobs[0].Type)
} }
} }
func TestGetPendingWork_OnlyReturnsAgentJobs(t *testing.T) {
ctx := context.Background()
now := time.Now()
agentA := "agent-A"
agentB := "agent-B"
agentRepo := &mockAgentRepo{
Agents: map[string]*domain.Agent{
agentA: {ID: agentA, Name: "agent-A", Hostname: "host-a", Status: domain.AgentStatusOnline, RegisteredAt: now, APIKeyHash: "hashA"},
agentB: {ID: agentB, Name: "agent-B", Hostname: "host-b", Status: domain.AgentStatusOnline, RegisteredAt: now, APIKeyHash: "hashB"},
},
HeartbeatUpdates: make(map[string]time.Time),
}
jobA := &domain.Job{ID: "job-A", Type: domain.JobTypeDeployment, CertificateID: "cert-001", Status: domain.JobStatusPending, AgentID: &agentA, CreatedAt: now}
jobB := &domain.Job{ID: "job-B", Type: domain.JobTypeDeployment, CertificateID: "cert-002", Status: domain.JobStatusPending, AgentID: &agentB, CreatedAt: now}
jobRepo := &mockJobRepo{
Jobs: map[string]*domain.Job{"job-A": jobA, "job-B": jobB},
StatusUpdates: make(map[string]domain.JobStatus),
}
certRepo := &mockCertRepo{Certs: make(map[string]*domain.ManagedCertificate), Versions: make(map[string][]*domain.CertificateVersion)}
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
auditService := NewAuditService(&mockAuditRepo{})
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, make(map[string]IssuerConnector), nil)
// Agent A should only see its job
jobsA, err := agentService.GetPendingWork(ctx, agentA)
if err != nil {
t.Fatalf("GetPendingWork for agent-A failed: %v", err)
}
if len(jobsA) != 1 {
t.Fatalf("expected 1 job for agent-A, got %d", len(jobsA))
}
if jobsA[0].ID != "job-A" {
t.Errorf("expected job-A, got %s", jobsA[0].ID)
}
// Agent B should only see its job
jobsB, err := agentService.GetPendingWork(ctx, agentB)
if err != nil {
t.Fatalf("GetPendingWork for agent-B failed: %v", err)
}
if len(jobsB) != 1 {
t.Fatalf("expected 1 job for agent-B, got %d", len(jobsB))
}
if jobsB[0].ID != "job-B" {
t.Errorf("expected job-B, got %s", jobsB[0].ID)
}
}
func TestGetPendingWork_EmptyWhenNoJobsForAgent(t *testing.T) {
ctx := context.Background()
now := time.Now()
agentA := "agent-A"
agentB := "agent-B"
agentRepo := &mockAgentRepo{
Agents: map[string]*domain.Agent{
agentA: {ID: agentA, Name: "agent-A", Hostname: "host-a", Status: domain.AgentStatusOnline, RegisteredAt: now, APIKeyHash: "hashA"},
},
HeartbeatUpdates: make(map[string]time.Time),
}
// All jobs belong to agent-B
jobB := &domain.Job{ID: "job-B", Type: domain.JobTypeDeployment, CertificateID: "cert-001", Status: domain.JobStatusPending, AgentID: &agentB, CreatedAt: now}
jobRepo := &mockJobRepo{
Jobs: map[string]*domain.Job{"job-B": jobB},
StatusUpdates: make(map[string]domain.JobStatus),
}
certRepo := &mockCertRepo{Certs: make(map[string]*domain.ManagedCertificate), Versions: make(map[string][]*domain.CertificateVersion)}
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
auditService := NewAuditService(&mockAuditRepo{})
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, make(map[string]IssuerConnector), nil)
jobs, err := agentService.GetPendingWork(ctx, agentA)
if err != nil {
t.Fatalf("GetPendingWork failed: %v", err)
}
if len(jobs) != 0 {
t.Errorf("expected 0 jobs for agent-A (all jobs are for agent-B), got %d", len(jobs))
}
}
func TestGetPendingWork_DeploymentAndCSR_Scoped(t *testing.T) {
ctx := context.Background()
now := time.Now()
agentA := "agent-A"
agentRepo := &mockAgentRepo{
Agents: map[string]*domain.Agent{
agentA: {ID: agentA, Name: "agent-A", Hostname: "host-a", Status: domain.AgentStatusOnline, RegisteredAt: now, APIKeyHash: "hashA"},
},
HeartbeatUpdates: make(map[string]time.Time),
}
deployJob := &domain.Job{ID: "job-deploy", Type: domain.JobTypeDeployment, CertificateID: "cert-001", Status: domain.JobStatusPending, AgentID: &agentA, CreatedAt: now}
csrJob := &domain.Job{ID: "job-csr", Type: domain.JobTypeRenewal, CertificateID: "cert-002", Status: domain.JobStatusAwaitingCSR, AgentID: &agentA, CreatedAt: now}
jobRepo := &mockJobRepo{
Jobs: map[string]*domain.Job{"job-deploy": deployJob, "job-csr": csrJob},
StatusUpdates: make(map[string]domain.JobStatus),
}
certRepo := &mockCertRepo{Certs: make(map[string]*domain.ManagedCertificate), Versions: make(map[string][]*domain.CertificateVersion)}
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
auditService := NewAuditService(&mockAuditRepo{})
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, make(map[string]IssuerConnector), nil)
jobs, err := agentService.GetPendingWork(ctx, agentA)
if err != nil {
t.Fatalf("GetPendingWork failed: %v", err)
}
if len(jobs) != 2 {
t.Fatalf("expected 2 jobs (deployment + AwaitingCSR), got %d", len(jobs))
}
}
func TestReportJobStatus(t *testing.T) { func TestReportJobStatus(t *testing.T) {
ctx := context.Background() ctx := context.Background()
now := time.Now() now := time.Now()
+5
View File
@@ -67,6 +67,11 @@ func (s *DeploymentService) CreateDeploymentJobs(ctx context.Context, certID str
if target.ID != "" { if target.ID != "" {
job.TargetID = &target.ID job.TargetID = &target.ID
} }
// Route job to the target's assigned agent
if target.AgentID != "" {
agentID := target.AgentID
job.AgentID = &agentID
}
if err := s.jobRepo.Create(ctx, job); err != nil { if err := s.jobRepo.Create(ctx, job); err != nil {
slog.Error("failed to create deployment job for target", "target_id", target.ID, "error", err) slog.Error("failed to create deployment job for target", "target_id", target.ID, "error", err)
+39
View File
@@ -85,6 +85,45 @@ func TestDeploymentService_CreateDeploymentJobs_Success(t *testing.T) {
if job.TargetID == nil || len(*job.TargetID) == 0 { if job.TargetID == nil || len(*job.TargetID) == 0 {
t.Errorf("expected job to have TargetID set") t.Errorf("expected job to have TargetID set")
} }
// M31: Verify AgentID is set from target's agent assignment
if job.AgentID == nil {
t.Errorf("expected job to have AgentID set (M31 agent routing)")
}
}
}
// TestDeploymentService_CreateDeploymentJobs_SetsAgentID verifies AgentID is populated from target.
func TestDeploymentService_CreateDeploymentJobs_SetsAgentID(t *testing.T) {
ctx := context.Background()
svc, jobRepo, targetRepo, _, _, _, _ := newTestDeploymentService()
target := &domain.DeploymentTarget{
ID: "tgt-nginx-1",
Name: "NGINX Server 1",
Type: domain.TargetTypeNGINX,
AgentID: "agent-web-01",
Enabled: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
targetRepo.AddTarget(target)
jobIDs, err := svc.CreateDeploymentJobs(ctx, "mc-cert-1")
if err != nil {
t.Fatalf("CreateDeploymentJobs failed: %v", err)
}
if len(jobIDs) != 1 {
t.Fatalf("expected 1 job, got %d", len(jobIDs))
}
job := jobRepo.Jobs[jobIDs[0]]
if job.AgentID == nil {
t.Fatal("expected AgentID to be set on deployment job")
}
if *job.AgentID != "agent-web-01" {
t.Errorf("expected AgentID 'agent-web-01', got '%s'", *job.AgentID)
} }
} }
+55 -3
View File
@@ -26,12 +26,18 @@ type RenewalService struct {
jobRepo repository.JobRepository jobRepo repository.JobRepository
renewalPolicyRepo repository.RenewalPolicyRepository renewalPolicyRepo repository.RenewalPolicyRepository
profileRepo repository.CertificateProfileRepository profileRepo repository.CertificateProfileRepository
targetRepo repository.TargetRepository
auditService *AuditService auditService *AuditService
notificationSvc *NotificationService notificationSvc *NotificationService
issuerRegistry map[string]IssuerConnector issuerRegistry map[string]IssuerConnector
keygenMode string // "agent" (default) or "server" (demo only) keygenMode string // "agent" (default) or "server" (demo only)
} }
// SetTargetRepo sets the target repository for resolving agent_id on deployment jobs.
func (s *RenewalService) SetTargetRepo(repo repository.TargetRepository) {
s.targetRepo = repo
}
// IssuerConnector defines the service-layer interface for interacting with certificate issuers. // IssuerConnector defines the service-layer interface for interacting with certificate issuers.
// This is distinct from the connector-layer issuer.Connector interface to maintain dependency // This is distinct from the connector-layer issuer.Connector interface to maintain dependency
// inversion. Use IssuerConnectorAdapter to bridge between the two. // inversion. Use IssuerConnectorAdapter to bridge between the two.
@@ -163,10 +169,39 @@ func (s *RenewalService) CheckExpiringCertificates(ctx context.Context) error {
s.sendThresholdAlerts(ctx, cert, int(daysUntil), thresholds) s.sendThresholdAlerts(ctx, cert, int(daysUntil), thresholds)
// Only create renewal job if an issuer connector is registered for this cert's issuer // Only create renewal job if an issuer connector is registered for this cert's issuer
if _, hasIssuer := s.issuerRegistry[cert.IssuerID]; !hasIssuer { connector, hasIssuer := s.issuerRegistry[cert.IssuerID]
if !hasIssuer {
continue continue
} }
// ARI check (RFC 9702): if the issuer supports ARI, let the CA direct renewal timing.
// Fetch the latest cert version to get the PEM chain for the ARI query.
ariChecked := false
if version, vErr := s.certRepo.GetLatestVersion(ctx, cert.ID); vErr == nil && version != nil && version.PEMChain != "" {
if ariResult, ariErr := connector.GetRenewalInfo(ctx, version.PEMChain); ariErr != nil {
// ARI error is non-fatal — log and fall through to threshold-based renewal
slog.Warn("ARI check failed, falling back to threshold-based renewal",
"cert_id", cert.ID, "issuer_id", cert.IssuerID, "error", ariErr)
} else if ariResult != nil {
ariChecked = true
now := time.Now()
if now.Before(ariResult.SuggestedWindowStart) {
// CA says it's too early to renew — skip this cert
slog.Debug("ARI: renewal not yet suggested by CA",
"cert_id", cert.ID,
"suggested_start", ariResult.SuggestedWindowStart,
"suggested_end", ariResult.SuggestedWindowEnd)
continue
}
slog.Info("ARI: CA suggests renewal now",
"cert_id", cert.ID,
"suggested_start", ariResult.SuggestedWindowStart,
"suggested_end", ariResult.SuggestedWindowEnd)
}
// ariResult == nil means issuer doesn't support ARI — fall through to threshold logic
}
_ = ariChecked // used for audit metadata below
// Check for existing pending/running renewal jobs to avoid duplicates // Check for existing pending/running renewal jobs to avoid duplicates
existingJobs, err := s.jobRepo.ListByCertificate(ctx, cert.ID) existingJobs, err := s.jobRepo.ListByCertificate(ctx, cert.ID)
if err == nil { if err == nil {
@@ -206,9 +241,12 @@ func (s *RenewalService) CheckExpiringCertificates(ctx context.Context) error {
} }
// Record audit event // Record audit event
auditMeta := map[string]interface{}{"days_until_expiry": daysUntil, "job_id": job.ID}
if ariChecked {
auditMeta["renewal_trigger"] = "ari"
}
if auditErr := s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem, if auditErr := s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem,
"renewal_job_created", "certificate", cert.ID, "renewal_job_created", "certificate", cert.ID, auditMeta); auditErr != nil {
map[string]interface{}{"days_until_expiry": daysUntil, "job_id": job.ID}); auditErr != nil {
slog.Error("failed to record audit event", "error", auditErr) slog.Error("failed to record audit event", "error", auditErr)
} }
} }
@@ -604,12 +642,26 @@ func (s *RenewalService) createDeploymentJobs(ctx context.Context, cert *domain.
} }
for _, targetID := range cert.TargetIDs { for _, targetID := range cert.TargetIDs {
tid := targetID tid := targetID
// Resolve agent_id from target for job routing
var agentIDPtr *string
if s.targetRepo != nil {
target, err := s.targetRepo.Get(ctx, tid)
if err != nil {
slog.Warn("failed to resolve agent for deployment job", "target_id", tid, "error", err)
} else if target.AgentID != "" {
agentID := target.AgentID
agentIDPtr = &agentID
}
}
deployJob := &domain.Job{ deployJob := &domain.Job{
ID: generateID("job"), ID: generateID("job"),
CertificateID: cert.ID, CertificateID: cert.ID,
Type: domain.JobTypeDeployment, Type: domain.JobTypeDeployment,
Status: domain.JobStatusPending, Status: domain.JobStatusPending,
TargetID: &tid, TargetID: &tid,
AgentID: agentIDPtr,
MaxAttempts: 3, MaxAttempts: 3,
ScheduledAt: time.Now(), ScheduledAt: time.Now(),
CreatedAt: time.Now(), CreatedAt: time.Now(),
+279
View File
@@ -863,4 +863,283 @@ func TestProcessRenewalJob_NoCertificate(t *testing.T) {
} }
} }
// --- ARI (RFC 9702) Scheduler Integration Tests ---
func TestCheckExpiringCertificates_ARI_ShouldRenewNow(t *testing.T) {
t.Helper()
ctx := context.Background()
certRepo := newMockCertificateRepository()
jobRepo := newMockJobRepository()
policyRepo := newMockRenewalPolicyRepository()
auditRepo := newMockAuditRepository()
notifRepo := newMockNotificationRepository()
auditSvc := NewAuditService(auditRepo)
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
// ARI says renew now: window started in the past
ariConnector := &mockIssuerConnector{
getRenewalInfoResult: &RenewalInfoResult{
SuggestedWindowStart: time.Now().Add(-24 * time.Hour),
SuggestedWindowEnd: time.Now().Add(48 * time.Hour),
},
}
issuerRegistry := map[string]IssuerConnector{
"iss-acme": ariConnector,
}
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
// Create cert expiring in 20 days with a cert version (needed for ARI lookup)
cert := &domain.ManagedCertificate{
ID: "mc-ari-renew",
Name: "ARI Cert",
CommonName: "ari.example.com",
SANs: []string{},
OwnerID: "owner-1",
TeamID: "team-1",
IssuerID: "iss-acme",
RenewalPolicyID: "rp-standard",
Status: domain.CertificateStatusActive,
ExpiresAt: time.Now().AddDate(0, 0, 20),
Tags: make(map[string]string),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
certRepo.AddCert(cert)
certRepo.Versions[cert.ID] = []*domain.CertificateVersion{
{ID: "cv-1", CertificateID: cert.ID, PEMChain: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"},
}
policy := &domain.RenewalPolicy{
ID: "rp-standard", Name: "Standard", RenewalWindowDays: 30,
AutoRenew: true, MaxRetries: 3, RetryInterval: 300,
AlertThresholdsDays: []int{30, 14, 7, 0},
CreatedAt: time.Now(), UpdatedAt: time.Now(),
}
policyRepo.AddPolicy(policy)
err := svc.CheckExpiringCertificates(ctx)
if err != nil {
t.Fatalf("CheckExpiringCertificates failed: %v", err)
}
// ARI says renew now, so a renewal job should be created
hasRenewalJob := false
for _, job := range jobRepo.Jobs {
if job.Type == domain.JobTypeRenewal {
hasRenewalJob = true
break
}
}
if !hasRenewalJob {
t.Errorf("expected renewal job when ARI ShouldRenewNow is true")
}
}
func TestCheckExpiringCertificates_ARI_NotYet(t *testing.T) {
t.Helper()
ctx := context.Background()
certRepo := newMockCertificateRepository()
jobRepo := newMockJobRepository()
policyRepo := newMockRenewalPolicyRepository()
auditRepo := newMockAuditRepository()
notifRepo := newMockNotificationRepository()
auditSvc := NewAuditService(auditRepo)
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
// ARI says NOT yet: window starts in the future
ariConnector := &mockIssuerConnector{
getRenewalInfoResult: &RenewalInfoResult{
SuggestedWindowStart: time.Now().Add(72 * time.Hour),
SuggestedWindowEnd: time.Now().Add(96 * time.Hour),
},
}
issuerRegistry := map[string]IssuerConnector{
"iss-acme": ariConnector,
}
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
// Cert is within the 30-day threshold window (would normally trigger renewal),
// but ARI says "not yet"
cert := &domain.ManagedCertificate{
ID: "mc-ari-wait",
Name: "ARI Wait Cert",
CommonName: "ari-wait.example.com",
SANs: []string{},
OwnerID: "owner-1",
TeamID: "team-1",
IssuerID: "iss-acme",
RenewalPolicyID: "rp-standard",
Status: domain.CertificateStatusActive,
ExpiresAt: time.Now().AddDate(0, 0, 10),
Tags: make(map[string]string),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
certRepo.AddCert(cert)
certRepo.Versions[cert.ID] = []*domain.CertificateVersion{
{ID: "cv-2", CertificateID: cert.ID, PEMChain: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"},
}
policy := &domain.RenewalPolicy{
ID: "rp-standard", Name: "Standard", RenewalWindowDays: 30,
AutoRenew: true, MaxRetries: 3, RetryInterval: 300,
AlertThresholdsDays: []int{30, 14, 7, 0},
CreatedAt: time.Now(), UpdatedAt: time.Now(),
}
policyRepo.AddPolicy(policy)
err := svc.CheckExpiringCertificates(ctx)
if err != nil {
t.Fatalf("CheckExpiringCertificates failed: %v", err)
}
// ARI says not yet, so NO renewal job should be created
for _, job := range jobRepo.Jobs {
if job.Type == domain.JobTypeRenewal {
t.Errorf("expected no renewal job when ARI says not yet, but found one")
}
}
}
func TestCheckExpiringCertificates_ARI_NilResult_FallsThrough(t *testing.T) {
t.Helper()
ctx := context.Background()
certRepo := newMockCertificateRepository()
jobRepo := newMockJobRepository()
policyRepo := newMockRenewalPolicyRepository()
auditRepo := newMockAuditRepository()
notifRepo := newMockNotificationRepository()
auditSvc := NewAuditService(auditRepo)
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
// ARI returns nil (issuer doesn't support ARI) — default mock behavior
issuerRegistry := map[string]IssuerConnector{
"iss-local": &mockIssuerConnector{},
}
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
cert := &domain.ManagedCertificate{
ID: "mc-ari-nil",
Name: "No ARI Cert",
CommonName: "no-ari.example.com",
SANs: []string{},
OwnerID: "owner-1",
TeamID: "team-1",
IssuerID: "iss-local",
RenewalPolicyID: "rp-standard",
Status: domain.CertificateStatusActive,
ExpiresAt: time.Now().AddDate(0, 0, 20),
Tags: make(map[string]string),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
certRepo.AddCert(cert)
certRepo.Versions[cert.ID] = []*domain.CertificateVersion{
{ID: "cv-3", CertificateID: cert.ID, PEMChain: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"},
}
policy := &domain.RenewalPolicy{
ID: "rp-standard", Name: "Standard", RenewalWindowDays: 30,
AutoRenew: true, MaxRetries: 3, RetryInterval: 300,
AlertThresholdsDays: []int{30, 14, 7, 0},
CreatedAt: time.Now(), UpdatedAt: time.Now(),
}
policyRepo.AddPolicy(policy)
err := svc.CheckExpiringCertificates(ctx)
if err != nil {
t.Fatalf("CheckExpiringCertificates failed: %v", err)
}
// ARI is nil (not supported), so threshold-based logic applies; cert is within 30-day window
hasRenewalJob := false
for _, job := range jobRepo.Jobs {
if job.Type == domain.JobTypeRenewal {
hasRenewalJob = true
break
}
}
if !hasRenewalJob {
t.Errorf("expected renewal job via threshold fallback when ARI returns nil")
}
}
func TestCheckExpiringCertificates_ARI_Error_FallsThrough(t *testing.T) {
t.Helper()
ctx := context.Background()
certRepo := newMockCertificateRepository()
jobRepo := newMockJobRepository()
policyRepo := newMockRenewalPolicyRepository()
auditRepo := newMockAuditRepository()
notifRepo := newMockNotificationRepository()
auditSvc := NewAuditService(auditRepo)
notifSvc := NewNotificationService(notifRepo, map[string]Notifier{})
// ARI returns an error — should fall through to threshold-based renewal
ariConnector := &mockIssuerConnector{
getRenewalInfoErr: fmt.Errorf("ARI endpoint unreachable"),
}
issuerRegistry := map[string]IssuerConnector{
"iss-acme": ariConnector,
}
svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server")
cert := &domain.ManagedCertificate{
ID: "mc-ari-err",
Name: "ARI Error Cert",
CommonName: "ari-err.example.com",
SANs: []string{},
OwnerID: "owner-1",
TeamID: "team-1",
IssuerID: "iss-acme",
RenewalPolicyID: "rp-standard",
Status: domain.CertificateStatusActive,
ExpiresAt: time.Now().AddDate(0, 0, 15),
Tags: make(map[string]string),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
certRepo.AddCert(cert)
certRepo.Versions[cert.ID] = []*domain.CertificateVersion{
{ID: "cv-4", CertificateID: cert.ID, PEMChain: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"},
}
policy := &domain.RenewalPolicy{
ID: "rp-standard", Name: "Standard", RenewalWindowDays: 30,
AutoRenew: true, MaxRetries: 3, RetryInterval: 300,
AlertThresholdsDays: []int{30, 14, 7, 0},
CreatedAt: time.Now(), UpdatedAt: time.Now(),
}
policyRepo.AddPolicy(policy)
err := svc.CheckExpiringCertificates(ctx)
if err != nil {
t.Fatalf("CheckExpiringCertificates failed: %v", err)
}
// ARI failed but renewal should still happen via threshold fallback
hasRenewalJob := false
for _, job := range jobRepo.Jobs {
if job.Type == domain.JobTypeRenewal {
hasRenewalJob = true
break
}
}
if !hasRenewalJob {
t.Errorf("expected renewal job via threshold fallback when ARI errors")
}
}
// stringPtr is defined in notification_test.go // stringPtr is defined in notification_test.go
+30 -9
View File
@@ -243,6 +243,25 @@ func (m *mockJobRepo) GetPendingJobs(ctx context.Context, jobType domain.JobType
return jobs, nil return jobs, nil
} }
func (m *mockJobRepo) ListPendingByAgentID(ctx context.Context, agentID string) ([]*domain.Job, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.ListErr != nil {
return nil, m.ListErr
}
var result []*domain.Job
for _, j := range m.Jobs {
if j.AgentID != nil && *j.AgentID == agentID {
if j.Status == domain.JobStatusPending && j.Type == domain.JobTypeDeployment {
result = append(result, j)
} else if j.Status == domain.JobStatusAwaitingCSR {
result = append(result, j)
}
}
}
return result, nil
}
func (m *mockJobRepo) AddJob(job *domain.Job) { func (m *mockJobRepo) AddJob(job *domain.Job) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@@ -660,8 +679,10 @@ func (m *mockTargetRepo) AddTarget(target *domain.DeploymentTarget) {
// mockIssuerConnector is a test implementation of IssuerConnector // mockIssuerConnector is a test implementation of IssuerConnector
type mockIssuerConnector struct { type mockIssuerConnector struct {
Result *IssuanceResult Result *IssuanceResult
Err error Err error
getRenewalInfoResult *RenewalInfoResult
getRenewalInfoErr error
} }
func (m *mockIssuerConnector) IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string) (*IssuanceResult, error) { func (m *mockIssuerConnector) IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string) (*IssuanceResult, error) {
@@ -717,14 +738,14 @@ func (m *mockIssuerConnector) GetCACertPEM(ctx context.Context) (string, error)
} }
func (m *mockIssuerConnector) GetRenewalInfo(ctx context.Context, certPEM string) (*RenewalInfoResult, error) { func (m *mockIssuerConnector) GetRenewalInfo(ctx context.Context, certPEM string) (*RenewalInfoResult, error) {
if m.Err != nil { if m.getRenewalInfoErr != nil {
return nil, m.Err return nil, m.getRenewalInfoErr
} }
now := time.Now() if m.getRenewalInfoResult != nil {
return &RenewalInfoResult{ return m.getRenewalInfoResult, nil
SuggestedWindowStart: now, }
SuggestedWindowEnd: now.Add(7 * 24 * time.Hour), // Default: return nil, nil (issuer does not support ARI)
}, nil return nil, nil
} }
// Constructor functions for mocks // Constructor functions for mocks
+4
View File
@@ -65,6 +65,10 @@ func (m *mockVerificationJobRepo) GetPendingJobs(ctx context.Context, jobType do
return nil, nil return nil, nil
} }
func (m *mockVerificationJobRepo) ListPendingByAgentID(ctx context.Context, agentID string) ([]*domain.Job, error) {
return nil, nil
}
// newVerificationTestService creates a VerificationService wired with test doubles. // newVerificationTestService creates a VerificationService wired with test doubles.
func newVerificationTestService(jobs map[string]*domain.Job, jobRepoErr error) (*VerificationService, *mockVerificationJobRepo, *mockAuditRepo) { func newVerificationTestService(jobs map[string]*domain.Job, jobRepoErr error) (*VerificationService, *mockVerificationJobRepo, *mockAuditRepo) {
jobRepo := &mockVerificationJobRepo{jobs: jobs, err: jobRepoErr} jobRepo := &mockVerificationJobRepo{jobs: jobs, err: jobRepoErr}
+100
View File
@@ -78,6 +78,11 @@ import {
triggerNetworkScan, triggerNetworkScan,
previewDigest, previewDigest,
sendDigest, sendDigest,
getJob,
getJobVerification,
getIssuer,
getTarget,
getPrometheusMetrics,
} from './client'; } from './client';
// Mock global fetch // Mock global fetch
@@ -1006,4 +1011,99 @@ describe('API Client', () => {
expect(result.message).toBe('digest sent'); expect(result.message).toBe('digest sent');
}); });
}); });
// ─── Job Detail ────────────────────────────
describe('Job Detail', () => {
it('getJob fetches single job by ID', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'job-1', type: 'Deployment', status: 'Completed' }));
const result = await getJob('job-1');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/jobs/job-1');
expect(result.id).toBe('job-1');
expect(result.type).toBe('Deployment');
});
it('getJobVerification fetches verification result', async () => {
const verificationData = {
job_id: 'job-1',
target_id: 't-nginx1',
verified: true,
actual_fingerprint: 'abc123',
expected_fingerprint: 'abc123',
verified_at: '2026-03-28T12:00:00Z',
};
mockFetch.mockReturnValueOnce(mockJsonResponse(verificationData));
const result = await getJobVerification('job-1');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/jobs/job-1/verification');
expect(result.verified).toBe(true);
expect(result.actual_fingerprint).toBe('abc123');
});
});
// ─── Issuer Detail ─────────────────────────
describe('Issuer Detail', () => {
it('getIssuer fetches single issuer by ID', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'iss-local', name: 'Local CA', type: 'local_ca', status: 'active' }));
const result = await getIssuer('iss-local');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/issuers/iss-local');
expect(result.name).toBe('Local CA');
expect(result.type).toBe('local_ca');
});
});
// ─── Target Detail ─────────────────────────
describe('Target Detail', () => {
it('getTarget fetches single target by ID', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 't-nginx1', name: 'Web Server', type: 'nginx', hostname: 'web1.example.com' }));
const result = await getTarget('t-nginx1');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/targets/t-nginx1');
expect(result.name).toBe('Web Server');
expect(result.type).toBe('nginx');
});
});
// ─── Prometheus Metrics ────────────────────
describe('Prometheus Metrics', () => {
it('getPrometheusMetrics fetches text format', async () => {
const metricsText = '# HELP certctl_certificate_total Total certificates\ncertctl_certificate_total 10';
mockFetch.mockReturnValueOnce(
Promise.resolve({
ok: true,
status: 200,
text: () => Promise.resolve(metricsText),
} as Response)
);
const result = await getPrometheusMetrics();
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/metrics/prometheus');
expect(result).toContain('certctl_certificate_total');
});
it('getPrometheusMetrics throws on error', async () => {
mockFetch.mockReturnValueOnce(
Promise.resolve({
ok: false,
status: 500,
text: () => Promise.resolve('error'),
} as Response)
);
await expect(getPrometheusMetrics()).rejects.toThrow('Prometheus metrics failed: 500');
});
it('getPrometheusMetrics includes auth header', async () => {
setApiKey('prom-key');
mockFetch.mockReturnValueOnce(
Promise.resolve({
ok: true,
status: 200,
text: () => Promise.resolve('metrics'),
} as Response)
);
await getPrometheusMetrics();
const [, init] = mockFetch.mock.calls[0];
expect(init.headers['Authorization']).toBe('Bearer prom-key');
});
});
}); });
+27
View File
@@ -365,5 +365,32 @@ export const previewDigest = () => {
export const sendDigest = () => export const sendDigest = () =>
fetchJSON<{ message: string }>(`${BASE}/digest/send`, { method: 'POST' }); fetchJSON<{ message: string }>(`${BASE}/digest/send`, { method: 'POST' });
// Jobs (single)
export const getJob = (id: string) =>
fetchJSON<Job>(`${BASE}/jobs/${id}`);
// Job Verification
export const getJobVerification = (id: string) =>
fetchJSON<{ job_id: string; target_id: string; verified: boolean; actual_fingerprint: string; expected_fingerprint: string; verified_at: string; error?: string }>(`${BASE}/jobs/${id}/verification`);
// Issuers (single)
export const getIssuer = (id: string) =>
fetchJSON<Issuer>(`${BASE}/issuers/${id}`);
// Targets (single)
export const getTarget = (id: string) =>
fetchJSON<Target>(`${BASE}/targets/${id}`);
// Prometheus metrics (text format)
export const getPrometheusMetrics = () => {
const headers: Record<string, string> = {};
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
return fetch(`${BASE}/metrics/prometheus`, { headers })
.then(r => {
if (!r.ok) throw new Error(`Prometheus metrics failed: ${r.status}`);
return r.text();
});
};
// Health // Health
export const getHealth = () => fetchJSON<{ status: string }>('/health'); export const getHealth = () => fetchJSON<{ status: string }>('/health');
+2
View File
@@ -70,6 +70,8 @@ export interface Job {
id: string; id: string;
certificate_id: string; certificate_id: string;
type: string; type: string;
target_id?: string;
agent_id?: string;
status: string; status: string;
attempts: number; attempts: number;
max_attempts: number; max_attempts: number;
+2
View File
@@ -19,6 +19,8 @@ const nav = [
{ to: '/discovery', label: 'Discovery', icon: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z' }, { to: '/discovery', label: 'Discovery', icon: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z' },
{ to: '/network-scans', label: 'Network Scans', icon: 'M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z M9 12l2 2 4-4' }, { to: '/network-scans', label: 'Network Scans', icon: 'M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z M9 12l2 2 4-4' },
{ to: '/short-lived', label: 'Short-Lived', icon: 'M13 10V3L4 14h7v7l9-11h-7z' }, { to: '/short-lived', label: 'Short-Lived', icon: 'M13 10V3L4 14h7v7l9-11h-7z' },
{ to: '/digest', label: 'Digest', icon: 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z' },
{ to: '/observability', label: 'Observability', icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z' },
{ to: '/audit', label: 'Audit Trail', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' }, { to: '/audit', label: 'Audit Trail', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' },
]; ];
+10
View File
@@ -25,6 +25,11 @@ import ShortLivedPage from './pages/ShortLivedPage';
import AgentFleetPage from './pages/AgentFleetPage'; import AgentFleetPage from './pages/AgentFleetPage';
import DiscoveryPage from './pages/DiscoveryPage'; import DiscoveryPage from './pages/DiscoveryPage';
import NetworkScanPage from './pages/NetworkScanPage'; import NetworkScanPage from './pages/NetworkScanPage';
import DigestPage from './pages/DigestPage';
import ObservabilityPage from './pages/ObservabilityPage';
import JobDetailPage from './pages/JobDetailPage';
import IssuerDetailPage from './pages/IssuerDetailPage';
import TargetDetailPage from './pages/TargetDetailPage';
import './index.css'; import './index.css';
const queryClient = new QueryClient({ const queryClient = new QueryClient({
@@ -53,11 +58,14 @@ createRoot(document.getElementById('root')!).render(
<Route path="agents/:id" element={<AgentDetailPage />} /> <Route path="agents/:id" element={<AgentDetailPage />} />
<Route path="fleet" element={<AgentFleetPage />} /> <Route path="fleet" element={<AgentFleetPage />} />
<Route path="jobs" element={<JobsPage />} /> <Route path="jobs" element={<JobsPage />} />
<Route path="jobs/:id" element={<JobDetailPage />} />
<Route path="notifications" element={<NotificationsPage />} /> <Route path="notifications" element={<NotificationsPage />} />
<Route path="policies" element={<PoliciesPage />} /> <Route path="policies" element={<PoliciesPage />} />
<Route path="profiles" element={<ProfilesPage />} /> <Route path="profiles" element={<ProfilesPage />} />
<Route path="issuers" element={<IssuersPage />} /> <Route path="issuers" element={<IssuersPage />} />
<Route path="issuers/:id" element={<IssuerDetailPage />} />
<Route path="targets" element={<TargetsPage />} /> <Route path="targets" element={<TargetsPage />} />
<Route path="targets/:id" element={<TargetDetailPage />} />
<Route path="owners" element={<OwnersPage />} /> <Route path="owners" element={<OwnersPage />} />
<Route path="teams" element={<TeamsPage />} /> <Route path="teams" element={<TeamsPage />} />
<Route path="agent-groups" element={<AgentGroupsPage />} /> <Route path="agent-groups" element={<AgentGroupsPage />} />
@@ -65,6 +73,8 @@ createRoot(document.getElementById('root')!).render(
<Route path="short-lived" element={<ShortLivedPage />} /> <Route path="short-lived" element={<ShortLivedPage />} />
<Route path="discovery" element={<DiscoveryPage />} /> <Route path="discovery" element={<DiscoveryPage />} />
<Route path="network-scans" element={<NetworkScanPage />} /> <Route path="network-scans" element={<NetworkScanPage />} />
<Route path="digest" element={<DigestPage />} />
<Route path="observability" element={<ObservabilityPage />} />
</Route> </Route>
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
+8 -1
View File
@@ -13,6 +13,7 @@ import type { Certificate } from '../api/types';
function CreateCertificateModal({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) { function CreateCertificateModal({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) {
const [form, setForm] = useState({ const [form, setForm] = useState({
name: '',
id: '', id: '',
common_name: '', common_name: '',
environment: 'production', environment: 'production',
@@ -35,6 +36,12 @@ function CreateCertificateModal({ onClose, onSuccess }: { onClose: () => void; o
<h2 className="text-lg font-semibold text-ink mb-4">New Certificate</h2> <h2 className="text-lg font-semibold text-ink mb-4">New Certificate</h2>
{error && <div className="bg-red-50 border border-red-200 text-red-700 rounded px-3 py-2 text-sm mb-4">{error}</div>} {error && <div className="bg-red-50 border border-red-200 text-red-700 rounded px-3 py-2 text-sm mb-4">{error}</div>}
<div className="space-y-3"> <div className="space-y-3">
<div>
<label className="text-xs text-ink-muted block mb-1">Name *</label>
<input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400 focus:ring-1 focus:ring-brand-400/20"
placeholder="API Production Cert" />
</div>
<div> <div>
<label className="text-xs text-ink-muted block mb-1">ID (optional)</label> <label className="text-xs text-ink-muted block mb-1">ID (optional)</label>
<input value={form.id} onChange={e => setForm(f => ({ ...f, id: e.target.value }))} <input value={form.id} onChange={e => setForm(f => ({ ...f, id: e.target.value }))}
@@ -89,7 +96,7 @@ function CreateCertificateModal({ onClose, onSuccess }: { onClose: () => void; o
<button onClick={onClose} className="btn btn-ghost text-sm">Cancel</button> <button onClick={onClose} className="btn btn-ghost text-sm">Cancel</button>
<button <button
onClick={() => mutation.mutate()} onClick={() => mutation.mutate()}
disabled={!form.common_name || !form.issuer_id || mutation.isPending} disabled={!form.name || !form.common_name || !form.issuer_id || mutation.isPending}
className="btn btn-primary text-sm disabled:opacity-50" className="btn btn-primary text-sm disabled:opacity-50"
> >
{mutation.isPending ? 'Creating...' : 'Create Certificate'} {mutation.isPending ? 'Creating...' : 'Create Certificate'}
+110
View File
@@ -0,0 +1,110 @@
import { useState } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { previewDigest, sendDigest } from '../api/client';
import PageHeader from '../components/PageHeader';
import ErrorState from '../components/ErrorState';
export default function DigestPage() {
const [showConfirm, setShowConfirm] = useState(false);
const { data: html, isLoading, error, refetch } = useQuery({
queryKey: ['digest-preview'],
queryFn: previewDigest,
retry: false,
});
const sendMutation = useMutation({
mutationFn: sendDigest,
onSuccess: () => setShowConfirm(false),
});
return (
<>
<PageHeader
title="Certificate Digest"
subtitle="Preview and send the scheduled certificate digest email"
action={
<button
onClick={() => setShowConfirm(true)}
disabled={!html || sendMutation.isPending}
className="btn btn-primary text-xs disabled:opacity-50"
>
Send Digest Now
</button>
}
/>
<div className="flex-1 overflow-y-auto px-6 py-4">
{sendMutation.isSuccess && (
<div className="mb-4 px-4 py-2.5 bg-emerald-50 border border-emerald-200 rounded-lg text-sm text-emerald-700">
Digest sent successfully.
</div>
)}
{sendMutation.isError && (
<div className="mb-4 px-4 py-2.5 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
Failed to send digest: {(sendMutation.error as Error).message}
</div>
)}
{isLoading && (
<div className="flex items-center justify-center py-20">
<div className="text-sm text-ink-muted">Loading digest preview...</div>
</div>
)}
{error && (
<ErrorState
error={error as Error}
onRetry={() => refetch()}
/>
)}
{html && (
<div className="bg-white border border-surface-border rounded-lg shadow-sm overflow-hidden">
<div className="px-4 py-2.5 bg-surface border-b border-surface-border flex items-center justify-between">
<span className="text-xs text-ink-muted font-medium">Email Preview</span>
<button
onClick={() => refetch()}
className="text-xs text-brand-400 hover:text-brand-500"
>
Refresh
</button>
</div>
<iframe
srcDoc={html}
title="Digest Preview"
className="w-full border-0"
style={{ minHeight: '600px' }}
sandbox="allow-same-origin"
/>
</div>
)}
</div>
{showConfirm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setShowConfirm(false)}>
<div className="bg-white rounded-lg shadow-xl w-full max-w-sm mx-4" onClick={e => e.stopPropagation()}>
<div className="px-6 py-4 border-b border-surface-border">
<h3 className="text-lg font-semibold text-ink">Send Digest</h3>
<p className="text-sm text-ink-muted mt-1">
This will send the certificate digest email to all configured recipients.
</p>
</div>
<div className="px-6 py-3 border-t border-surface-border flex justify-end gap-2">
<button onClick={() => setShowConfirm(false)} className="px-4 py-2 text-sm text-ink-muted hover:text-ink rounded border border-surface-border">
Cancel
</button>
<button
onClick={() => sendMutation.mutate()}
disabled={sendMutation.isPending}
className="px-4 py-2 text-sm text-white bg-brand-500 hover:bg-brand-600 rounded disabled:opacity-50"
>
{sendMutation.isPending ? 'Sending...' : 'Send'}
</button>
</div>
</div>
</div>
)}
</>
);
}
+162
View File
@@ -0,0 +1,162 @@
import { useParams } from 'react-router-dom';
import { useQuery, useMutation } from '@tanstack/react-query';
import { getIssuer, testIssuerConnection, getCertificates } from '../api/client';
import PageHeader from '../components/PageHeader';
import StatusBadge from '../components/StatusBadge';
import DataTable from '../components/DataTable';
import type { Column } from '../components/DataTable';
import ErrorState from '../components/ErrorState';
import { formatDateTime } from '../api/utils';
import type { Certificate } from '../api/types';
const typeLabels: Record<string, string> = {
local_ca: 'Local CA',
acme: 'ACME (Let\'s Encrypt)',
step_ca: 'step-ca',
openssl: 'OpenSSL / Custom',
vault: 'Vault PKI',
};
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex justify-between py-2 border-b border-surface-border/50">
<span className="text-sm text-ink-muted">{label}</span>
<span className="text-sm text-ink">{value}</span>
</div>
);
}
export default function IssuerDetailPage() {
const { id } = useParams<{ id: string }>();
const { data: issuer, isLoading, error, refetch } = useQuery({
queryKey: ['issuer', id],
queryFn: () => getIssuer(id!),
enabled: !!id,
});
const { data: certsData } = useQuery({
queryKey: ['certificates', { issuer_id: id }],
queryFn: () => getCertificates({ issuer_id: id! }),
enabled: !!id,
});
const testMutation = useMutation({
mutationFn: () => testIssuerConnection(id!),
});
if (error) {
return (
<>
<PageHeader title="Issuer Details" />
<ErrorState error={error as Error} onRetry={() => refetch()} />
</>
);
}
if (isLoading || !issuer) {
return (
<>
<PageHeader title="Issuer Details" />
<div className="flex items-center justify-center py-20">
<div className="text-sm text-ink-muted">Loading issuer...</div>
</div>
</>
);
}
// Redact sensitive config fields
const safeConfig = issuer.config ? Object.fromEntries(
Object.entries(issuer.config).map(([k, v]) => {
const sensitive = ['password', 'secret', 'token', 'key', 'hmac', 'private'].some(s => k.toLowerCase().includes(s));
return [k, sensitive ? '********' : v];
})
) : {};
const certColumns: Column<Certificate>[] = [
{
key: 'name',
label: 'Certificate',
render: (c) => (
<div>
<div className="font-medium text-ink text-sm">{c.common_name}</div>
<div className="text-xs text-ink-faint font-mono">{c.id}</div>
</div>
),
},
{ key: 'status', label: 'Status', render: (c) => <StatusBadge status={c.status} /> },
{ key: 'expires', label: 'Expires', render: (c) => <span className="text-xs text-ink-muted">{formatDateTime(c.expires_at)}</span> },
];
return (
<>
<PageHeader
title={issuer.name}
subtitle={typeLabels[issuer.type] || issuer.type}
action={
<button
onClick={() => testMutation.mutate()}
disabled={testMutation.isPending}
className="btn btn-primary text-xs disabled:opacity-50"
>
{testMutation.isPending ? 'Testing...' : 'Test Connection'}
</button>
}
/>
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
{testMutation.isSuccess && (
<div className="px-4 py-2.5 bg-emerald-50 border border-emerald-200 rounded-lg text-sm text-emerald-700">
Connection test passed.
</div>
)}
{testMutation.isError && (
<div className="px-4 py-2.5 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
Connection test failed: {(testMutation.error as Error).message}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Issuer info */}
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
<h3 className="text-sm font-semibold text-ink-muted mb-4">Issuer Information</h3>
<InfoRow label="ID" value={<span className="font-mono text-xs">{issuer.id}</span>} />
<InfoRow label="Name" value={issuer.name} />
<InfoRow label="Type" value={typeLabels[issuer.type] || issuer.type} />
<InfoRow label="Status" value={<StatusBadge status={issuer.status} />} />
<InfoRow label="Created" value={formatDateTime(issuer.created_at)} />
</div>
{/* Config (redacted) */}
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
<h3 className="text-sm font-semibold text-ink-muted mb-4">Configuration</h3>
{Object.keys(safeConfig).length > 0 ? (
<div className="space-y-0">
{Object.entries(safeConfig).map(([key, val]) => (
<InfoRow key={key} label={key} value={
<span className="font-mono text-xs truncate max-w-xs inline-block">{String(val)}</span>
} />
))}
</div>
) : (
<div className="text-sm text-ink-faint py-4 text-center">No configuration data</div>
)}
</div>
</div>
{/* Issued certificates */}
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
<h3 className="text-sm font-semibold text-ink-muted mb-4">
Issued Certificates {certsData ? `(${certsData.total})` : ''}
</h3>
<DataTable
columns={certColumns}
data={certsData?.data || []}
isLoading={!certsData}
emptyMessage="No certificates issued by this issuer"
/>
</div>
</div>
</>
);
}
+4 -1
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getIssuers, testIssuerConnection, deleteIssuer, createIssuer } from '../api/client'; import { getIssuers, testIssuerConnection, deleteIssuer, createIssuer } from '../api/client';
import PageHeader from '../components/PageHeader'; import PageHeader from '../components/PageHeader';
@@ -120,7 +121,9 @@ export default function IssuersPage() {
label: 'Issuer', label: 'Issuer',
render: (i) => ( render: (i) => (
<div> <div>
<div className="font-medium text-ink">{i.name}</div> <Link to={`/issuers/${i.id}`} className="font-medium text-accent hover:text-accent-bright" onClick={(e) => e.stopPropagation()}>
{i.name}
</Link>
<div className="text-xs text-ink-faint font-mono">{i.id}</div> <div className="text-xs text-ink-faint font-mono">{i.id}</div>
</div> </div>
), ),
+183
View File
@@ -0,0 +1,183 @@
import { useParams, Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { getJob, getJobVerification, getAuditEvents } from '../api/client';
import PageHeader from '../components/PageHeader';
import StatusBadge from '../components/StatusBadge';
import ErrorState from '../components/ErrorState';
import { formatDateTime, timeAgo } from '../api/utils';
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex justify-between py-2 border-b border-surface-border/50">
<span className="text-sm text-ink-muted">{label}</span>
<span className="text-sm text-ink">{value}</span>
</div>
);
}
function VerificationBadge({ status }: { status?: string }) {
if (!status) return <span className="text-xs text-ink-faint"></span>;
const styles: Record<string, string> = {
success: 'bg-emerald-100 text-emerald-700',
failed: 'bg-red-100 text-red-700',
pending: 'bg-yellow-100 text-yellow-700',
skipped: 'bg-gray-100 text-gray-600',
};
const labels: Record<string, string> = {
success: 'Verified',
failed: 'Failed',
pending: 'Pending',
skipped: 'Skipped',
};
return (
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${styles[status] || 'bg-gray-100 text-gray-600'}`}>
{labels[status] || status}
</span>
);
}
export default function JobDetailPage() {
const { id } = useParams<{ id: string }>();
const { data: job, isLoading, error, refetch } = useQuery({
queryKey: ['job', id],
queryFn: () => getJob(id!),
enabled: !!id,
refetchInterval: 10000,
});
const { data: verification } = useQuery({
queryKey: ['job-verification', id],
queryFn: () => getJobVerification(id!),
enabled: !!id && job?.type === 'Deployment' && job?.status === 'Completed',
retry: false,
});
const { data: auditData } = useQuery({
queryKey: ['audit', { resource_id: id }],
queryFn: () => getAuditEvents({ resource_id: id!, per_page: '10' }),
enabled: !!id,
});
if (error) {
return (
<>
<PageHeader title="Job Details" />
<ErrorState error={error as Error} onRetry={() => refetch()} />
</>
);
}
if (isLoading || !job) {
return (
<>
<PageHeader title="Job Details" />
<div className="flex items-center justify-center py-20">
<div className="text-sm text-ink-muted">Loading job...</div>
</div>
</>
);
}
return (
<>
<PageHeader
title={`Job ${job.id}`}
subtitle={`${job.type} job`}
/>
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Job details */}
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
<h3 className="text-sm font-semibold text-ink-muted mb-4">Job Information</h3>
<InfoRow label="ID" value={<span className="font-mono text-xs">{job.id}</span>} />
<InfoRow label="Type" value={job.type} />
<InfoRow label="Status" value={<StatusBadge status={job.status} />} />
<InfoRow label="Certificate" value={
<Link to={`/certificates/${job.certificate_id}`} className="text-xs text-accent hover:text-accent-bright font-mono">
{job.certificate_id}
</Link>
} />
{job.agent_id && (
<InfoRow label="Agent" value={
<Link to={`/agents/${job.agent_id}`} className="text-xs text-accent hover:text-accent-bright font-mono">
{job.agent_id}
</Link>
} />
)}
{job.target_id && (
<InfoRow label="Target" value={
<Link to={`/targets/${job.target_id}`} className="text-xs text-accent hover:text-accent-bright font-mono">
{job.target_id}
</Link>
} />
)}
<InfoRow label="Attempts" value={`${job.attempts} / ${job.max_attempts}`} />
{job.error_message && (
<InfoRow label="Error" value={
<span className="text-red-600 text-xs">{job.error_message}</span>
} />
)}
</div>
{/* Timeline */}
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
<h3 className="text-sm font-semibold text-ink-muted mb-4">Timeline</h3>
<InfoRow label="Created" value={formatDateTime(job.created_at)} />
<InfoRow label="Scheduled" value={formatDateTime(job.scheduled_at)} />
{job.started_at && <InfoRow label="Started" value={formatDateTime(job.started_at)} />}
{job.completed_at && <InfoRow label="Completed" value={formatDateTime(job.completed_at)} />}
{job.completed_at && job.started_at && (
<InfoRow label="Duration" value={timeAgo(job.started_at)} />
)}
</div>
</div>
{/* Verification section — only for deployment jobs */}
{job.type === 'Deployment' && (
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
<h3 className="text-sm font-semibold text-ink-muted mb-4">Post-Deployment Verification</h3>
{job.verification_status ? (
<div className="space-y-0">
<InfoRow label="Status" value={<VerificationBadge status={job.verification_status} />} />
{job.verified_at && <InfoRow label="Verified At" value={formatDateTime(job.verified_at)} />}
{job.verification_fingerprint && (
<InfoRow label="Fingerprint" value={<span className="font-mono text-xs">{job.verification_fingerprint}</span>} />
)}
{job.verification_error && (
<InfoRow label="Error" value={<span className="text-red-600 text-xs">{job.verification_error}</span>} />
)}
{verification && verification.verified && (
<InfoRow label="Expected Fingerprint" value={<span className="font-mono text-xs">{verification.expected_fingerprint}</span>} />
)}
</div>
) : (
<div className="text-sm text-ink-faint py-4 text-center">
{job.status === 'Completed' ? 'No verification data recorded' : 'Verification runs after deployment completes'}
</div>
)}
</div>
)}
{/* Audit trail */}
{auditData && auditData.data.length > 0 && (
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
<h3 className="text-sm font-semibold text-ink-muted mb-4">Related Audit Events</h3>
<div className="space-y-2">
{auditData.data.map(event => (
<div key={event.id} className="flex items-center justify-between py-2 border-b border-surface-border/50 last:border-0">
<div>
<span className="text-sm text-ink">{event.action}</span>
<span className="text-xs text-ink-faint ml-2">by {event.actor}</span>
</div>
<span className="text-xs text-ink-muted">{formatDateTime(event.timestamp)}</span>
</div>
))}
</div>
</div>
)}
</div>
</>
);
}
+41 -1
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getJobs, cancelJob, approveRenewal, rejectRenewal } from '../api/client'; import { getJobs, cancelJob, approveRenewal, rejectRenewal } from '../api/client';
import PageHeader from '../components/PageHeader'; import PageHeader from '../components/PageHeader';
@@ -47,6 +48,27 @@ function RejectModal({ job, onClose, onReject }: { job: Job; onClose: () => void
); );
} }
function VerificationBadge({ status }: { status?: string }) {
if (!status) return <span className="text-xs text-ink-faint"></span>;
const styles: Record<string, string> = {
success: 'bg-emerald-100 text-emerald-700',
failed: 'bg-red-100 text-red-700',
pending: 'bg-yellow-100 text-yellow-700',
skipped: 'bg-gray-100 text-gray-600',
};
const labels: Record<string, string> = {
success: 'Verified',
failed: 'Failed',
pending: 'Pending',
skipped: 'Skipped',
};
return (
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${styles[status] || 'bg-gray-100 text-gray-600'}`}>
{labels[status] || status}
</span>
);
}
export default function JobsPage() { export default function JobsPage() {
const [statusFilter, setStatusFilter] = useState(''); const [statusFilter, setStatusFilter] = useState('');
const [typeFilter, setTypeFilter] = useState(''); const [typeFilter, setTypeFilter] = useState('');
@@ -89,13 +111,26 @@ export default function JobsPage() {
label: 'Job', label: 'Job',
render: (j) => ( render: (j) => (
<div> <div>
<div className="font-mono text-xs text-ink">{j.id}</div> <Link to={`/jobs/${j.id}`} className="font-mono text-xs text-accent hover:text-accent-bright" onClick={(e) => e.stopPropagation()}>
{j.id}
</Link>
<div className="text-xs text-ink-faint">{j.type}</div> <div className="text-xs text-ink-faint">{j.type}</div>
</div> </div>
), ),
}, },
{ key: 'status', label: 'Status', render: (j) => <StatusBadge status={j.status} /> }, { key: 'status', label: 'Status', render: (j) => <StatusBadge status={j.status} /> },
{ key: 'cert', label: 'Certificate', render: (j) => <span className="text-xs text-ink-muted font-mono">{j.certificate_id}</span> }, { key: 'cert', label: 'Certificate', render: (j) => <span className="text-xs text-ink-muted font-mono">{j.certificate_id}</span> },
{
key: 'agent',
label: 'Agent',
render: (j) => j.agent_id ? (
<Link to={`/agents/${j.agent_id}`} className="text-xs text-accent hover:text-accent-bright font-mono" onClick={(e) => e.stopPropagation()}>
{j.agent_id}
</Link>
) : (
<span className="text-xs text-ink-faint"></span>
),
},
{ {
key: 'attempts', key: 'attempts',
label: 'Attempts', label: 'Attempts',
@@ -103,6 +138,11 @@ export default function JobsPage() {
}, },
{ key: 'scheduled', label: 'Scheduled', render: (j) => <span className="text-xs text-ink-muted">{formatDateTime(j.scheduled_at)}</span> }, { key: 'scheduled', label: 'Scheduled', render: (j) => <span className="text-xs text-ink-muted">{formatDateTime(j.scheduled_at)}</span> },
{ key: 'completed', label: 'Completed', render: (j) => <span className="text-xs text-ink-muted">{formatDateTime(j.completed_at)}</span> }, { key: 'completed', label: 'Completed', render: (j) => <span className="text-xs text-ink-muted">{formatDateTime(j.completed_at)}</span> },
{
key: 'verification',
label: 'Verification',
render: (j) => j.type === 'Deployment' ? <VerificationBadge status={j.verification_status} /> : <span className="text-xs text-ink-faint"></span>,
},
{ {
key: 'actions', key: 'actions',
label: '', label: '',
+149
View File
@@ -0,0 +1,149 @@
import { useQuery } from '@tanstack/react-query';
import { getMetrics, getPrometheusMetrics, getHealth } from '../api/client';
import PageHeader from '../components/PageHeader';
import ErrorState from '../components/ErrorState';
function MetricCard({ label, value, sub }: { label: string; value: string | number; sub?: string }) {
return (
<div className="bg-surface border border-surface-border rounded p-4 shadow-sm">
<div className="text-xs text-ink-muted mb-1">{label}</div>
<div className="text-2xl font-bold text-ink">{value}</div>
{sub && <div className="text-xs text-ink-faint mt-1">{sub}</div>}
</div>
);
}
function formatUptime(seconds: number): string {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h ${m}m`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
export default function ObservabilityPage() {
const { data: metrics, isLoading, error, refetch } = useQuery({
queryKey: ['metrics'],
queryFn: getMetrics,
refetchInterval: 15000,
});
const { data: health } = useQuery({
queryKey: ['health'],
queryFn: getHealth,
refetchInterval: 15000,
});
const { data: promText } = useQuery({
queryKey: ['prometheus-metrics'],
queryFn: getPrometheusMetrics,
refetchInterval: 30000,
retry: false,
});
if (error) {
return (
<>
<PageHeader title="Observability" />
<ErrorState error={error as Error} onRetry={() => refetch()} />
</>
);
}
return (
<>
<PageHeader
title="Observability"
subtitle={health ? `Server: ${health.status}` : undefined}
/>
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
{/* Health status */}
<div className="flex items-center gap-3">
<div className={`w-3 h-3 rounded-full ${health?.status === 'ok' ? 'bg-emerald-500' : 'bg-red-500'}`} />
<span className="text-sm text-ink font-medium">
Server {health?.status === 'ok' ? 'Healthy' : 'Unhealthy'}
</span>
{metrics && (
<span className="text-xs text-ink-faint ml-auto">
Uptime: {formatUptime(metrics.uptime.uptime_seconds)} | Started: {new Date(metrics.uptime.server_started).toLocaleString()}
</span>
)}
</div>
{/* Gauge metrics */}
{isLoading && (
<div className="text-sm text-ink-muted py-10 text-center">Loading metrics...</div>
)}
{metrics && (
<>
<div>
<h3 className="text-sm font-semibold text-ink-muted mb-3">Certificate Gauges</h3>
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
<MetricCard label="Total" value={metrics.gauge.certificate_total} />
<MetricCard label="Active" value={metrics.gauge.certificate_active} />
<MetricCard label="Expiring Soon" value={metrics.gauge.certificate_expiring_soon} />
<MetricCard label="Expired" value={metrics.gauge.certificate_expired} />
<MetricCard label="Revoked" value={metrics.gauge.certificate_revoked} />
</div>
</div>
<div>
<h3 className="text-sm font-semibold text-ink-muted mb-3">Agent & Job Gauges</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<MetricCard label="Total Agents" value={metrics.gauge.agent_total} />
<MetricCard label="Online Agents" value={metrics.gauge.agent_online} />
<MetricCard label="Pending Jobs" value={metrics.gauge.job_pending} />
</div>
</div>
<div>
<h3 className="text-sm font-semibold text-ink-muted mb-3">Counters</h3>
<div className="grid grid-cols-2 md:grid-cols-2 gap-3">
<MetricCard label="Jobs Completed (total)" value={metrics.counter.job_completed_total} />
<MetricCard label="Jobs Failed (total)" value={metrics.counter.job_failed_total} />
</div>
</div>
</>
)}
{/* Prometheus config */}
<div>
<h3 className="text-sm font-semibold text-ink-muted mb-3">Prometheus Integration</h3>
<div className="bg-surface border border-surface-border rounded p-4 shadow-sm">
<p className="text-sm text-ink mb-3">
Add this scrape target to your <code className="text-xs bg-surface-muted px-1 py-0.5 rounded">prometheus.yml</code>:
</p>
<pre className="bg-ink text-white rounded p-4 text-xs overflow-x-auto font-mono">
{`scrape_configs:
- job_name: 'certctl'
metrics_path: '/api/v1/metrics/prometheus'
scheme: 'https'
bearer_token: '<YOUR_API_KEY>'
static_configs:
- targets: ['<CERTCTL_HOST>:443']`}
</pre>
</div>
</div>
{/* Live Prometheus output */}
{promText && (
<div>
<h3 className="text-sm font-semibold text-ink-muted mb-3">Live Prometheus Output</h3>
<div className="bg-surface border border-surface-border rounded shadow-sm">
<div className="px-4 py-2 border-b border-surface-border flex items-center justify-between">
<span className="text-xs text-ink-faint font-mono">GET /api/v1/metrics/prometheus</span>
<span className="text-xs text-ink-faint">text/plain</span>
</div>
<pre className="p-4 text-xs text-ink-muted overflow-x-auto font-mono max-h-96 overflow-y-auto whitespace-pre">
{promText}
</pre>
</div>
</div>
)}
</div>
</>
);
}
+169
View File
@@ -0,0 +1,169 @@
import { useParams, Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { getTarget, getJobs } from '../api/client';
import PageHeader from '../components/PageHeader';
import StatusBadge from '../components/StatusBadge';
import DataTable from '../components/DataTable';
import type { Column } from '../components/DataTable';
import ErrorState from '../components/ErrorState';
import { formatDateTime } from '../api/utils';
import type { Job } from '../api/types';
const typeLabels: Record<string, string> = {
nginx: 'NGINX',
apache: 'Apache',
haproxy: 'HAProxy',
traefik: 'Traefik',
caddy: 'Caddy',
f5_bigip: 'F5 BIG-IP',
iis: 'IIS',
};
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex justify-between py-2 border-b border-surface-border/50">
<span className="text-sm text-ink-muted">{label}</span>
<span className="text-sm text-ink">{value}</span>
</div>
);
}
export default function TargetDetailPage() {
const { id } = useParams<{ id: string }>();
const { data: target, isLoading, error, refetch } = useQuery({
queryKey: ['target', id],
queryFn: () => getTarget(id!),
enabled: !!id,
});
// Deployment jobs for this target
const { data: jobsData } = useQuery({
queryKey: ['jobs', { target_id: id, type: 'Deployment' }],
queryFn: () => getJobs({ target_id: id! }),
enabled: !!id,
});
if (error) {
return (
<>
<PageHeader title="Target Details" />
<ErrorState error={error as Error} onRetry={() => refetch()} />
</>
);
}
if (isLoading || !target) {
return (
<>
<PageHeader title="Target Details" />
<div className="flex items-center justify-center py-20">
<div className="text-sm text-ink-muted">Loading target...</div>
</div>
</>
);
}
const jobColumns: Column<Job>[] = [
{
key: 'id',
label: 'Job',
render: (j) => (
<Link to={`/jobs/${j.id}`} className="font-mono text-xs text-accent hover:text-accent-bright">
{j.id}
</Link>
),
},
{ key: 'status', label: 'Status', render: (j) => <StatusBadge status={j.status} /> },
{ key: 'cert', label: 'Certificate', render: (j) => (
<Link to={`/certificates/${j.certificate_id}`} className="text-xs text-accent hover:text-accent-bright font-mono">
{j.certificate_id}
</Link>
)},
{ key: 'completed', label: 'Completed', render: (j) => <span className="text-xs text-ink-muted">{formatDateTime(j.completed_at)}</span> },
{
key: 'verification',
label: 'Verification',
render: (j) => {
if (!j.verification_status) return <span className="text-xs text-ink-faint"></span>;
const styles: Record<string, string> = {
success: 'bg-emerald-100 text-emerald-700',
failed: 'bg-red-100 text-red-700',
pending: 'bg-yellow-100 text-yellow-700',
skipped: 'bg-gray-100 text-gray-600',
};
const labels: Record<string, string> = {
success: 'Verified',
failed: 'Failed',
pending: 'Pending',
skipped: 'Skipped',
};
return (
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${styles[j.verification_status] || 'bg-gray-100 text-gray-600'}`}>
{labels[j.verification_status] || j.verification_status}
</span>
);
},
},
];
return (
<>
<PageHeader
title={target.name}
subtitle={typeLabels[target.type] || target.type}
/>
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Target info */}
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
<h3 className="text-sm font-semibold text-ink-muted mb-4">Target Information</h3>
<InfoRow label="ID" value={<span className="font-mono text-xs">{target.id}</span>} />
<InfoRow label="Name" value={target.name} />
<InfoRow label="Type" value={typeLabels[target.type] || target.type} />
<InfoRow label="Hostname" value={target.hostname || '—'} />
<InfoRow label="Status" value={<StatusBadge status={target.status} />} />
{target.agent_id && (
<InfoRow label="Agent" value={
<Link to={`/agents/${target.agent_id}`} className="text-xs text-accent hover:text-accent-bright font-mono">
{target.agent_id}
</Link>
} />
)}
<InfoRow label="Created" value={formatDateTime(target.created_at)} />
</div>
{/* Config */}
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
<h3 className="text-sm font-semibold text-ink-muted mb-4">Configuration</h3>
{target.config && Object.keys(target.config).length > 0 ? (
<div className="space-y-0">
{Object.entries(target.config).map(([key, val]) => (
<InfoRow key={key} label={key.replace(/_/g, ' ')} value={
<span className="font-mono text-xs truncate max-w-xs inline-block">{String(val)}</span>
} />
))}
</div>
) : (
<div className="text-sm text-ink-faint py-4 text-center">No configuration data</div>
)}
</div>
</div>
{/* Deployment history */}
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
<h3 className="text-sm font-semibold text-ink-muted mb-4">
Deployment History {jobsData ? `(${jobsData.total})` : ''}
</h3>
<DataTable
columns={jobColumns}
data={jobsData?.data || []}
isLoading={!jobsData}
emptyMessage="No deployments to this target"
/>
</div>
</div>
</>
);
}
+4 -1
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { getTargets, createTarget, deleteTarget } from '../api/client'; import { getTargets, createTarget, deleteTarget } from '../api/client';
import PageHeader from '../components/PageHeader'; import PageHeader from '../components/PageHeader';
@@ -266,7 +267,9 @@ export default function TargetsPage() {
label: 'Target', label: 'Target',
render: (t) => ( render: (t) => (
<div> <div>
<div className="font-medium text-ink">{t.name}</div> <Link to={`/targets/${t.id}`} className="font-medium text-accent hover:text-accent-bright" onClick={(e) => e.stopPropagation()}>
{t.name}
</Link>
<div className="text-xs text-ink-faint font-mono">{t.id}</div> <div className="text-xs text-ink-faint font-mono">{t.id}</div>
</div> </div>
), ),