fix: Docker Compose missing migrations, network scan []int crash, demo seed data

Three bugs fixed:
- Docker Compose only mounted migration 000001; migrations 000002-000007
  (profiles, agent groups, revocation, discovery, network scans) never ran,
  breaking half the demo features. Now mounts all 7 migrations in order.
- Network Scans page crashed with pq.Array scan error because lib/pq
  doesn't support []int, only []int64. Changed Ports field accordingly.
- Dashboard pie chart displayed "RenewalInProgress" without spaces.
  Added formatStatus() helper for PascalCase → spaced display.

Also adds first-run demo experience improvements:
- 9 discovered certificates (filesystem + network scan mix)
- 3 discovery scans with recent timestamps
- 2 AwaitingApproval renewal jobs for approval workflow demo
- CERTCTL_NETWORK_SCAN_ENABLED=true in Docker Compose
- Network scan targets seeded with last_scan results
- Version badge updated to v2.0.5
- Docs updated (quickstart, advanced demo) to reference seeded data

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Shankar
2026-03-27 18:33:50 -04:00
parent 7028629acd
commit 1444aba08d
11 changed files with 156 additions and 23 deletions
+9 -2
View File
@@ -12,8 +12,14 @@ services:
volumes:
- postgres_data:/var/lib/postgresql/data
- ../migrations/000001_initial_schema.up.sql:/docker-entrypoint-initdb.d/001_schema.sql
- ../migrations/seed.sql:/docker-entrypoint-initdb.d/002_seed.sql
- ../migrations/seed_demo.sql:/docker-entrypoint-initdb.d/003_seed_demo.sql
- ../migrations/000002_agent_metadata.up.sql:/docker-entrypoint-initdb.d/002_agent_metadata.sql
- ../migrations/000003_certificate_profiles.up.sql:/docker-entrypoint-initdb.d/003_certificate_profiles.sql
- ../migrations/000004_agent_groups.up.sql:/docker-entrypoint-initdb.d/004_agent_groups.sql
- ../migrations/000005_revocation.up.sql:/docker-entrypoint-initdb.d/005_revocation.sql
- ../migrations/000006_discovery.up.sql:/docker-entrypoint-initdb.d/006_discovery.sql
- ../migrations/000007_network_discovery.up.sql:/docker-entrypoint-initdb.d/007_network_discovery.sql
- ../migrations/seed.sql:/docker-entrypoint-initdb.d/010_seed.sql
- ../migrations/seed_demo.sql:/docker-entrypoint-initdb.d/011_seed_demo.sql
networks:
- certctl-network
healthcheck:
@@ -39,6 +45,7 @@ services:
CERTCTL_LOG_LEVEL: info
CERTCTL_AUTH_TYPE: none
CERTCTL_KEYGEN_MODE: server # Demo uses server-side keygen; production should use "agent"
CERTCTL_NETWORK_SCAN_ENABLED: "true" # Enable network scan GUI with seeded demo targets
ports:
- "8443:8443"
networks:
+6 -4
View File
@@ -876,14 +876,14 @@ curl -s -X POST $API/api/v1/agent-groups \
## Part 12: Interactive Approval Workflow
For high-value certificates, you may want human oversight before renewal proceeds. Create a policy that requires approval:
For high-value certificates, you may want human oversight before renewal proceeds. The demo includes 2 pre-seeded `AwaitingApproval` renewal jobs (for `auth-production` and `payments-production`). Open **Jobs** in the sidebar — you'll see the amber "Pending Approval" banner and Approve/Reject buttons immediately.
```bash
# Check jobs that need approval
# Check jobs that need approval (demo includes 2)
curl -s "$API/api/v1/jobs?status=AwaitingApproval" | jq '.data[] | {id, type, certificate_id, status}'
```
If there are jobs awaiting approval, approve or reject them:
Approve or reject them:
```bash
# Approve a job
@@ -1029,6 +1029,8 @@ The MCP server is perfect for:
certctl discovers existing certificates two ways: **filesystem scanning** (agents scan local directories) and **network scanning** (the server probes TLS endpoints). Both feed into the same triage pipeline.
**The demo comes pre-loaded with discovery data:** 9 discovered certificates (3 Unmanaged from filesystem scans, 3 Unmanaged from network scans, 2 Managed, 1 Dismissed), 3 discovery scans, and 3 network scan targets with recent scan results. Open **Discovery** in the sidebar to see the triage workflow immediately. The steps below show how to configure discovery from scratch.
### Filesystem Discovery (Agent-Side)
Configure the demo agent to scan for certificates. In the Docker Compose setup, agents have a `/tmp/certs` directory (created by the seed script). Restart the agent with discovery enabled:
@@ -1049,7 +1051,7 @@ certctl-agent --agent-id a-demo-1 --key-dir /tmp/keys --discovery-dirs /tmp/cert
### Network Discovery (Server-Side)
The server can also discover certificates by actively probing TLS endpoints — no agent required. Create a scan target and trigger a scan:
The server can also discover certificates by actively probing TLS endpoints — no agent required. Network scanning is enabled by default in the Docker Compose demo (`CERTCTL_NETWORK_SCAN_ENABLED=true`), with 3 pre-configured scan targets. You can create additional targets:
```bash
# Create a network scan target
+15 -4
View File
@@ -101,7 +101,9 @@ Explore the sidebar: Certificates, Agents, Policies, Jobs, Audit Trail, Notifica
**"Can I revoke a compromised cert?"** — Click any active certificate, then "Revoke." A modal with RFC 5280 reason codes (Key Compromise, Superseded, Cessation of Operation). After revocation, CRL and OCSP are served automatically — clients stop trusting the cert immediately.
**"What about certificates already in production?"** — Click "Discovered Certificates." Agents scan local filesystems for existing certs. The server probes TLS endpoints on configured CIDR ranges. Both feed into a triage workflow: claim unmanaged certs to bring them under automation, or dismiss them.
**"What about certificates already in production?"** — Click "Discovery" in the sidebar. The demo comes pre-loaded with 9 discovered certificates — some found by agents scanning filesystems, some found by the server probing TLS endpoints on the network. You'll see Unmanaged certs waiting for triage (including an expired printer cert and an expiring switch management cert), certs already linked to managed inventory, and one that was dismissed. Claim unmanaged certs to bring them under automation, or dismiss them. Click "Network Scans" to see the 3 configured scan targets with recent scan results.
**"I need to approve a renewal before it proceeds"** — Click "Jobs" in the sidebar. You'll see an amber banner: "2 jobs awaiting approval." These are renewal jobs for `auth-production` and `payments-production` that require human sign-off before proceeding. Click Approve or Reject with a reason — the decision is recorded in the audit trail.
**"Show me the agent fleet"** — Click "Agents." Four agents online, one offline. Click "Fleet Overview" for OS/architecture grouping, version distribution, and per-platform listing. Agents generate ECDSA P-256 keys locally — private keys never leave your infrastructure.
@@ -254,9 +256,12 @@ curl -s http://localhost:8443/api/v1/crl | jq .
### Interactive approval workflow
For high-value certificates where you want human oversight:
For high-value certificates where you want human oversight. The demo includes 2 pre-seeded jobs in `AwaitingApproval` status (for `auth-production` and `payments-production`). Open **Jobs** in the sidebar and you'll see the amber "Pending Approval" banner immediately.
```bash
# List jobs awaiting approval (demo includes 2)
curl -s "http://localhost:8443/api/v1/jobs?status=AwaitingApproval" | jq '.data[] | {id, certificate_id, status}'
# Approve a pending job
curl -s -X POST http://localhost:8443/api/v1/jobs/JOB_ID/approve \
-H "Content-Type: application/json" \
@@ -272,6 +277,8 @@ curl -s -X POST http://localhost:8443/api/v1/jobs/JOB_ID/reject \
Find certificates already running in your infrastructure — ones you didn't issue through certctl.
The demo environment comes pre-loaded with 9 discovered certificates (from agent filesystem scans and server-side network scans), 3 network scan targets, and recent scan history. Open **Discovery** and **Network Scans** in the sidebar to see the triage workflow immediately.
### Filesystem discovery (agent-based)
```bash
@@ -355,11 +362,15 @@ Exposes 78 MCP tools covering the REST API via stdio transport. Ask Claude: "Wha
| Teams | 5 | Platform, Security, Payments, Frontend, Data |
| Owners | 5 | Alice, Bob, Carol, Dave, Eve |
| Issuers | 4 | Local Dev CA, Let's Encrypt Staging, step-ca Internal, DigiCert (disabled) |
| Agents | 5 | ag-web-prod, ag-web-staging, ag-lb-prod, ag-iis-prod, ag-data-prod |
| Agents | 6 | ag-web-prod, ag-web-staging, ag-lb-prod, ag-iis-prod, ag-data-prod, server-scanner (network discovery) |
| Targets | 5 | NGINX (prod/staging/data), F5 LB, IIS |
| Certificates | 15 | Various statuses: Active, Expiring, Expired, Failed, Wildcard |
| Discovered Certs | 9 | 5 Unmanaged (filesystem + network), 2 Managed (linked), 1 Dismissed, network-discovered expired printer cert |
| Discovery Scans | 3 | Agent filesystem scans + network TLS scan |
| Network Scan Targets | 3 | DC1 Web Servers, DC2 Application Tier, DMZ Public Endpoints |
| Jobs (Approval) | 2 | AwaitingApproval renewal jobs for auth-prod and payments-prod |
| Policies | 4 | Required owner, allowed environments, max lifetime, min renewal window |
| Profiles | 3 | Default TLS, Short-Lived, High-Security |
| Profiles | 4 | Standard TLS, Internal mTLS, Short-Lived, High Security |
| Agent Groups | 5 | Linux agents, ARM agents, Production subnet, etc. |
## Dashboard Demo Mode
@@ -77,8 +77,8 @@ func (m *mockNetworkScanService) TriggerScan(ctx context.Context, targetID strin
func TestListNetworkScanTargets(t *testing.T) {
svc := &mockNetworkScanService{
targets: []*domain.NetworkScanTarget{
{ID: "nst-1", Name: "target1", CIDRs: []string{"10.0.0.0/24"}, Ports: []int{443}},
{ID: "nst-2", Name: "target2", CIDRs: []string{"192.168.0.0/16"}, Ports: []int{443, 8443}},
{ID: "nst-1", Name: "target1", CIDRs: []string{"10.0.0.0/24"}, Ports: []int64{443}},
{ID: "nst-2", Name: "target2", CIDRs: []string{"192.168.0.0/16"}, Ports: []int64{443, 8443}},
},
}
h := NewNetworkScanHandler(svc)
@@ -118,7 +118,7 @@ func TestCreateNetworkScanTarget(t *testing.T) {
body, _ := json.Marshal(map[string]interface{}{
"name": "Production",
"cidrs": []string{"10.0.0.0/24"},
"ports": []int{443},
"ports": []int64{443},
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/network-scan-targets", bytes.NewReader(body))
+1 -1
View File
@@ -7,7 +7,7 @@ type NetworkScanTarget struct {
ID string `json:"id"`
Name string `json:"name"`
CIDRs []string `json:"cidrs"`
Ports []int `json:"ports"`
Ports []int64 `json:"ports"`
Enabled bool `json:"enabled"`
ScanIntervalHours int `json:"scan_interval_hours"`
TimeoutMs int `json:"timeout_ms"`
+2 -2
View File
@@ -10,7 +10,7 @@ func TestNetworkScanTarget_Defaults(t *testing.T) {
ID: "nst-test",
Name: "Test Target",
CIDRs: []string{"10.0.0.0/24"},
Ports: []int{443},
Ports: []int64{443},
Enabled: true,
ScanIntervalHours: 6,
TimeoutMs: 5000,
@@ -35,7 +35,7 @@ func TestNetworkScanTarget_WithScanResults(t *testing.T) {
ID: "nst-prod",
Name: "Production Network",
CIDRs: []string{"192.168.1.0/24", "10.0.0.0/16"},
Ports: []int{443, 8443, 636},
Ports: []int64{443, 8443, 636},
Enabled: true,
ScanIntervalHours: 1,
TimeoutMs: 3000,
+2 -2
View File
@@ -76,7 +76,7 @@ func (s *NetworkScanService) CreateTarget(ctx context.Context, target *domain.Ne
}
}
if len(target.Ports) == 0 {
target.Ports = []int{443}
target.Ports = []int64{443}
}
if target.ScanIntervalHours == 0 {
target.ScanIntervalHours = 6
@@ -276,7 +276,7 @@ func (s *NetworkScanService) scanTarget(ctx context.Context, target *domain.Netw
}
// expandEndpoints converts CIDR ranges and ports into a list of "ip:port" endpoints.
func (s *NetworkScanService) expandEndpoints(cidrs []string, ports []int) []string {
func (s *NetworkScanService) expandEndpoints(cidrs []string, ports []int64) []string {
var endpoints []string
for _, cidr := range cidrs {
+2 -2
View File
@@ -123,7 +123,7 @@ func TestNetworkScanService_CreateTarget(t *testing.T) {
target, err := svc.CreateTarget(context.Background(), &domain.NetworkScanTarget{
Name: "Test Network",
CIDRs: []string{"10.0.0.0/24"},
Ports: []int{443, 8443},
Ports: []int64{443, 8443},
})
if err != nil {
t.Fatalf("CreateTarget failed: %v", err)
@@ -221,7 +221,7 @@ func TestNetworkScanService_ListTargets(t *testing.T) {
func TestExpandEndpoints(t *testing.T) {
svc := &NetworkScanService{}
endpoints := svc.expandEndpoints([]string{"192.168.1.1"}, []int{443, 8443})
endpoints := svc.expandEndpoints([]string{"192.168.1.1"}, []int64{443, 8443})
if len(endpoints) != 2 {
t.Errorf("expected 2 endpoints, got %d: %v", len(endpoints), endpoints)
}
+110
View File
@@ -214,3 +214,113 @@ INSERT INTO agent_group_members (agent_group_id, agent_id, membership_type, crea
('ag-manual', 'ag-web-staging', 'include', NOW()),
('ag-manual', 'ag-iis-prod', 'exclude', NOW())
ON CONFLICT (agent_group_id, agent_id) DO NOTHING;
-- Sentinel agent for network-discovered certificates (created by server on startup, seed for demo)
INSERT INTO agents (id, name, hostname, status, last_heartbeat_at, registered_at, api_key_hash, os, architecture, ip_address, version) VALUES
('server-scanner', 'Network Scanner (Server-Side)', 'certctl-server', 'online', NOW(), NOW() - INTERVAL '30 days', 'sentinel_no_auth', 'linux', 'amd64', '127.0.0.1', '2.0.5')
ON CONFLICT (id) DO NOTHING;
-- Discovery Scans — show recent scan activity from agents
INSERT INTO discovery_scans (id, agent_id, directories, certificates_found, certificates_new, errors_count, scan_duration_ms, started_at, completed_at) VALUES
('ds-web-prod-01', 'ag-web-prod', '{/etc/nginx/ssl,/etc/pki/tls/certs}', 4, 2, 0, 1250, NOW() - INTERVAL '3 hours', NOW() - INTERVAL '3 hours' + INTERVAL '1 second'),
('ds-data-prod-01', 'ag-data-prod', '{/etc/nginx/ssl,/opt/certs}', 3, 1, 0, 980, NOW() - INTERVAL '2 hours', NOW() - INTERVAL '2 hours' + INTERVAL '1 second'),
('ds-network-scan-01','server-scanner', '{network-scan}', 3, 3, 0, 4500, NOW() - INTERVAL '1 hour', NOW() - INTERVAL '1 hour' + INTERVAL '5 seconds')
ON CONFLICT (id) DO NOTHING;
-- Discovered Certificates — populate discovery triage page with realistic mix
INSERT INTO discovered_certificates (id, fingerprint_sha256, common_name, sans, serial_number, issuer_dn, subject_dn, not_before, not_after, key_algorithm, key_size, is_ca, pem_data, source_path, source_format, agent_id, discovery_scan_id, managed_certificate_id, status, first_seen_at, last_seen_at) VALUES
-- Unmanaged: found on filesystem, not yet claimed
('dc-unmanaged-01', 'sha256:f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0',
'internal-service.example.com', ARRAY['internal-service.example.com', 'internal-svc.local'],
'1A:2B:3C:4D:5E:6F:00:11', 'CN=Example Internal CA,O=Example Corp',
'CN=internal-service.example.com,O=Example Corp', NOW() - INTERVAL '200 days', NOW() + INTERVAL '20 days',
'RSA', 2048, false, '', '/etc/pki/tls/certs/internal-svc.pem', 'PEM',
'ag-web-prod', 'ds-web-prod-01', NULL, 'Unmanaged',
NOW() - INTERVAL '7 days', NOW() - INTERVAL '3 hours'),
('dc-unmanaged-02', 'sha256:a9b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0',
'monitoring.internal.example.com', ARRAY['monitoring.internal.example.com', 'prometheus.internal.example.com'],
'2B:3C:4D:5E:6F:7A:00:22', 'CN=Let''s Encrypt Authority X3,O=Let''s Encrypt',
'CN=monitoring.internal.example.com', NOW() - INTERVAL '60 days', NOW() + INTERVAL '30 days',
'ECDSA', 256, false, '', '/opt/certs/monitoring.pem', 'PEM',
'ag-data-prod', 'ds-data-prod-01', NULL, 'Unmanaged',
NOW() - INTERVAL '5 days', NOW() - INTERVAL '2 hours'),
('dc-unmanaged-03', 'sha256:1122334455667788990011223344556677889900',
'db-replication.example.com', ARRAY['db-replication.example.com'],
'3C:4D:5E:6F:7A:8B:00:33', 'CN=Example Internal CA,O=Example Corp',
'CN=db-replication.example.com,O=Example Corp', NOW() - INTERVAL '300 days', NOW() - INTERVAL '10 days',
'RSA', 4096, false, '', '/etc/pki/tls/certs/db-repl.pem', 'PEM',
'ag-web-prod', 'ds-web-prod-01', NULL, 'Unmanaged',
NOW() - INTERVAL '7 days', NOW() - INTERVAL '3 hours'),
-- Managed: already linked to managed certificates
('dc-managed-01', 'sha256:ab12cd34ef56ab12cd34ef56ab12cd34ef56ab12',
'api.example.com', ARRAY['api.example.com', 'api-v2.example.com'],
'0A:1B:2C:3D:4E:5F:00:01', 'CN=CertCtl Demo CA',
'CN=api.example.com', NOW() - INTERVAL '15 days', NOW() + INTERVAL '75 days',
'ECDSA', 256, false, '', '/etc/nginx/ssl/cert.pem', 'PEM',
'ag-web-prod', 'ds-web-prod-01', 'mc-api-prod', 'Managed',
NOW() - INTERVAL '15 days', NOW() - INTERVAL '3 hours'),
('dc-managed-02', 'sha256:cd34ef56ab12cd34ef56ab12cd34ef56ab12cd34',
'data.example.com', ARRAY['data.example.com', 'analytics.example.com'],
'0A:1B:2C:3D:4E:5F:00:06', 'CN=CertCtl Demo CA',
'CN=data.example.com', NOW() - INTERVAL '35 days', NOW() + INTERVAL '55 days',
'ECDSA', 256, false, '', '/etc/nginx/ssl/cert.pem', 'PEM',
'ag-data-prod', 'ds-data-prod-01', 'mc-data-prod', 'Managed',
NOW() - INTERVAL '35 days', NOW() - INTERVAL '2 hours'),
-- Dismissed: triaged and explicitly ignored
('dc-dismissed-01', 'sha256:9988776655443322110099887766554433221100',
'test-selfsigned.local', ARRAY['test-selfsigned.local', 'localhost'],
'00:00:00:00:00:00:FF:01', 'CN=test-selfsigned.local',
'CN=test-selfsigned.local', NOW() - INTERVAL '365 days', NOW() + INTERVAL '365 days',
'RSA', 2048, false, '', '/etc/pki/tls/certs/test.pem', 'PEM',
'ag-web-prod', 'ds-web-prod-01', NULL, 'Dismissed',
NOW() - INTERVAL '7 days', NOW() - INTERVAL '3 hours'),
-- Network-discovered certs (from server-scanner sentinel agent)
('dc-network-01', 'sha256:net1aabbccdd11223344556677889900aabbccdd',
'switch-mgmt.example.com', ARRAY['switch-mgmt.example.com'],
'5E:6F:7A:8B:9C:0D:00:44', 'CN=Example Network CA,O=Example Corp',
'CN=switch-mgmt.example.com,O=Example Corp', NOW() - INTERVAL '180 days', NOW() + INTERVAL '5 days',
'RSA', 2048, false, '', '10.0.1.50:443', 'TLS',
'server-scanner', 'ds-network-scan-01', NULL, 'Unmanaged',
NOW() - INTERVAL '1 hour', NOW() - INTERVAL '1 hour'),
('dc-network-02', 'sha256:net2eeff00112233445566778899aabbccddeeff',
'printer.example.com', ARRAY['printer.example.com'],
'6F:7A:8B:9C:0D:1E:00:55', 'CN=printer.example.com',
'CN=printer.example.com', NOW() - INTERVAL '400 days', NOW() - INTERVAL '30 days',
'RSA', 1024, false, '', '10.0.2.100:443', 'TLS',
'server-scanner', 'ds-network-scan-01', NULL, 'Unmanaged',
NOW() - INTERVAL '1 hour', NOW() - INTERVAL '1 hour'),
('dc-network-03', 'sha256:net3001122334455667788990011223344556677',
'vpn-appliance.example.com', ARRAY['vpn-appliance.example.com', '10.0.1.1'],
'7A:8B:9C:0D:1E:2F:00:66', 'CN=Fortinet CA,O=Fortinet',
'CN=vpn-appliance.example.com', NOW() - INTERVAL '90 days', NOW() + INTERVAL '275 days',
'RSA', 2048, false, '', '10.0.1.1:443', 'TLS',
'server-scanner', 'ds-network-scan-01', NULL, 'Unmanaged',
NOW() - INTERVAL '1 hour', NOW() - INTERVAL '1 hour')
ON CONFLICT (id) DO NOTHING;
-- Jobs — add AwaitingApproval jobs for approval workflow demo
INSERT INTO jobs (id, type, certificate_id, target_id, agent_id, status, attempts, max_attempts, last_error, scheduled_at, created_at) VALUES
('job-approval-01', 'renewal', 'mc-auth-prod', NULL, 'ag-web-prod', 'AwaitingApproval', 0, 3, NULL, NOW() - INTERVAL '1 hour', NOW() - INTERVAL '1 hour'),
('job-approval-02', 'renewal', 'mc-pay-prod', NULL, 'ag-web-prod', 'AwaitingApproval', 0, 3, NULL, NOW() - INTERVAL '30 minutes', NOW() - INTERVAL '30 minutes')
ON CONFLICT (id) DO NOTHING;
-- Update network scan targets with last_scan data so GUI shows recent activity
UPDATE network_scan_targets SET
last_scan_at = NOW() - INTERVAL '1 hour',
last_scan_duration_ms = 4500,
last_scan_certs_found = 3
WHERE id = 'nst-dc1-web';
UPDATE network_scan_targets SET
last_scan_at = NOW() - INTERVAL '2 hours',
last_scan_duration_ms = 8200,
last_scan_certs_found = 0
WHERE id = 'nst-dc2-apps';
+1 -1
View File
@@ -69,7 +69,7 @@ export default function Layout() {
</nav>
<div className="px-5 py-3 border-t border-white/10 flex items-center justify-between">
<span className="text-[10px] text-brand-300/60 font-mono">v2.0.4</span>
<span className="text-[10px] text-brand-300/60 font-mono">v2.0.5</span>
{authRequired && (
<button
onClick={logout}
+5 -2
View File
@@ -13,6 +13,9 @@ import PageHeader from '../components/PageHeader';
import StatusBadge from '../components/StatusBadge';
import { daysUntil, expiryColor, formatDate } from '../api/utils';
// Convert PascalCase status like "RenewalInProgress" to "Renewal In Progress"
const formatStatus = (s: string) => s.replace(/([a-z])([A-Z])/g, '$1 $2');
const STATUS_COLORS: Record<string, string> = {
Active: '#10b981',
Expiring: '#f59e0b',
@@ -149,7 +152,7 @@ export default function DashboardPage() {
outerRadius={90}
paddingAngle={2}
dataKey="value"
label={({ name, value }) => `${name}: ${value}`}
label={({ name, value }) => `${formatStatus(name || '')}: ${value}`}
labelLine={false}
>
{pieData.map((entry, index) => (
@@ -160,7 +163,7 @@ export default function DashboardPage() {
<Legend
verticalAlign="bottom"
height={36}
formatter={(value: string) => <span className="text-xs text-ink-muted">{value}</span>}
formatter={(value: string) => <span className="text-xs text-ink-muted">{formatStatus(value)}</span>}
/>
</PieChart>
</ResponsiveContainer>