diff --git a/README.md b/README.md index 1289ccf..c139268 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ certctl is a self-hosted platform that automates the entire certificate lifecycl | [Architecture](docs/architecture.md) | System design, data flow diagrams, security model | | [Connectors](docs/connectors.md) | Build custom issuer, target, and notifier connectors | | [Compliance Mapping](docs/compliance.md) | SOC 2 Type II, PCI-DSS 4.0, NIST SP 800-57 alignment guides | -| [Manual Testing Guide](docs/testing-guide.md) | 284 tests across 25 areas — full V2 QA runbook with exact commands and pass/fail criteria | +| [Manual Testing Guide](docs/testing-guide.md) | Extensively tested — full V2 QA runbook with exact commands and pass/fail criteria | ## Contents @@ -87,29 +87,29 @@ certctl gives you a single pane of glass for every TLS certificate in your organ - - - + + + - - - + + + - - - + + + - - - + + + - - - + + +
Dashboard
Dashboard
Stats, expiration heatmap, renewal trends
Certificates
Certificates
Inventory with status, owner, team filters
Agents
Agents
Fleet health, OS/arch, IP, version
Dashboard
Dashboard
Stats, expiration heatmap, renewal trends
Certificates
Certificates
Inventory with status, owner, team filters
Agents
Agents
Fleet health, OS/arch, IP, version
Fleet Overview
Fleet Overview
OS distribution, status breakdown
Jobs
Jobs
Issuance, renewal, deployment queue
Notifications
Notifications
Expiration warnings, renewal results
Fleet Overview
Fleet Overview
OS distribution, status breakdown
Jobs
Jobs
Issuance, renewal, deployment queue
Notifications
Notifications
Expiration warnings, renewal results
Policies
Policies
Ownership, lifetime, renewal rules
Profiles
Profiles
Key types, max TTL, crypto constraints
Issuers
Issuers
Local CA, ACME, step-ca connectors
Policies
Policies
Ownership, lifetime, renewal rules
Profiles
Profiles
Key types, max TTL, crypto constraints
Issuers
Issuers
Local CA, ACME, step-ca connectors
Targets
Targets
NGINX, Apache, HAProxy deployment
Owners
Owners
Cert ownership with team assignment
Teams
Teams
Org grouping for notification routing
Targets
Targets
NGINX, Apache, HAProxy deployment
Owners
Owners
Cert ownership with team assignment
Teams
Teams
Org grouping for notification routing
Agent Groups
Agent Groups
Dynamic grouping by OS, arch, CIDR
Audit Trail
Audit Trail
Immutable log, CSV/JSON export
Short-Lived
Short-Lived Creds
Ephemeral certs with live TTL countdown
Agent Groups
Agent Groups
Dynamic grouping by OS, arch, CIDR
Audit Trail
Audit Trail
Immutable log, CSV/JSON export
Short-Lived
Short-Lived Creds
Ephemeral certs with live TTL countdown
diff --git a/docs/architecture.md b/docs/architecture.md index 29d36e8..6d68f2e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -99,7 +99,7 @@ The dashboard includes an **ErrorBoundary component** for graceful error recover **Tech decisions**: - Vite for fast builds and HMR during development - TanStack Query over manual fetch/useEffect for automatic cache invalidation and refetching -- Dark theme default (ops teams live in dark mode) +- Light content area with branded dark teal sidebar, Inter + JetBrains Mono typography - SSE/WebSocket planned for real-time job status updates ### PostgreSQL Database diff --git a/docs/screenshots/v2-agent-groups.png b/docs/screenshots/v2-agent-groups.png new file mode 100644 index 0000000..696a82f Binary files /dev/null and b/docs/screenshots/v2-agent-groups.png differ diff --git a/docs/screenshots/v2-agents.png b/docs/screenshots/v2-agents.png new file mode 100644 index 0000000..5c69bbf Binary files /dev/null and b/docs/screenshots/v2-agents.png differ diff --git a/docs/screenshots/v2-audit-trail.png b/docs/screenshots/v2-audit-trail.png new file mode 100644 index 0000000..13452e1 Binary files /dev/null and b/docs/screenshots/v2-audit-trail.png differ diff --git a/docs/screenshots/v2-certificates.png b/docs/screenshots/v2-certificates.png new file mode 100644 index 0000000..78a2999 Binary files /dev/null and b/docs/screenshots/v2-certificates.png differ diff --git a/docs/screenshots/v2-dashboard.png b/docs/screenshots/v2-dashboard.png new file mode 100644 index 0000000..ce12244 Binary files /dev/null and b/docs/screenshots/v2-dashboard.png differ diff --git a/docs/screenshots/v2-fleet.png b/docs/screenshots/v2-fleet.png new file mode 100644 index 0000000..9bfac3b Binary files /dev/null and b/docs/screenshots/v2-fleet.png differ diff --git a/docs/screenshots/v2-issuers.png b/docs/screenshots/v2-issuers.png new file mode 100644 index 0000000..34efb0c Binary files /dev/null and b/docs/screenshots/v2-issuers.png differ diff --git a/docs/screenshots/v2-jobs.png b/docs/screenshots/v2-jobs.png new file mode 100644 index 0000000..42c1fa5 Binary files /dev/null and b/docs/screenshots/v2-jobs.png differ diff --git a/docs/screenshots/v2-notifications.png b/docs/screenshots/v2-notifications.png new file mode 100644 index 0000000..3f14847 Binary files /dev/null and b/docs/screenshots/v2-notifications.png differ diff --git a/docs/screenshots/v2-owners.png b/docs/screenshots/v2-owners.png new file mode 100644 index 0000000..f08f822 Binary files /dev/null and b/docs/screenshots/v2-owners.png differ diff --git a/docs/screenshots/v2-policies.png b/docs/screenshots/v2-policies.png new file mode 100644 index 0000000..f00a275 Binary files /dev/null and b/docs/screenshots/v2-policies.png differ diff --git a/docs/screenshots/v2-profiles.png b/docs/screenshots/v2-profiles.png new file mode 100644 index 0000000..ee2bdd1 Binary files /dev/null and b/docs/screenshots/v2-profiles.png differ diff --git a/docs/screenshots/v2-short-lived.png b/docs/screenshots/v2-short-lived.png new file mode 100644 index 0000000..0cf3f30 Binary files /dev/null and b/docs/screenshots/v2-short-lived.png differ diff --git a/docs/screenshots/v2-targets.png b/docs/screenshots/v2-targets.png new file mode 100644 index 0000000..586d249 Binary files /dev/null and b/docs/screenshots/v2-targets.png differ diff --git a/docs/screenshots/v2-teams.png b/docs/screenshots/v2-teams.png new file mode 100644 index 0000000..c3e7dc0 Binary files /dev/null and b/docs/screenshots/v2-teams.png differ diff --git a/docs/testing-guide.md b/docs/testing-guide.md index 724bc99..804b56c 100644 --- a/docs/testing-guide.md +++ b/docs/testing-guide.md @@ -6,7 +6,7 @@ Comprehensive manual testing playbook. Every test has a concrete command, an exp ## Prerequisites -### Why manual QA on top of 900+ automated tests? +### Why manual QA on top of automated tests? Automated tests mock dependencies and run in isolation. Manual QA validates the full integrated stack: real PostgreSQL, real HTTP, real agent binary, real file I/O, real scheduler timing. It catches issues that unit tests can't: migration ordering, Docker networking, env var parsing, browser rendering, and timing-dependent scheduler behavior. @@ -1423,6 +1423,42 @@ curl -s -w "\nHTTP %{http_code}\n" -X POST -H "$AUTH" -H "$CT" \ --- +### 6.2 ACME DNS Challenge Configuration + +**Test 6.2.1 — List ACME issuer with DNS-01 configuration** + +```bash +curl -s -H "$AUTH" "$SERVER/api/v1/issuers/iss-acme-le" | jq '{id, type, config}' +``` + +**What:** Retrieves the ACME Let's Encrypt issuer and verifies its configuration. +**Why:** ACME issuers configured for DNS-01 challenges need their solver scripts accessible for wildcard certificate support. +**Expected:** HTTP 200. `type` = "acme". `config` may include challenge type and DNS script paths. +**PASS if** HTTP 200 and type matches. **FAIL** otherwise. + +--- + +**Test 6.2.2 — Create ACME issuer with DNS-PERSIST-01** + +Edit `deploy/docker-compose.yml` to set environment variables for ACME DNS-PERSIST-01: +- `CERTCTL_ACME_CHALLENGE_TYPE: dns-persist-01` +- `CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN: le.example.com` +- `CERTCTL_ACME_DNS_PRESENT_SCRIPT: /usr/local/bin/dns-present.sh` +- `CERTCTL_ACME_DNS_CLEANUP_SCRIPT: /usr/local/bin/dns-cleanup.sh` + +Restart and verify the issuer accepts the config: + +```bash +curl -s -H "$AUTH" "$SERVER/api/v1/issuers/iss-acme-le" | jq '{id, type}' +``` + +**What:** Verifies that ACME issuers read DNS-PERSIST-01 configuration from environment variables. +**Why:** DNS-PERSIST-01 requires a standing TXT record per IETF draft. The issuer must know the issuer domain and support this challenge type. +**Expected:** HTTP 200. ACME issuer still functional. +**PASS if** HTTP 200 and issuer still works. **FAIL** if 500 or issuer broken. + +--- + ## Part 7: Target Connectors & Deployment **What this validates:** CRUD for deployment targets, including type-specific configuration for all 5 target types. @@ -2368,8 +2404,8 @@ curl -s -H "$AUTH" "$SERVER/api/v1/metrics/prometheus" | grep -c "^# HELP" **What:** Counts `# HELP` comment lines (metric descriptions). **Why:** HELP lines are required by the Prometheus exposition format. Missing = non-compliant. -**Expected:** Count ≥ 11 (one per metric). -**PASS if** count ≥ 11. **FAIL** if 0. +**Expected:** Count > 0 (one per metric). +**PASS if** count > 0. **FAIL** if 0. --- @@ -2380,12 +2416,12 @@ curl -s -H "$AUTH" "$SERVER/api/v1/metrics/prometheus" | grep -c "^# TYPE" ``` **What:** Counts `# TYPE` annotations (gauge/counter declarations). -**Expected:** Count ≥ 11. -**PASS if** count ≥ 11. **FAIL** if 0. +**Expected:** Count > 0. +**PASS if** count > 0. **FAIL** if 0. --- -**Test 13.3.4 — All 11 Prometheus metrics present** +**Test 13.3.4 — All documented Prometheus metrics present** ```bash METRICS=$(curl -s -H "$AUTH" "$SERVER/api/v1/metrics/prometheus") @@ -2395,10 +2431,10 @@ for m in certctl_certificate_total certctl_certificate_active certctl_certificat done ``` -**What:** Verifies all 11 documented Prometheus metrics are present in the output. +**What:** Verifies all documented Prometheus metrics are present in the output. **Why:** Missing metrics mean missing dashboard panels in Grafana. Each metric was chosen for operational value. **Expected:** Each metric reports count = 1 (present). -**PASS if** all 11 metrics show count = 1. **FAIL** if any shows 0. +**PASS if** all metrics show count = 1. **FAIL** if any shows 0. --- @@ -3298,7 +3334,7 @@ Open `http://localhost:8443` in a browser. | 19.6.1 | Sidebar nav | Click all sidebar links | All pages load without errors | PASS if no broken routes | | 19.6.2 | Logout | Click logout | Returns to login screen | PASS if login page shown | | 19.6.3 | 401 redirect | Expire/remove auth token | Auto-redirect to login | PASS if login page shown | -| 19.6.4 | Dark theme | Check page styling | Dark background, readable text | PASS if theme consistent | +| 19.6.4 | Theme consistency | Check page styling | Light content area, teal sidebar, branded colors, readable text | PASS if theme consistent across all pages | --- @@ -3698,36 +3734,38 @@ docker compose logs certctl-server 2>&1 | grep -v "^certctl-server" | grep -cv " **What this validates:** Documentation accuracy against the running system. Claims in docs must match reality. -**Why it matters:** Inaccurate documentation destroys trust. If the README says "21 tables" but there are 19, or "78 MCP tools" but there are 76, evaluators question everything else too. +**Why it matters:** Inaccurate documentation destroys trust. Claims in docs must match the running system. If the README says "X features" but the code doesn't have them, evaluators question everything else too. | Test ID | Document | Verification | Pass/Fail Criteria | |---------|----------|-------------|-------------------| -| 24.1.1 | `README.md` | Feature list matches actual capabilities. Screenshot paths resolve. Mermaid diagram says "21 tables". | PASS if all claims verified | +| 24.1.1 | `README.md` | Feature list matches actual capabilities. Screenshot paths resolve. Mermaid diagram shows database schema tables. | PASS if all claims verified | | 24.1.2 | `docs/quickstart.md` | Every command in the quickstart works on a clean clone. | PASS if all commands succeed | | 24.1.3 | `docs/concepts.md` | Terminology matches API field names and UI labels. | PASS if terminology consistent | -| 24.1.4 | `docs/architecture.md` | Component diagram matches `docker compose ps`. Says "21 tables", "78 MCP Tools", "900+ tests". | PASS if numbers match | -| 24.1.5 | `docs/connectors.md` | All 5 issuer types and 5 target types documented. F5/IIS marked as stubs. | PASS if all documented | -| 24.1.6 | `docs/features.md` | Endpoint count (93), MCP tools (78), table count (21), test count (900+) all accurate. | PASS if numbers match | +| 24.1.4 | `docs/architecture.md` | Component diagram matches `docker compose ps`. Key components and tables documented. | PASS if accurate | +| 24.1.5 | `docs/connectors.md` | All issuer types and target types documented. F5/IIS marked as stubs. | PASS if all documented | +| 24.1.6 | `docs/features.md` | Feature list complete and accurate. | PASS if accurate | | 24.1.7 | `docs/quickstart.md` | Quick start + demo walkthrough works against fresh `docker compose up`. | PASS if all steps work | | 24.1.8 | `docs/demo-advanced.md` | All parts executable against running stack. Network discovery section present. | PASS if all executable | | 24.1.9 | `docs/compliance.md` | Framework links resolve, mapping references real features. | PASS if links work | | 24.1.10 | `docs/compliance-soc2.md` | API endpoints cited actually exist in the router. | PASS if endpoints exist | | 24.1.11 | `docs/compliance-pci-dss.md` | Claims match implementation (audit trail, revocation, key management). | PASS if claims verified | | 24.1.12 | `docs/compliance-nist.md` | Key management claims match agent keygen behavior. | PASS if claims verified | -| 24.1.13 | `docs/mcp.md` | Tool count = 78, domain count = 16, setup instructions work. | PASS if numbers match | -| 24.1.14 | `api/openapi.yaml` | Operation count = 93, matches all routes in router.go. | PASS if count matches | +| 24.1.13 | `docs/mcp.md` | Tool coverage documented, setup instructions work. | PASS if accurate | +| 24.1.14 | `api/openapi.yaml` | OpenAPI spec matches all routes in router.go (check operation count). | PASS if count matches | **Verification command for OpenAPI parity:** ```bash # Count OpenAPI operations -grep -c "operationId:" api/openapi.yaml +OPENAPI_OPS=$(grep -c "operationId:" api/openapi.yaml) # Count router registrations -grep -c "r.Register\|r.mux.Handle" internal/api/router/router.go +ROUTER_REGS=$(grep -c "r.Register\|r.mux.Handle" internal/api/router/router.go) +echo "OpenAPI operations: $OPENAPI_OPS" +echo "Router registrations: $ROUTER_REGS" ``` -**Expected:** Both return 93. -**PASS if** both counts = 93. **FAIL** if mismatch. +**Expected:** Both counts match. +**PASS if** both counts are equal. **FAIL** if mismatch (indicates spec/code drift). --- @@ -3812,14 +3850,14 @@ echo "OpenAPI operations: $(grep -c 'operationId:' api/openapi.yaml)" echo "Router registrations: $(grep -c 'r.Register\|r.mux.Handle' internal/api/router/router.go)" ``` -**What:** Counts operations in the OpenAPI spec and route registrations in the router. -**Why:** The audit found the OpenAPI spec had 78 operations while the router had 93. This was fixed by adding 15 missing operations. -**Expected:** Both = 93. -**PASS if** both equal 93. **FAIL** if mismatch. +**What:** Counts operations in the OpenAPI spec and route registrations in the router, verifying they match. +**Why:** OpenAPI spec drift happens as endpoints are added or removed. Mismatches indicate the spec is out of date. +**Expected:** Both counts equal. +**PASS if** both counts match. **FAIL** if mismatch (indicates spec/code drift). --- -**Test 25.1.5 — Go service tests use strings.Contains, not errors.Is** +**Test 25.1.6 — Go service tests use strings.Contains, not errors.Is** ```bash grep -rn "errors.Is.*errors.New\|errors.Is(.*err.*errors.New" internal/service/*_test.go | wc -l @@ -4104,5 +4142,5 @@ All 26 parts must pass before tagging v2.0.1. | Part 25: Regression Tests | ☐ | | | | | Part 26: EST Server (RFC 7030) | ☐ | | | | -**Automated tests (900+) 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. diff --git a/internal/integration/e2e_test.go b/internal/integration/e2e_test.go index 69e09e7..91656ff 100644 --- a/internal/integration/e2e_test.go +++ b/internal/integration/e2e_test.go @@ -937,6 +937,61 @@ func generateE2ECSRBase64DER(t *testing.T, cn string, sans []string) string { return base64.StdEncoding.EncodeToString(csrDER) } +// TestPrometheusMetrics exercises the Prometheus metrics endpoint (M22). +func TestPrometheusMetrics(t *testing.T) { + server, _, _, _ := setupTestServer(t) + + t.Run("GetPrometheusMetrics_Success", func(t *testing.T) { + resp, err := http.Get(server.URL + "/api/v1/metrics/prometheus") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes)) + } + + // Verify Content-Type contains text/plain + contentType := resp.Header.Get("Content-Type") + if !strings.Contains(contentType, "text/plain") { + t.Errorf("expected Content-Type containing 'text/plain', got %s", contentType) + } + + // Read and verify Prometheus format + body, _ := io.ReadAll(resp.Body) + bodyStr := string(body) + + // Should contain HELP and TYPE lines for metrics + if !strings.Contains(bodyStr, "# HELP") { + t.Error("expected HELP line in Prometheus response") + } + if !strings.Contains(bodyStr, "# TYPE") { + t.Error("expected TYPE line in Prometheus response") + } + + // Should contain metric lines (gauge, counter, uptime) + if !strings.Contains(bodyStr, "certctl_") { + t.Error("expected certctl_ prefixed metrics in response") + } + + t.Logf("Prometheus metrics endpoint working, body size: %d bytes", len(bodyStr)) + }) + + t.Run("GetPrometheusMetrics_MethodNotAllowed", func(t *testing.T) { + resp, err := http.Post(server.URL+"/api/v1/metrics/prometheus", "application/json", nil) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Errorf("expected 405, got %d", resp.StatusCode) + } + }) +} + // TestESTEndpoints exercises the EST (RFC 7030) enrollment endpoints end-to-end (M23). func TestESTEndpoints(t *testing.T) { server, _, _, _ := setupTestServer(t) diff --git a/internal/integration/negative_test.go b/internal/integration/negative_test.go index 82b696c..a5c1edd 100644 --- a/internal/integration/negative_test.go +++ b/internal/integration/negative_test.go @@ -804,4 +804,3 @@ func TestRevocationEndpoints(t *testing.T) { }) } -// mockNetworkScanService is defined in lifecycle_test.go (same package) diff --git a/web/src/assets/certctl-logo.png b/web/src/assets/certctl-logo.png new file mode 100644 index 0000000..5e526e3 Binary files /dev/null and b/web/src/assets/certctl-logo.png differ diff --git a/web/src/components/AuthGate.tsx b/web/src/components/AuthGate.tsx index e3ea8e7..8c02063 100644 --- a/web/src/components/AuthGate.tsx +++ b/web/src/components/AuthGate.tsx @@ -7,10 +7,10 @@ export default function AuthGate({ children }: { children: ReactNode }) { if (loading) { return ( -
+
-

certctl

-

Connecting...

+

certctl

+

Connecting...

); diff --git a/web/src/components/DataTable.tsx b/web/src/components/DataTable.tsx index 698edc4..02f1957 100644 --- a/web/src/components/DataTable.tsx +++ b/web/src/components/DataTable.tsx @@ -20,7 +20,7 @@ interface DataTableProps { export default function DataTable({ columns, data, onRowClick, emptyMessage, isLoading, keyField = 'id', selectable, selectedKeys, onSelectionChange }: DataTableProps) { if (isLoading) { return ( -
+
@@ -32,7 +32,7 @@ export default function DataTable({ columns, data, onRowClick, emptyMessage, if (!data.length) { return ( -
+
{emptyMessage || 'No data found'}
); @@ -62,19 +62,19 @@ export default function DataTable({ columns, data, onRowClick, emptyMessage,
- + {selectable && ( )} {columns.map(col => ( - ))} @@ -88,7 +88,7 @@ export default function DataTable({ columns, data, onRowClick, emptyMessage, onRowClick?.(item)} - className={`border-b border-slate-700/50 transition-colors hover:bg-blue-500/5 ${onRowClick ? 'cursor-pointer' : ''} ${isSelected ? 'bg-blue-500/10' : ''}`} + className={`border-b border-surface-border/50 transition-colors hover:bg-surface-muted ${onRowClick ? 'cursor-pointer' : ''} ${isSelected ? 'bg-brand-50' : ''}`} > {selectable && ( )} {columns.map(col => ( - ))} diff --git a/web/src/components/ErrorBoundary.tsx b/web/src/components/ErrorBoundary.tsx index 797e940..c7feca2 100644 --- a/web/src/components/ErrorBoundary.tsx +++ b/web/src/components/ErrorBoundary.tsx @@ -26,10 +26,10 @@ export default class ErrorBoundary extends Component { render() { if (this.state.hasError) { return ( -
+
-

Something went wrong

-

+

Something went wrong

+

{this.state.error?.message || 'An unexpected error occurred'}

diff --git a/web/src/components/ErrorState.tsx b/web/src/components/ErrorState.tsx index 18b40e9..c88d555 100644 --- a/web/src/components/ErrorState.tsx +++ b/web/src/components/ErrorState.tsx @@ -5,12 +5,12 @@ interface ErrorStateProps { export default function ErrorState({ error, onRetry }: ErrorStateProps) { return ( -
- +
+ -

Failed to load data

-

{error.message}

+

Failed to load data

+

{error.message}

{onRetry && (
- {/* Main content */} -
+ {/* Main content — light background */} +
diff --git a/web/src/components/PageHeader.tsx b/web/src/components/PageHeader.tsx index 5f80a9c..7b9032f 100644 --- a/web/src/components/PageHeader.tsx +++ b/web/src/components/PageHeader.tsx @@ -6,10 +6,10 @@ interface PageHeaderProps { export default function PageHeader({ title, subtitle, action }: PageHeaderProps) { return ( -
+
-

{title}

- {subtitle &&

{subtitle}

} +

{title}

+ {subtitle &&

{subtitle}

}
{action}
diff --git a/web/src/components/StatusBadge.tsx b/web/src/components/StatusBadge.tsx index f42b168..5d0a2ee 100644 --- a/web/src/components/StatusBadge.tsx +++ b/web/src/components/StatusBadge.tsx @@ -1,4 +1,5 @@ const statusStyles: Record = { + // Certificate statuses Active: 'badge-success', Expiring: 'badge-warning', Expired: 'badge-danger', @@ -8,6 +9,8 @@ const statusStyles: Record = { Revoked: 'badge-danger', // Job statuses Pending: 'badge-info', + AwaitingCSR: 'badge-info', + AwaitingApproval: 'badge-info', Running: 'badge-warning', Completed: 'badge-success', Failed: 'badge-danger', diff --git a/web/src/index.css b/web/src/index.css index dd1c20c..5a2a025 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1,30 +1,53 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap'); + @tailwind base; @tailwind components; @tailwind utilities; @layer base { body { - @apply bg-slate-900 text-slate-100 antialiased; + @apply bg-page text-ink antialiased; + font-family: 'Inter', system-ui, -apple-system, sans-serif; } } @layer components { + /* Badges */ .badge { - @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium; + @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold tracking-wide; } - .badge-success { @apply bg-emerald-500/10 text-emerald-400 border border-emerald-500/20; } - .badge-warning { @apply bg-amber-500/10 text-amber-400 border border-amber-500/20; } - .badge-danger { @apply bg-red-500/10 text-red-400 border border-red-500/20; } - .badge-info { @apply bg-blue-500/10 text-blue-400 border border-blue-500/20; } - .badge-neutral { @apply bg-slate-500/10 text-slate-400 border border-slate-500/20; } + .badge-success { @apply bg-emerald-100 text-emerald-700; } + .badge-warning { @apply bg-amber-100 text-amber-700; } + .badge-danger { @apply bg-red-100 text-red-700; } + .badge-info { @apply bg-brand-100 text-brand-700; } + .badge-neutral { @apply bg-slate-100 text-slate-600; } + /* Cards */ .card { - @apply bg-slate-800 border border-slate-700 rounded-lg; + @apply bg-surface border border-surface-border rounded-md shadow-sm; } + /* Buttons */ .btn { - @apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors; + @apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded text-sm font-semibold transition-all duration-150; + } + .btn-primary { @apply bg-brand-500 hover:bg-brand-600 text-white shadow-sm; } + .btn-danger { @apply bg-red-500 hover:bg-red-600 text-white shadow-sm; } + .btn-ghost { @apply text-ink-muted hover:text-ink hover:bg-surface-muted; } + .btn-outline { @apply border border-surface-border text-ink-muted hover:text-ink hover:bg-surface-muted; } + + /* Form inputs */ + .input { + @apply bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink placeholder:text-ink-faint focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 outline-none transition-colors; + } + + /* Monospace data values */ + .mono { + @apply font-mono text-xs; + } + + /* Stat cards with colored top borders */ + .stat-card { + @apply bg-surface border border-surface-border rounded-md shadow-sm p-5 border-t-4; } - .btn-primary { @apply bg-blue-600 hover:bg-blue-500 text-white; } - .btn-ghost { @apply hover:bg-slate-700 text-slate-300; } } diff --git a/web/src/pages/AgentDetailPage.tsx b/web/src/pages/AgentDetailPage.tsx index 77a160b..06bb460 100644 --- a/web/src/pages/AgentDetailPage.tsx +++ b/web/src/pages/AgentDetailPage.tsx @@ -8,9 +8,9 @@ import { formatDateTime, timeAgo } from '../api/utils'; function InfoRow({ label, value }: { label: string; value: React.ReactNode }) { return ( -
- {label} - {value} +
+ {label} + {value}
); } @@ -47,7 +47,7 @@ export default function AgentDetailPage() { return ( <> -
Loading...
+
Loading...
); } @@ -75,8 +75,8 @@ export default function AgentDetailPage() {
{/* Agent Info */} -
-

Agent Details

+
+

Agent Details

} /> {agent.hostname || '—'}} /> {agent.ip_address || '—'}} /> @@ -85,7 +85,7 @@ export default function AgentDetailPage() { agent.last_heartbeat ? ( {timeAgo(agent.last_heartbeat)} - {formatDateTime(agent.last_heartbeat)} + {formatDateTime(agent.last_heartbeat)} ) : '—' } /> @@ -94,15 +94,15 @@ export default function AgentDetailPage() {
{/* System Info */} -
-

System Information

+
+

System Information

{agent.ip_address || '—'}} /> {agent.capabilities?.length ? (
-

Capabilities

+

Capabilities

{agent.capabilities.map((c) => ( {c} @@ -112,7 +112,7 @@ export default function AgentDetailPage() { ) : null} {agent.tags && Object.keys(agent.tags).length > 0 ? (
-

Tags

+

Tags

{Object.entries(agent.tags).map(([k, v]) => ( {k}: {v} @@ -124,20 +124,20 @@ export default function AgentDetailPage() {
{/* Recent Jobs */} -
-

Recent Jobs

+
+

Recent Jobs

{!agentJobs.length ? ( -

No recent jobs

+

No recent jobs

) : (
{agentJobs.map(j => ( -
+
-
{j.type}
-
{j.id}
+
{j.type}
+
{j.id}
- {j.certificate_id} + {j.certificate_id}
@@ -147,16 +147,16 @@ export default function AgentDetailPage() {
{/* Heartbeat Timeline */} -
-

Heartbeat Status

+
+

Heartbeat Status

-

{health}

-

+

{health}

+

{health === 'Online' && 'Agent is responding to heartbeat checks'} {health === 'Stale' && 'Agent has not sent a heartbeat recently'} {health === 'Offline' && 'Agent is not responding'} diff --git a/web/src/pages/AgentFleetPage.tsx b/web/src/pages/AgentFleetPage.tsx index 876e29b..9905765 100644 --- a/web/src/pages/AgentFleetPage.tsx +++ b/web/src/pages/AgentFleetPage.tsx @@ -8,7 +8,7 @@ import type { Agent } from '../api/types'; const OS_COLORS: Record = { linux: '#f97316', - darwin: '#3b82f6', + darwin: '#2ea88f', windows: '#8b5cf6', unknown: '#64748b', }; @@ -53,9 +53,9 @@ function groupAgents(agents: Agent[]): GroupedAgents[] { const CustomTooltip = ({ active, payload }: any) => { if (!active || !payload?.length) return null; return ( -

+
{payload.map((entry: any, i: number) => ( -

+

{entry.name}: {entry.value}

))} @@ -113,25 +113,25 @@ export default function AgentFleetPage() {
{/* Summary Cards */}
-
-

Total Agents

-

{totalAgents}

+
+

Total Agents

+

{totalAgents}

-
-

Online

-

{onlineAgents}

+
+

Online

+

{onlineAgents}

-
-

Offline

-

{offlineAgents}

+
+

Offline

+

{offlineAgents}

{/* Charts */}
{/* OS Distribution */} -
-

OS Distribution

+
+

OS Distribution

{osPieData.length > 0 ? ( @@ -145,14 +145,14 @@ export default function AgentFleetPage() { ) : ( -
No data
+
No data
)}
{/* Status Distribution */} -
-

Status Distribution

+
+

Status Distribution

{statusPieData.length > 0 ? ( @@ -166,33 +166,33 @@ export default function AgentFleetPage() { ) : ( -
No data
+
No data
)}
{/* Version Breakdown */} -
-

Agent Versions

+
+

Agent Versions

{Object.entries(versionCounts) .sort(([, a], [, b]) => b - a) .map(([version, count]) => (
- {version} + {version}
-
+
- {count} + {count}
))} {Object.keys(versionCounts).length === 0 && ( -

No version data

+

No version data

)}
@@ -200,50 +200,50 @@ export default function AgentFleetPage() { {/* Environment Groups */}
-

Fleet by Platform

+

Fleet by Platform

{isLoading ? ( -

Loading fleet data...

+

Loading fleet data...

) : groups.length === 0 ? ( -

No agents registered

+

No agents registered

) : (
{groups.map(group => ( -
-
+
+
-

+

{group.os} / {group.arch}

- + {group.agents.length} agent{group.agents.length !== 1 ? 's' : ''}
- {group.online} online - {group.offline > 0 && {group.offline} offline} + {group.online} online + {group.offline > 0 && {group.offline} offline}
-
+
{group.agents.map(agent => (
navigate(`/agents/${agent.id}`)} - className="px-5 py-3 flex items-center justify-between hover:bg-slate-700/30 cursor-pointer transition-colors" + className="px-5 py-3 flex items-center justify-between hover:bg-surface-muted cursor-pointer transition-colors" >
-
+
-
{agent.name || agent.hostname}
-
{agent.ip_address || agent.id}
+
{agent.name || agent.hostname}
+
{agent.ip_address || agent.id}
{agent.version && ( - {agent.version} + {agent.version} )}
diff --git a/web/src/pages/AgentGroupsPage.tsx b/web/src/pages/AgentGroupsPage.tsx index a4e4a0f..dba3bb9 100644 --- a/web/src/pages/AgentGroupsPage.tsx +++ b/web/src/pages/AgentGroupsPage.tsx @@ -27,10 +27,10 @@ export default function AgentGroupsPage() { label: 'Group', render: (g) => (
-
{g.name}
-
{g.id}
+
{g.name}
+
{g.id}
{g.description && ( -
{g.description}
+
{g.description}
)}
), @@ -51,7 +51,7 @@ export default function AgentGroupsPage() { ))}
) : ( - Manual only + Manual only ); }, }, @@ -63,7 +63,7 @@ export default function AgentGroupsPage() { { key: 'created', label: 'Created', - render: (g) => {formatDateTime(g.created_at)}, + render: (g) => {formatDateTime(g.created_at)}, }, { key: 'actions', @@ -71,7 +71,7 @@ export default function AgentGroupsPage() { render: (g) => ( diff --git a/web/src/pages/AgentsPage.tsx b/web/src/pages/AgentsPage.tsx index 11967ea..1ddeaad 100644 --- a/web/src/pages/AgentsPage.tsx +++ b/web/src/pages/AgentsPage.tsx @@ -31,8 +31,8 @@ export default function AgentsPage() { label: 'Agent', render: (a) => (
-
{a.name}
-
{a.id}
+
{a.name}
+
{a.id}
), }, @@ -41,14 +41,14 @@ export default function AgentsPage() { label: 'Health', render: (a) => , }, - { key: 'hostname', label: 'Hostname', render: (a) => {a.hostname || '—'} }, - { key: 'os', label: 'OS / Arch', render: (a) => {a.os && a.architecture ? `${a.os}/${a.architecture}` : a.os || '—'} }, - { key: 'ip', label: 'IP Address', render: (a) => {a.ip_address || '—'} }, - { key: 'version', label: 'Version', render: (a) => {a.version || '—'} }, + { key: 'hostname', label: 'Hostname', render: (a) => {a.hostname || '—'} }, + { key: 'os', label: 'OS / Arch', render: (a) => {a.os && a.architecture ? `${a.os}/${a.architecture}` : a.os || '—'} }, + { key: 'ip', label: 'IP Address', render: (a) => {a.ip_address || '—'} }, + { key: 'version', label: 'Version', render: (a) => {a.version || '—'} }, { key: 'heartbeat', label: 'Last Heartbeat', - render: (a) => {timeAgo(a.last_heartbeat)}, + render: (a) => {timeAgo(a.last_heartbeat)}, }, ]; diff --git a/web/src/pages/AuditPage.tsx b/web/src/pages/AuditPage.tsx index 0db10a2..7b4e816 100644 --- a/web/src/pages/AuditPage.tsx +++ b/web/src/pages/AuditPage.tsx @@ -9,16 +9,16 @@ import { formatDateTime } from '../api/utils'; import type { AuditEvent } from '../api/types'; const actionColors: Record = { - certificate_created: 'text-emerald-400', - renewal_triggered: 'text-blue-400', - renewal_job_created: 'text-blue-400', - renewal_completed: 'text-emerald-400', - deployment_completed: 'text-emerald-400', - deployment_failed: 'text-red-400', - expiration_alert_sent: 'text-amber-400', - agent_registered: 'text-blue-400', - policy_violated: 'text-red-400', - certificate_revoked: 'text-red-400', + certificate_created: 'text-emerald-600', + renewal_triggered: 'text-brand-500', + renewal_job_created: 'text-brand-500', + renewal_completed: 'text-emerald-600', + deployment_completed: 'text-emerald-600', + deployment_failed: 'text-red-600', + expiration_alert_sent: 'text-amber-600', + agent_registered: 'text-brand-500', + policy_violated: 'text-red-600', + certificate_revoked: 'text-red-600', }; const RESOURCE_TYPES = ['', 'certificate', 'agent', 'job', 'notification', 'policy', 'target', 'issuer']; @@ -94,7 +94,7 @@ export default function AuditPage() { key: 'action', label: 'Action', render: (e) => ( - + {e.action.replace(/_/g, ' ')} ), @@ -104,8 +104,8 @@ export default function AuditPage() { label: 'Actor', render: (e) => (
-
{e.actor}
-
{e.actor_type}
+
{e.actor}
+
{e.actor_type}
), }, @@ -114,8 +114,8 @@ export default function AuditPage() { label: 'Resource', render: (e) => (
-
{e.resource_type}
-
{e.resource_id}
+
{e.resource_type}
+
{e.resource_id}
), }, @@ -123,15 +123,15 @@ export default function AuditPage() { key: 'details', label: 'Details', render: (e) => { - if (!e.details || Object.keys(e.details).length === 0) return ; + if (!e.details || Object.keys(e.details).length === 0) return ; return ( - + {JSON.stringify(e.details).slice(0, 60)} ); }, }, - { key: 'time', label: 'Time', render: (e) => {formatDateTime(e.timestamp)} }, + { key: 'time', label: 'Time', render: (e) => {formatDateTime(e.timestamp)} }, ]; const hasFilters = resourceType || actorFilter || timeRange || actionFilter; @@ -144,21 +144,21 @@ export default function AuditPage() { action={ filtered.length > 0 ? (
- -
) : undefined } /> -
+
setActionFilter(e.target.value)} - className="bg-slate-800 border border-slate-600 rounded px-3 py-1.5 text-xs text-slate-300 placeholder-slate-500 focus:outline-none focus:border-blue-500 w-40" + className="bg-surface border border-surface-border rounded px-3 py-1.5 text-xs text-ink placeholder-ink-faint focus:outline-none focus:border-brand-400 w-40" /> setPolicyId(e.target.value)} - className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200"> + className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink"> {policies?.data?.map(p => ( @@ -204,9 +204,9 @@ function InlinePolicyEditor({ certId, currentPolicyId, currentProfileId }: { cer
- + setDeployTargetId(e.target.value)} - className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 mb-4" + className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink mb-4" > {targets?.data?.map(t => ( @@ -548,22 +548,22 @@ export default function CertificateDetailPage() { {/* Revoke Modal */} {showRevoke && ( -
setShowRevoke(false)}> -
e.stopPropagation()}> -

Revoke Certificate

-

+

setShowRevoke(false)}> +
e.stopPropagation()}> +

Revoke Certificate

+

This action cannot be undone. The certificate will be added to the CRL and marked as revoked.

{revokeMutation.isError && ( -
+
{revokeMutation.error instanceof Error ? revokeMutation.error.message : 'Unknown error'}
)} - + setForm(f => ({ ...f, id: e.target.value }))} - className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-blue-500" + 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="mc-api-prod (auto-generated if empty)" />
- + setForm(f => ({ ...f, common_name: e.target.value }))} - className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-blue-500" + 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.example.com" />
- +
- + setForm(f => ({ ...f, issuer_id: e.target.value }))} - className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-blue-500" + 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="iss-local" />
- + setForm(f => ({ ...f, owner_id: e.target.value }))} - className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-blue-500" + 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="o-alice" />
- + setForm(f => ({ ...f, team_id: e.target.value }))} - className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-blue-500" + 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="t-platform" />
- + setForm(f => ({ ...f, renewal_policy_id: e.target.value }))} - className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-blue-500" + 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="rp-standard" />
@@ -124,27 +124,27 @@ function BulkRevokeModal({ ids, onClose, onSuccess }: { ids: string[]; onClose: }; return ( -
-
e.stopPropagation()}> -

Bulk Revoke

-

+

+
e.stopPropagation()}> +

Bulk Revoke

+

Revoke {ids.length} certificate{ids.length > 1 ? 's' : ''}. This cannot be undone.

- {error &&
{error}
} + {error &&
{error}
} {running && (
-
+
Progress {progress}/{ids.length}
-
+
)} - + setOwnerId(e.target.value)} - className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 mb-4" + className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink mb-4" disabled={running} > @@ -276,8 +276,8 @@ export default function CertificatesPage() { label: 'Certificate', render: (c) => (
-
{c.common_name}
-
{c.id}
+
{c.common_name}
+
{c.id}
), }, @@ -290,14 +290,14 @@ export default function CertificatesPage() { return (
{formatDate(c.expires_at)}
-
{days <= 0 ? 'Expired' : `${days} days`}
+
{days <= 0 ? 'Expired' : `${days} days`}
); }, }, - { key: 'env', label: 'Environment', render: (c) => {c.environment || '—'} }, - { key: 'issuer', label: 'Issuer', render: (c) => {c.issuer_id} }, - { key: 'owner', label: 'Owner', render: (c) => {c.owner_id} }, + { key: 'env', label: 'Environment', render: (c) => {c.environment || '—'} }, + { key: 'issuer', label: 'Issuer', render: (c) => {c.issuer_id} }, + { key: 'owner', label: 'Owner', render: (c) => {c.owner_id} }, ]; const selectedArray = Array.from(selectedIds); @@ -317,8 +317,8 @@ export default function CertificatesPage() { {/* Bulk Action Bar */} {hasSelection && ( -
- {selectedArray.length} selected +
+ {selectedArray.length} selected
@@ -344,18 +344,18 @@ export default function CertificatesPage() { {/* Bulk Renewal Success */} {bulkRenewProgress && !bulkRenewProgress.running && ( -
- +
+ Triggered renewal for {bulkRenewProgress.done} certificate{bulkRenewProgress.done > 1 ? 's' : ''}.
)} -
+
setEnvFilter(e.target.value)} - className="bg-slate-800 border border-slate-600 rounded-lg px-3 py-1.5 text-sm text-slate-300" + className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink" > diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx index dc1db85..952acca 100644 --- a/web/src/pages/DashboardPage.tsx +++ b/web/src/pages/DashboardPage.tsx @@ -19,28 +19,29 @@ const STATUS_COLORS: Record = { Expired: '#ef4444', Revoked: '#8b5cf6', Pending: '#6366f1', - RenewalInProgress: '#3b82f6', + RenewalInProgress: '#2ea88f', Failed: '#f43f5e', Archived: '#64748b', }; function StatCard({ label, value, icon, color }: { label: string; value: string | number; icon: string; color: string }) { - const colorMap: Record = { - success: 'bg-emerald-500/10 text-emerald-400', - warning: 'bg-amber-500/10 text-amber-400', - danger: 'bg-red-500/10 text-red-400', - info: 'bg-blue-500/10 text-blue-400', + const colorMap: Record = { + success: { bg: 'bg-emerald-50', border: 'border-t-emerald-500', text: 'text-emerald-700' }, + warning: { bg: 'bg-amber-50', border: 'border-t-amber-500', text: 'text-amber-700' }, + danger: { bg: 'bg-red-50', border: 'border-t-red-500', text: 'text-red-700' }, + info: { bg: 'bg-blue-50', border: 'border-t-brand-400', text: 'text-brand-500' }, }; + const config = colorMap[color] || colorMap.info; return ( -
-
+
+
-

{label}

-

{value}

+

{label}

+

{value}

); @@ -48,8 +49,8 @@ function StatCard({ label, value, icon, color }: { label: string; value: string function ChartCard({ title, children }: { title: string; children: React.ReactNode }) { return ( -
-

{title}

+
+

{title}

{children}
@@ -60,8 +61,8 @@ function ChartCard({ title, children }: { title: string; children: React.ReactNo const CustomTooltip = ({ active, payload, label }: any) => { if (!active || !payload?.length) return null; return ( -
-

{label}

+
+

{label}

{payload.map((entry: any, i: number) => (

{entry.name}: {typeof entry.value === 'number' && entry.name?.includes('rate') ? `${entry.value.toFixed(1)}%` : entry.value} @@ -159,12 +160,12 @@ export default function DashboardPage() { {value}} + formatter={(value: string) => {value}} /> ) : ( -

No certificate data
+
No certificate data
)} @@ -173,15 +174,15 @@ export default function DashboardPage() { {weeklyExpiration.length > 0 ? ( - - - + + + } /> ) : ( -
No expiration data
+
No expiration data
)}
@@ -193,17 +194,17 @@ export default function DashboardPage() { {(jobTrends || []).length > 0 ? ( - - - + + + } /> - {value}} /> + {value}} /> ) : ( -
No job trend data
+
No job trend data
)} @@ -212,28 +213,28 @@ export default function DashboardPage() { {(issuanceRate || []).length > 0 ? ( - - - + + + } /> - + ) : ( -
No issuance data
+
No issuance data
)}
{/* Expiring Certificates */} -
+
-

Certificates Expiring Soon

- +

Certificates Expiring Soon

+
{!certs?.data?.length ? ( -

No certificates

+

No certificates

) : (
{certs.data @@ -246,17 +247,17 @@ export default function DashboardPage() {
navigate(`/certificates/${c.id}`)} - className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-slate-700/50 cursor-pointer transition-colors" + className="flex items-center justify-between py-2 px-3 rounded hover:bg-surface-muted cursor-pointer transition-colors" >
-
{c.common_name}
-
{c.environment || 'no env'}
+
{c.common_name}
+
{c.environment || 'no env'}
{days <= 0 ? 'Expired' : `${days} days`}
-
{formatDate(c.expires_at)}
+
{formatDate(c.expires_at)}
); @@ -266,20 +267,20 @@ export default function DashboardPage() {
{/* Recent Jobs */} -
+
-

Recent Jobs

- +

Recent Jobs

+
{!jobs?.data?.length ? ( -

No jobs

+

No jobs

) : (
{jobs.data.slice(0, 5).map(j => ( -
+
-
{j.type}
-
{j.certificate_id}
+
{j.type}
+
{j.certificate_id}
@@ -291,10 +292,10 @@ export default function DashboardPage() { {/* Pending Jobs Banner */} {pendingJobs > 0 && ( -
+
-

{pendingJobs} pending job{pendingJobs > 1 ? 's' : ''}

-

Jobs are waiting to be processed

+

{pendingJobs} pending job{pendingJobs > 1 ? 's' : ''}

+

Jobs are waiting to be processed

diff --git a/web/src/pages/IssuersPage.tsx b/web/src/pages/IssuersPage.tsx index 5dc5d36..945e998 100644 --- a/web/src/pages/IssuersPage.tsx +++ b/web/src/pages/IssuersPage.tsx @@ -42,8 +42,8 @@ export default function IssuersPage() { label: 'Issuer', render: (i) => (
-
{i.name}
-
{i.id}
+
{i.name}
+
{i.id}
), }, @@ -63,9 +63,9 @@ export default function IssuersPage() { key: 'config', label: 'Config', render: (i) => { - if (!i.config || Object.keys(i.config).length === 0) return ; + if (!i.config || Object.keys(i.config).length === 0) return ; return ( - + {JSON.stringify(i.config).slice(0, 60)} ); @@ -74,7 +74,7 @@ export default function IssuersPage() { { key: 'created', label: 'Created', - render: (i) => {formatDateTime(i.created_at)}, + render: (i) => {formatDateTime(i.created_at)}, }, { key: 'actions', @@ -84,13 +84,13 @@ export default function IssuersPage() { @@ -103,7 +103,7 @@ export default function IssuersPage() { <> {testResult && ( -
+
{testResult.id}: {testResult.msg}
diff --git a/web/src/pages/JobsPage.tsx b/web/src/pages/JobsPage.tsx index 2d889f7..0560129 100644 --- a/web/src/pages/JobsPage.tsx +++ b/web/src/pages/JobsPage.tsx @@ -35,20 +35,20 @@ export default function JobsPage() { label: 'Job', render: (j) => (
-
{j.id}
-
{j.type}
+
{j.id}
+
{j.type}
), }, { key: 'status', label: 'Status', render: (j) => }, - { key: 'cert', label: 'Certificate', render: (j) => {j.certificate_id} }, + { key: 'cert', label: 'Certificate', render: (j) => {j.certificate_id} }, { key: 'attempts', label: 'Attempts', - render: (j) => {j.attempts}/{j.max_attempts}, + render: (j) => {j.attempts}/{j.max_attempts}, }, - { key: 'scheduled', label: 'Scheduled', render: (j) => {formatDateTime(j.scheduled_at)} }, - { key: 'completed', label: 'Completed', render: (j) => {formatDateTime(j.completed_at)} }, + { key: 'scheduled', label: 'Scheduled', render: (j) => {formatDateTime(j.scheduled_at)} }, + { key: 'completed', label: 'Completed', render: (j) => {formatDateTime(j.completed_at)} }, { key: 'actions', label: '', @@ -68,11 +68,11 @@ export default function JobsPage() { return ( <> -
+
setTypeFilter(e.target.value)} - className="bg-slate-800 border border-slate-600 rounded-lg px-3 py-1.5 text-sm text-slate-300" + className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink" > diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx index 5f33ad2..4c62c2d 100644 --- a/web/src/pages/LoginPage.tsx +++ b/web/src/pages/LoginPage.tsx @@ -24,16 +24,16 @@ export default function LoginPage() { } return ( -
+
-

certctl

-

Certificate Control Plane

+

certctl

+

Certificate Control Plane

-
+
-
{error && ( -
+
{error}
)} @@ -56,13 +56,13 @@ export default function LoginPage() { -

- The API key is set via CERTCTL_AUTH_SECRET on the server. +

+ The API key is set via CERTCTL_AUTH_SECRET on the server.

diff --git a/web/src/pages/NotificationsPage.tsx b/web/src/pages/NotificationsPage.tsx index 30f5aa2..391ec95 100644 --- a/web/src/pages/NotificationsPage.tsx +++ b/web/src/pages/NotificationsPage.tsx @@ -60,7 +60,7 @@ export default function NotificationsPage() { return ( <> -
Loading...
+
Loading...
); } @@ -80,17 +80,17 @@ export default function NotificationsPage() { title="Notifications" subtitle={`${filtered.length} notifications${unreadCount ? ` (${unreadCount} unread)` : ''}`} /> -
-
+
+
@@ -98,7 +98,7 @@ export default function NotificationsPage() { setStatusFilter(e.target.value)} - className="bg-slate-800 border border-slate-600 rounded px-3 py-1.5 text-xs text-slate-300 focus:outline-none focus:border-blue-500" + className="bg-surface border border-surface-border rounded px-3 py-1.5 text-xs text-ink focus:outline-none focus:border-brand-400" > {statuses.map(s => )} @@ -114,7 +114,7 @@ export default function NotificationsPage() { {(typeFilter || statusFilter) && ( @@ -123,15 +123,15 @@ export default function NotificationsPage() {
{viewMode === 'grouped' ? ( grouped.length === 0 ? ( -
No notifications
+
No notifications
) : ( grouped.map(([certId, items]) => (
- + {certId === 'general' ? 'General' : certId} - {items.length} notification{items.length !== 1 ? 's' : ''} + {items.length} notification{items.length !== 1 ? 's' : ''}
{items.map((n) => ( @@ -143,7 +143,7 @@ export default function NotificationsPage() { ) ) : ( filtered.length === 0 ? ( -
No notifications
+
No notifications
) : (
{filtered.map((n) => ( @@ -160,23 +160,23 @@ export default function NotificationsPage() { function NotificationRow({ notification: n, onMarkRead }: { notification: Notification; onMarkRead: () => void }) { const isUnread = n.status === 'Pending' || n.status === 'pending'; return ( -
+
- {n.type.replace(/([A-Z])/g, ' $1').trim()} + {n.type.replace(/([A-Z])/g, ' $1').trim()} - {n.channel} + {n.channel}
-

{n.message || n.subject}

+

{n.message || n.subject}

- {n.recipient} - {timeAgo(n.created_at)} + {n.recipient} + {timeAgo(n.created_at)}
{isUnread && ( diff --git a/web/src/pages/OwnersPage.tsx b/web/src/pages/OwnersPage.tsx index 84e796e..1ea672f 100644 --- a/web/src/pages/OwnersPage.tsx +++ b/web/src/pages/OwnersPage.tsx @@ -35,15 +35,15 @@ export default function OwnersPage() { label: 'Owner', render: (o) => (
-
{o.name}
-
{o.id}
+
{o.name}
+
{o.id}
), }, { key: 'email', label: 'Email', - render: (o) => {o.email || '\u2014'}, + render: (o) => {o.email || '\u2014'}, }, { key: 'team', @@ -51,14 +51,14 @@ export default function OwnersPage() { render: (o) => { const team = teamMap.get(o.team_id); return team - ? {team.name} - : {o.team_id || '\u2014'}; + ? {team.name} + : {o.team_id || '\u2014'}; }, }, { key: 'created', label: 'Created', - render: (o) => {formatDateTime(o.created_at)}, + render: (o) => {formatDateTime(o.created_at)}, }, { key: 'actions', @@ -66,7 +66,7 @@ export default function OwnersPage() { render: (o) => ( diff --git a/web/src/pages/PoliciesPage.tsx b/web/src/pages/PoliciesPage.tsx index 9d76dee..823d942 100644 --- a/web/src/pages/PoliciesPage.tsx +++ b/web/src/pages/PoliciesPage.tsx @@ -15,10 +15,10 @@ const severityStyles: Record = { }; const severityDots: Record = { - low: 'bg-blue-400', - medium: 'bg-amber-400', - high: 'bg-orange-400', - critical: 'bg-red-400', + low: 'bg-emerald-500', + medium: 'bg-amber-500', + high: 'bg-orange-500', + critical: 'bg-red-500', }; export default function PoliciesPage() { @@ -52,12 +52,12 @@ export default function PoliciesPage() { label: 'Rule', render: (p) => (
-
{p.name}
-
{p.id}
+
{p.name}
+
{p.id}
), }, - { key: 'type', label: 'Type', render: (p) => {p.type.replace(/_/g, ' ')} }, + { key: 'type', label: 'Type', render: (p) => {p.type.replace(/_/g, ' ')} }, { key: 'severity', label: 'Severity', @@ -67,9 +67,9 @@ export default function PoliciesPage() { key: 'config', label: 'Config', render: (p) => { - if (!p.config || Object.keys(p.config).length === 0) return ; + if (!p.config || Object.keys(p.config).length === 0) return ; return ( - + {JSON.stringify(p.config).slice(0, 50)} ); @@ -81,20 +81,20 @@ export default function PoliciesPage() { render: (p) => ( ), }, - { key: 'created', label: 'Created', render: (p) => {formatDateTime(p.created_at)} }, + { key: 'created', label: 'Created', render: (p) => {formatDateTime(p.created_at)} }, { key: 'actions', label: '', render: (p) => ( @@ -106,18 +106,18 @@ export default function PoliciesPage() { <> {policies.length > 0 && ( -
+
- Enabled: - {enabledCount} - / - {policies.length} + Enabled: + {enabledCount} + / + {policies.length}
{Object.entries(bySeverity).map(([sev, count]) => (
- {sev} - {count} + {sev} + {count}
))}
diff --git a/web/src/pages/ProfilesPage.tsx b/web/src/pages/ProfilesPage.tsx index 5a7e6e9..4b5e613 100644 --- a/web/src/pages/ProfilesPage.tsx +++ b/web/src/pages/ProfilesPage.tsx @@ -35,10 +35,10 @@ export default function ProfilesPage() { label: 'Profile', render: (p) => (
-
{p.name}
-
{p.id}
+
{p.name}
+
{p.id}
{p.description && ( -
{p.description}
+
{p.description}
)}
), @@ -61,9 +61,9 @@ export default function ProfilesPage() { label: 'Max TTL', render: (p) => (
- {formatTTL(p.max_ttl_seconds)} + {formatTTL(p.max_ttl_seconds)} {p.allow_short_lived && ( - + short-lived )} @@ -76,7 +76,7 @@ export default function ProfilesPage() { render: (p) => (
{(p.allowed_ekus || []).map((eku, i) => ( - {eku} + {eku} ))}
), @@ -86,8 +86,8 @@ export default function ProfilesPage() { label: 'SPIFFE', render: (p) => ( p.spiffe_uri_pattern - ? {p.spiffe_uri_pattern} - : + ? {p.spiffe_uri_pattern} + : ), }, { @@ -98,7 +98,7 @@ export default function ProfilesPage() { { key: 'created', label: 'Created', - render: (p) => {formatDateTime(p.created_at)}, + render: (p) => {formatDateTime(p.created_at)}, }, { key: 'actions', @@ -106,7 +106,7 @@ export default function ProfilesPage() { render: (p) => ( diff --git a/web/src/pages/ShortLivedPage.tsx b/web/src/pages/ShortLivedPage.tsx index 7ed2173..79306c1 100644 --- a/web/src/pages/ShortLivedPage.tsx +++ b/web/src/pages/ShortLivedPage.tsx @@ -19,10 +19,10 @@ function formatTTL(seconds: number): string { function ttlRemaining(expiresAt: string): { text: string; color: string; seconds: number } { const diff = new Date(expiresAt).getTime() - Date.now(); const secs = Math.floor(diff / 1000); - if (secs <= 0) return { text: 'Expired', color: 'text-red-400', seconds: 0 }; - if (secs < 300) return { text: `${secs}s`, color: 'text-red-400', seconds: secs }; - if (secs < 1800) return { text: `${Math.round(secs / 60)}m`, color: 'text-amber-400', seconds: secs }; - return { text: formatTTL(secs), color: 'text-emerald-400', seconds: secs }; + if (secs <= 0) return { text: 'Expired', color: 'text-red-600', seconds: 0 }; + if (secs < 300) return { text: `${secs}s`, color: 'text-red-600', seconds: secs }; + if (secs < 1800) return { text: `${Math.round(secs / 60)}m`, color: 'text-amber-600', seconds: secs }; + return { text: formatTTL(secs), color: 'text-emerald-600', seconds: secs }; } export default function ShortLivedPage() { @@ -75,8 +75,8 @@ export default function ShortLivedPage() { label: 'Certificate', render: (c) => (
-
{c.common_name}
-
{c.id}
+
{c.common_name}
+
{c.id}
), }, @@ -103,15 +103,15 @@ export default function ShortLivedPage() { const profile = profileMap.get(c.certificate_profile_id); return (
-
{profile?.name || c.certificate_profile_id || '—'}
- {profile &&
Max TTL: {formatTTL(profile.max_ttl_seconds)}
} +
{profile?.name || c.certificate_profile_id || '—'}
+ {profile &&
Max TTL: {formatTTL(profile.max_ttl_seconds)}
}
); }, }, - { key: 'env', label: 'Environment', render: (c) => {c.environment || '—'} }, - { key: 'issuer', label: 'Issuer', render: (c) => {c.issuer_id} }, - { key: 'expires', label: 'Expires At', render: (c) => {formatDateTime(c.expires_at)} }, + { key: 'env', label: 'Environment', render: (c) => {c.environment || '—'} }, + { key: 'issuer', label: 'Issuer', render: (c) => {c.issuer_id} }, + { key: 'expires', label: 'Expires At', render: (c) => {formatDateTime(c.expires_at)} }, ]; return ( @@ -121,21 +121,21 @@ export default function ShortLivedPage() { subtitle={`${shortLivedCerts.length} active ephemeral certificates`} /> {/* Stats bar */} -
+
-
- Active: - {active} +
+ Active: + {active}
-
- Expired: - {expired} +
+ Expired: + {expired}
-
- Profiles: - {profiles.size} +
+ Profiles: + {profiles.size}
diff --git a/web/src/pages/TargetsPage.tsx b/web/src/pages/TargetsPage.tsx index b4c84a2..8538a32 100644 --- a/web/src/pages/TargetsPage.tsx +++ b/web/src/pages/TargetsPage.tsx @@ -81,8 +81,8 @@ function CreateTargetWizard({ onClose, onSuccess }: { onClose: () => void; onSuc const canProceedToReview = name && targetType && fields.filter(f => f.required).every(f => config[f.key]); return ( -
-
e.stopPropagation()}> +
+
e.stopPropagation()}> {/* Step indicators */}
{['Select Type', 'Configure', 'Review'].map((label, i) => { @@ -93,36 +93,36 @@ function CreateTargetWizard({ onClose, onSuccess }: { onClose: () => void; onSuc return (
{isDone ? '✓' : i + 1}
- {label} - {i < 2 &&
} + {label} + {i < 2 &&
}
); })}
- {error &&
{error}
} + {error &&
{error}
} {/* Step 1: Select Type */} {step === 'type' && (
-

Select Target Type

+

Select Target Type

{TARGET_TYPES.map(t => ( ))}
@@ -137,35 +137,35 @@ function CreateTargetWizard({ onClose, onSuccess }: { onClose: () => void; onSuc {/* Step 2: Configure */} {step === 'config' && (
-

+

Configure {typeLabels[targetType] || targetType} Target

- + setName(e.target.value)} - className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-blue-500" + 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" placeholder="web-server-1" />
- + setHostname(e.target.value)} - className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-blue-500" + 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" placeholder="web1.example.com" />
- + setAgentId(e.target.value)} - className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-blue-500" + 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" placeholder="agent-web1" />
{fields.map(f => (
- + setConfig(c => ({ ...c, [f.key]: e.target.value }))} - className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-blue-500" + 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" placeholder={f.placeholder} />
))} @@ -184,32 +184,32 @@ function CreateTargetWizard({ onClose, onSuccess }: { onClose: () => void; onSuc {/* Step 3: Review */} {step === 'review' && (
-

Review Target

-
+

Review Target

+
- Name - {name} + Name + {name}
- Type - {typeLabels[targetType] || targetType} + Type + {typeLabels[targetType] || targetType}
{hostname && (
- Hostname - {hostname} + Hostname + {hostname}
)} {agentId && (
- Agent - {agentId} + Agent + {agentId}
)} {Object.entries(config).filter(([, v]) => v).map(([k, v]) => (
- {k.replace(/_/g, ' ')} - {v} + {k.replace(/_/g, ' ')} + {v}
))}
@@ -250,8 +250,8 @@ export default function TargetsPage() { label: 'Target', render: (t) => (
-
{t.name}
-
{t.id}
+
{t.name}
+
{t.id}
), }, @@ -265,12 +265,12 @@ export default function TargetsPage() { { key: 'hostname', label: 'Hostname', - render: (t) => {t.hostname || '\u2014'}, + render: (t) => {t.hostname || '\u2014'}, }, { key: 'agent', label: 'Agent', - render: (t) => {t.agent_id || '\u2014'}, + render: (t) => {t.agent_id || '\u2014'}, }, { key: 'status', @@ -280,7 +280,7 @@ export default function TargetsPage() { { key: 'created', label: 'Created', - render: (t) => {formatDateTime(t.created_at)}, + render: (t) => {formatDateTime(t.created_at)}, }, { key: 'actions', @@ -288,7 +288,7 @@ export default function TargetsPage() { render: (t) => ( diff --git a/web/src/pages/TeamsPage.tsx b/web/src/pages/TeamsPage.tsx index 5ad4747..97ca414 100644 --- a/web/src/pages/TeamsPage.tsx +++ b/web/src/pages/TeamsPage.tsx @@ -27,8 +27,8 @@ export default function TeamsPage() { label: 'Team', render: (t) => (
-
{t.name}
-
{t.id}
+
{t.name}
+
{t.id}
), }, @@ -36,13 +36,13 @@ export default function TeamsPage() { key: 'description', label: 'Description', render: (t) => ( - {t.description || '\u2014'} + {t.description || '\u2014'} ), }, { key: 'created', label: 'Created', - render: (t) => {formatDateTime(t.created_at)}, + render: (t) => {formatDateTime(t.created_at)}, }, { key: 'actions', @@ -50,7 +50,7 @@ export default function TeamsPage() { render: (t) => ( diff --git a/web/tailwind.config.cjs b/web/tailwind.config.cjs index 7e045bd..cc2d9f1 100644 --- a/web/tailwind.config.cjs +++ b/web/tailwind.config.cjs @@ -6,7 +6,59 @@ module.exports = { ], darkMode: 'class', theme: { - extend: {}, + extend: { + colors: { + // === certctl brand palette (from logo) === + brand: { + 50: '#eefbf6', + 100: '#d5f5e9', + 200: '#afe9d5', + 300: '#7ad8bc', + 400: '#2ea88f', // Primary teal — logo "ctl" + 500: '#1f9680', + 600: '#147868', + 700: '#106055', + 800: '#0f4d44', + 900: '#0d3f39', + }, + accent: { + blue: '#3b7dd8', // Logo blue arrows + orange: '#e8873a', // Logo orange arrows + green: '#4ebe6e', // Logo green highlights + }, + // Light content area + page: '#f0f4f8', // Light blue-gray page background + surface: { + DEFAULT: '#ffffff', // Cards — white + hover: '#f8fafc', // Hover on cards + border: '#e2e8f0', // Card/table borders + muted: '#f1f5f9', // Zebra stripes, subtle fills + }, + // Dark sidebar + sidebar: { + DEFAULT: '#0c2e25', // Deep teal-black + hover: '#134438', + active: '#185c4a', + border: '#1a5c48', + text: '#94d2be', // Muted teal for inactive nav + }, + // Text on light backgrounds + ink: { + DEFAULT: '#1e293b', // Primary text + muted: '#64748b', // Secondary text + faint: '#94a3b8', // Tertiary/placeholder + }, + }, + fontFamily: { + mono: ['JetBrains Mono', 'ui-monospace', 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', 'monospace'], + }, + borderRadius: { + DEFAULT: '0.375rem', + sm: '0.25rem', + md: '0.5rem', + lg: '0.75rem', + }, + }, }, plugins: [], }
+ {col.label}
@@ -97,12 +97,12 @@ export default function DataTable({ columns, data, onRowClick, emptyMessage, checked={isSelected || false} onChange={(e) => { e.stopPropagation(); toggleOne(rowKey); }} onClick={(e) => e.stopPropagation()} - className="rounded border-slate-600 bg-slate-900 text-blue-600 focus:ring-blue-500 focus:ring-offset-0 cursor-pointer" + className="rounded border-surface-border bg-white text-brand-500 focus:ring-brand-500 focus:ring-offset-0 cursor-pointer" /> + {col.render(item)}