mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:01:32 +00:00
9e6756d02f
Backend hardening: - Fix 6 nginx.go non-constant format string build errors - Add validation.go with hostname, PEM, and enum validators - Apply input validation to all POST/PUT handlers (certificates, agents, CSR, policies, teams, owners, targets, issuers) - Fix unchecked JSON decode in TriggerDeployment handler Frontend (Vite + React + TypeScript): - Migrate from single-file SPA to proper build pipeline - 7 pages: Dashboard, Certificates (list+detail), Agents, Jobs, Notifications, Policies, Audit Trail - TanStack Query for server state with auto-refetch intervals - Certificate detail with version history and renewal trigger - Job cancellation, status/type filtering, expiry countdowns - Reusable components: DataTable, StatusBadge, ErrorState, PageHeader - Dark theme with Tailwind CSS, sidebar nav via React Router Server integration: - Go server serves web/dist/ (Vite output) with SPA fallback - Falls back to web/index.html for legacy mode - .gitignore updated for web/node_modules/ and web/dist/ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1869 lines
88 KiB
Plaintext
1869 lines
88 KiB
Plaintext
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>certctl - Certificate Control Plane</title>
|
|
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
|
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<style>
|
|
:root {
|
|
--bg-primary: #0f172a;
|
|
--bg-secondary: #1e293b;
|
|
--bg-tertiary: #334155;
|
|
--text-primary: #f1f5f9;
|
|
--text-secondary: #cbd5e1;
|
|
--border: #475569;
|
|
--accent: #3b82f6;
|
|
--accent-hover: #2563eb;
|
|
--success: #10b981;
|
|
--warning: #f59e0b;
|
|
--danger: #ef4444;
|
|
}
|
|
|
|
* {
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
background-color: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
.app-container {
|
|
display: flex;
|
|
height: 100vh;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.sidebar {
|
|
width: 256px;
|
|
background-color: var(--bg-secondary);
|
|
border-right: 1px solid var(--border);
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding: 1.5rem;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.sidebar-logo {
|
|
margin-bottom: 2rem;
|
|
border-bottom: 1px solid var(--border);
|
|
padding-bottom: 1rem;
|
|
}
|
|
|
|
.sidebar-logo h1 {
|
|
font-size: 1.5rem;
|
|
font-weight: 700;
|
|
margin: 0 0 0.25rem 0;
|
|
color: var(--accent);
|
|
}
|
|
|
|
.sidebar-logo p {
|
|
font-size: 0.75rem;
|
|
color: var(--text-secondary);
|
|
margin: 0;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
|
|
.nav-items {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.nav-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
padding: 0.75rem 1rem;
|
|
border-radius: 0.5rem;
|
|
cursor: pointer;
|
|
color: var(--text-secondary);
|
|
background-color: transparent;
|
|
border: none;
|
|
font-size: 0.9rem;
|
|
transition: all 0.2s ease;
|
|
width: 100%;
|
|
text-align: left;
|
|
}
|
|
|
|
.nav-item:hover {
|
|
background-color: var(--bg-tertiary);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.nav-item.active {
|
|
background-color: var(--accent);
|
|
color: white;
|
|
}
|
|
|
|
.nav-item svg {
|
|
width: 18px;
|
|
height: 18px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.nav-section-title {
|
|
font-size: 0.7rem;
|
|
font-weight: 600;
|
|
color: var(--text-secondary);
|
|
margin-top: 1.5rem;
|
|
margin-bottom: 0.75rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
padding: 0 1rem;
|
|
}
|
|
|
|
.main-content {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
background-color: var(--bg-primary);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.top-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 1rem 1.5rem;
|
|
border-bottom: 1px solid var(--border);
|
|
background-color: var(--bg-secondary);
|
|
height: 64px;
|
|
}
|
|
|
|
.top-bar h2 {
|
|
font-size: 1.25rem;
|
|
font-weight: 600;
|
|
margin: 0;
|
|
}
|
|
|
|
.demo-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.25rem 0.75rem;
|
|
background-color: rgba(248, 113, 113, 0.1);
|
|
color: #fca5a5;
|
|
border: 1px solid rgba(248, 113, 113, 0.3);
|
|
border-radius: 0.375rem;
|
|
font-size: 0.75rem;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.content-area {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 1.5rem;
|
|
}
|
|
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 1rem;
|
|
margin-bottom: 2rem;
|
|
}
|
|
|
|
.stat-card {
|
|
background-color: var(--bg-secondary);
|
|
border: 1px solid var(--border);
|
|
border-radius: 0.5rem;
|
|
padding: 1.5rem;
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 1rem;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.stat-card:hover {
|
|
border-color: var(--accent);
|
|
box-shadow: 0 0 20px rgba(59, 130, 246, 0.1);
|
|
}
|
|
|
|
.stat-icon {
|
|
width: 40px;
|
|
height: 40px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border-radius: 0.375rem;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.stat-icon.success { background-color: rgba(16, 185, 129, 0.1); color: var(--success); }
|
|
.stat-icon.warning { background-color: rgba(245, 158, 11, 0.1); color: var(--warning); }
|
|
.stat-icon.danger { background-color: rgba(239, 68, 68, 0.1); color: var(--danger); }
|
|
.stat-icon.info { background-color: rgba(59, 130, 246, 0.1); color: var(--accent); }
|
|
|
|
.stat-content h3 {
|
|
margin: 0;
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
color: var(--text-secondary);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
|
|
.stat-content p {
|
|
margin: 0.5rem 0 0 0;
|
|
font-size: 2rem;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.card {
|
|
background-color: var(--bg-secondary);
|
|
border: 1px solid var(--border);
|
|
border-radius: 0.5rem;
|
|
padding: 1.5rem;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.card-title {
|
|
font-size: 1rem;
|
|
font-weight: 600;
|
|
margin: 0 0 1.5rem 0;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.card-title svg {
|
|
width: 20px;
|
|
height: 20px;
|
|
color: var(--accent);
|
|
}
|
|
|
|
.table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.table thead {
|
|
border-bottom: 2px solid var(--border);
|
|
}
|
|
|
|
.table th {
|
|
padding: 1rem;
|
|
text-align: left;
|
|
font-weight: 600;
|
|
color: var(--text-secondary);
|
|
text-transform: uppercase;
|
|
font-size: 0.75rem;
|
|
letter-spacing: 0.05em;
|
|
background-color: var(--bg-primary);
|
|
position: sticky;
|
|
top: 0;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
}
|
|
|
|
.table th:hover {
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.table tbody tr {
|
|
border-bottom: 1px solid var(--border);
|
|
transition: background-color 0.2s ease;
|
|
}
|
|
|
|
.table tbody tr:hover {
|
|
background-color: rgba(59, 130, 246, 0.05);
|
|
}
|
|
|
|
.table td {
|
|
padding: 1rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.table td:first-child {
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
padding: 0.25rem 0.75rem;
|
|
border-radius: 0.375rem;
|
|
font-size: 0.75rem;
|
|
font-weight: 500;
|
|
text-transform: uppercase;
|
|
gap: 0.375rem;
|
|
}
|
|
|
|
.badge-success { background-color: rgba(16, 185, 129, 0.1); color: var(--success); }
|
|
.badge-warning { background-color: rgba(245, 158, 11, 0.1); color: var(--warning); }
|
|
.badge-danger { background-color: rgba(239, 68, 68, 0.1); color: var(--danger); }
|
|
.badge-info { background-color: rgba(59, 130, 246, 0.1); color: var(--accent); }
|
|
.badge-gray { background-color: rgba(148, 163, 184, 0.1); color: #94a3b8; }
|
|
|
|
.badge-dot {
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 50%;
|
|
background-color: currentColor;
|
|
}
|
|
|
|
.btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.5rem 1rem;
|
|
border: none;
|
|
border-radius: 0.375rem;
|
|
font-weight: 500;
|
|
font-size: 0.875rem;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.btn-primary {
|
|
background-color: var(--accent);
|
|
color: white;
|
|
}
|
|
|
|
.btn-primary:hover {
|
|
background-color: var(--accent-hover);
|
|
}
|
|
|
|
.btn-secondary {
|
|
background-color: var(--bg-tertiary);
|
|
color: var(--text-primary);
|
|
border: 1px solid var(--border);
|
|
}
|
|
|
|
.btn-secondary:hover {
|
|
background-color: rgba(59, 130, 246, 0.1);
|
|
border-color: var(--accent);
|
|
}
|
|
|
|
.btn-sm {
|
|
padding: 0.375rem 0.75rem;
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.btn svg {
|
|
width: 16px;
|
|
height: 16px;
|
|
}
|
|
|
|
.chart-bar {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.chart-bar-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.chart-bar-label {
|
|
width: 100px;
|
|
font-size: 0.875rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.chart-bar-track {
|
|
flex: 1;
|
|
height: 32px;
|
|
background-color: var(--bg-primary);
|
|
border-radius: 0.375rem;
|
|
overflow: hidden;
|
|
border: 1px solid var(--border);
|
|
}
|
|
|
|
.chart-bar-fill {
|
|
height: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
padding-right: 0.75rem;
|
|
color: white;
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.fill-7d { background: linear-gradient(90deg, rgba(16, 185, 129, 0.3) 0%, rgba(16, 185, 129, 0.8) 100%); }
|
|
.fill-14d { background: linear-gradient(90deg, rgba(34, 197, 94, 0.3) 0%, rgba(34, 197, 94, 0.8) 100%); }
|
|
.fill-30d { background: linear-gradient(90deg, rgba(245, 158, 11, 0.3) 0%, rgba(245, 158, 11, 0.8) 100%); }
|
|
.fill-60d { background: linear-gradient(90deg, rgba(249, 115, 22, 0.3) 0%, rgba(249, 115, 22, 0.8) 100%); }
|
|
.fill-90d { background: linear-gradient(90deg, rgba(239, 68, 68, 0.3) 0%, rgba(239, 68, 68, 0.8) 100%); }
|
|
|
|
.activity-item {
|
|
display: flex;
|
|
gap: 1rem;
|
|
padding-bottom: 1rem;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
|
|
.activity-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.activity-icon {
|
|
width: 32px;
|
|
height: 32px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border-radius: 0.375rem;
|
|
background-color: var(--bg-primary);
|
|
color: var(--accent);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.activity-content h4 {
|
|
margin: 0;
|
|
font-size: 0.9rem;
|
|
font-weight: 500;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.activity-content p {
|
|
margin: 0.25rem 0 0 0;
|
|
font-size: 0.8rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.modal-overlay {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background-color: rgba(0, 0, 0, 0.5);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 1000;
|
|
}
|
|
|
|
.modal {
|
|
background-color: var(--bg-secondary);
|
|
border: 1px solid var(--border);
|
|
border-radius: 0.5rem;
|
|
max-width: 800px;
|
|
width: 90%;
|
|
max-height: 90vh;
|
|
overflow-y: auto;
|
|
padding: 2rem;
|
|
}
|
|
|
|
.modal-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 1.5rem;
|
|
border-bottom: 1px solid var(--border);
|
|
padding-bottom: 1rem;
|
|
}
|
|
|
|
.modal-header h2 {
|
|
margin: 0;
|
|
font-size: 1.25rem;
|
|
}
|
|
|
|
.modal-close {
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
padding: 0.5rem;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.modal-close:hover {
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.input-group {
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.input-group label {
|
|
display: block;
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
margin-bottom: 0.5rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.input, .select {
|
|
width: 100%;
|
|
padding: 0.75rem;
|
|
background-color: var(--bg-primary);
|
|
border: 1px solid var(--border);
|
|
border-radius: 0.375rem;
|
|
color: var(--text-primary);
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.input:focus, .select:focus {
|
|
outline: none;
|
|
border-color: var(--accent);
|
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
|
}
|
|
|
|
.tabs {
|
|
display: flex;
|
|
gap: 1rem;
|
|
border-bottom: 1px solid var(--border);
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.tab {
|
|
padding: 0.75rem 1rem;
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
border-bottom: 2px solid transparent;
|
|
transition: all 0.2s ease;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.tab:hover {
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.tab.active {
|
|
color: var(--accent);
|
|
border-bottom-color: var(--accent);
|
|
}
|
|
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: 3rem 1rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.empty-state svg {
|
|
width: 48px;
|
|
height: 48px;
|
|
margin-bottom: 1rem;
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.loading {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 2rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.spinner {
|
|
width: 24px;
|
|
height: 24px;
|
|
border: 2px solid var(--border);
|
|
border-top-color: var(--accent);
|
|
border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
.countdown {
|
|
font-weight: 600;
|
|
}
|
|
|
|
.countdown.warning {
|
|
color: var(--warning);
|
|
}
|
|
|
|
.countdown.danger {
|
|
color: var(--danger);
|
|
}
|
|
|
|
.status-dot {
|
|
display: inline-block;
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
margin-right: 0.5rem;
|
|
}
|
|
|
|
.status-dot.online {
|
|
background-color: var(--success);
|
|
animation: pulse 2s infinite;
|
|
}
|
|
|
|
.status-dot.offline {
|
|
background-color: var(--danger);
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.5; }
|
|
}
|
|
|
|
.breadcrumb {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
margin-bottom: 1.5rem;
|
|
font-size: 0.9rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.breadcrumb button {
|
|
background: none;
|
|
border: none;
|
|
color: var(--accent);
|
|
cursor: pointer;
|
|
padding: 0;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.breadcrumb button:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.metadata-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
|
gap: 1.5rem;
|
|
}
|
|
|
|
.metadata-item {
|
|
border: 1px solid var(--border);
|
|
border-radius: 0.375rem;
|
|
padding: 1rem;
|
|
background-color: var(--bg-primary);
|
|
}
|
|
|
|
.metadata-item label {
|
|
display: block;
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
color: var(--text-secondary);
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.metadata-item p {
|
|
margin: 0;
|
|
word-break: break-all;
|
|
}
|
|
|
|
.deployment-item {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 1rem;
|
|
background-color: var(--bg-primary);
|
|
border: 1px solid var(--border);
|
|
border-radius: 0.375rem;
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
|
|
.deployment-item-info h4 {
|
|
margin: 0;
|
|
font-weight: 500;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.deployment-item-info p {
|
|
margin: 0.25rem 0 0 0;
|
|
font-size: 0.8rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.filter-controls {
|
|
display: flex;
|
|
gap: 1rem;
|
|
margin-bottom: 1.5rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.filter-controls > div {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.filter-controls label {
|
|
font-size: 0.875rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.filter-controls .select {
|
|
min-width: 150px;
|
|
}
|
|
|
|
.search-input {
|
|
flex: 1;
|
|
min-width: 250px;
|
|
}
|
|
|
|
.pagination {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 1rem;
|
|
margin-top: 1.5rem;
|
|
}
|
|
|
|
.pagination button {
|
|
padding: 0.5rem 0.75rem;
|
|
background-color: var(--bg-tertiary);
|
|
color: var(--text-primary);
|
|
border: 1px solid var(--border);
|
|
border-radius: 0.375rem;
|
|
cursor: pointer;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.pagination button:hover:not(:disabled) {
|
|
background-color: var(--accent);
|
|
border-color: var(--accent);
|
|
}
|
|
|
|
.pagination button:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.pagination span {
|
|
font-size: 0.875rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.detail-actions {
|
|
display: flex;
|
|
gap: 1rem;
|
|
margin-top: 2rem;
|
|
padding-top: 1.5rem;
|
|
border-top: 1px solid var(--border);
|
|
}
|
|
|
|
.section-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.section-header h3 {
|
|
margin: 0;
|
|
font-size: 1rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
::-webkit-scrollbar {
|
|
width: 8px;
|
|
height: 8px;
|
|
}
|
|
|
|
::-webkit-scrollbar-track {
|
|
background-color: var(--bg-primary);
|
|
}
|
|
|
|
::-webkit-scrollbar-thumb {
|
|
background-color: var(--border);
|
|
border-radius: 4px;
|
|
}
|
|
|
|
::-webkit-scrollbar-thumb:hover {
|
|
background-color: var(--bg-tertiary);
|
|
}
|
|
|
|
.toast {
|
|
position: fixed;
|
|
bottom: 1.5rem;
|
|
right: 1.5rem;
|
|
background-color: var(--bg-secondary);
|
|
border: 1px solid var(--border);
|
|
border-radius: 0.375rem;
|
|
padding: 1rem 1.5rem;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
z-index: 2000;
|
|
animation: slideIn 0.3s ease;
|
|
}
|
|
|
|
@keyframes slideIn {
|
|
from {
|
|
transform: translateX(400px);
|
|
opacity: 0;
|
|
}
|
|
to {
|
|
transform: translateX(0);
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
.toggle {
|
|
position: relative;
|
|
width: 40px;
|
|
height: 24px;
|
|
background-color: var(--bg-tertiary);
|
|
border-radius: 9999px;
|
|
cursor: pointer;
|
|
border: 1px solid var(--border);
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.toggle.active {
|
|
background-color: var(--success);
|
|
border-color: var(--success);
|
|
}
|
|
|
|
.toggle-knob {
|
|
position: absolute;
|
|
top: 2px;
|
|
left: 2px;
|
|
width: 20px;
|
|
height: 20px;
|
|
background-color: white;
|
|
border-radius: 50%;
|
|
transition: left 0.2s ease;
|
|
}
|
|
|
|
.toggle.active .toggle-knob {
|
|
left: 18px;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="root"></div>
|
|
|
|
<script type="text/babel">
|
|
const { useState, useEffect, useRef } = React;
|
|
|
|
// Lucide icon components
|
|
const Icon = ({ name, size = 24, ...props }) => {
|
|
const svgProps = { width: size, height: size, fill: 'none', stroke: 'currentColor', strokeWidth: 2, ...props };
|
|
|
|
const icons = {
|
|
'home': <svg viewBox="0 0 24 24" {...svgProps}><path d="M3 12l9-9 9 9v11h-6v-5h-6v5H3z"/></svg>,
|
|
'certificate': <svg viewBox="0 0 24 24" {...svgProps}><path d="M21 16V8c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v8c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2z"/><path d="M3 10h18"/><path d="M9 17l-3 3m6-3l3 3"/></svg>,
|
|
'shield': <svg viewBox="0 0 24 24" {...svgProps}><path d="M12 22s-8-4-8-10V5l8-3 8 3v7c0 6-8 10-8 10z"/></svg>,
|
|
'target': <svg viewBox="0 0 24 24" {...svgProps}><circle cx="12" cy="12" r="1"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="9"/></svg>,
|
|
'users': <svg viewBox="0 0 24 24" {...svgProps}><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>,
|
|
'activity': <svg viewBox="0 0 24 24" {...svgProps}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>,
|
|
'zap': <svg viewBox="0 0 24 24" {...svgProps}><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>,
|
|
'alert-circle': <svg viewBox="0 0 24 24" {...svgProps}><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>,
|
|
'check-circle': <svg viewBox="0 0 24 24" {...svgProps}><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>,
|
|
'clock': <svg viewBox="0 0 24 24" {...svgProps}><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>,
|
|
'settings': <svg viewBox="0 0 24 24" {...svgProps}><circle cx="12" cy="12" r="3"/><path d="M12 1v6m0 6v6M4.22 4.22l4.24 4.24m5.08 5.08l4.24 4.24M1 12h6m6 0h6M4.22 19.78l4.24-4.24m5.08-5.08l4.24-4.24"/></svg>,
|
|
'server': <svg viewBox="0 0 24 24" {...svgProps}><rect x="2" y="2" width="20" height="8"/><rect x="2" y="14" width="20" height="8"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>,
|
|
'database': <svg viewBox="0 0 24 24" {...svgProps}><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5c0 1.66 4 3 9 3s9-1.34 9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/><line x1="3" y1="12" x2="21" y2="12"/></svg>,
|
|
'file-text': <svg viewBox="0 0 24 24" {...svgProps}><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="12" y1="13" x2="8" y2="13"/><line x1="12" y1="17" x2="8" y2="17"/></svg>,
|
|
'plus': <svg viewBox="0 0 24 24" {...svgProps}><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>,
|
|
'refresh-cw': <svg viewBox="0 0 24 24" {...svgProps}><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36M20.49 15a9 9 0 0 1-14.85 3.36"/></svg>,
|
|
'deployment': <svg viewBox="0 0 24 24" {...svgProps}><path d="M16 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V8l-5-5z"/><polyline points="14 3 14 8 19 8"/></svg>,
|
|
'x': <svg viewBox="0 0 24 24" {...svgProps}><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>,
|
|
'search': <svg viewBox="0 0 24 24" {...svgProps}><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>,
|
|
'chevron-right': <svg viewBox="0 0 24 24" {...svgProps}><polyline points="9 18 15 12 9 6"/></svg>,
|
|
'eye': <svg viewBox="0 0 24 24" {...svgProps}><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>,
|
|
'link': <svg viewBox="0 0 24 24" {...svgProps}><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>,
|
|
'trash-2': <svg viewBox="0 0 24 24" {...svgProps}><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>,
|
|
'download': <svg viewBox="0 0 24 24" {...svgProps}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>,
|
|
'alert-triangle': <svg viewBox="0 0 24 24" {...svgProps}><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3.05h16.94a2 2 0 0 0 1.71-3.05L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>,
|
|
'info': <svg viewBox="0 0 24 24" {...svgProps}><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>,
|
|
};
|
|
|
|
return icons[name] || <svg viewBox="0 0 24 24" {...svgProps}><circle cx="12" cy="12" r="10"/></svg>;
|
|
};
|
|
|
|
// Mock data
|
|
const mockCertificates = [
|
|
{ id: '1', name: 'api.example.com', commonName: 'api.example.com', sans: ['*.api.example.com'], status: 'Active', environment: 'prod', owner: 'Alice Smith', team: 'Platform', issuer: 'Let\'s Encrypt', expiresAt: '2026-06-15', issuerType: 'ACME', deployments: ['nginx-prod-1', 'nginx-prod-2'], renewalSuccess: true },
|
|
{ id: '2', name: 'dashboard.internal', commonName: 'dashboard.internal', sans: [], status: 'Expiring', environment: 'prod', owner: 'Bob Johnson', team: 'DevOps', issuer: 'Internal CA', expiresAt: '2026-03-25', issuerType: 'Local', deployments: ['f5-load-balancer'], renewalSuccess: false },
|
|
{ id: '3', name: 'cdn.example.com', commonName: '*.cdn.example.com', sans: ['cdn.example.com'], status: 'Active', environment: 'prod', owner: 'Carol White', team: 'Infrastructure', issuer: 'DigiCert', expiresAt: '2027-01-10', issuerType: 'GenericCA', deployments: ['cdn-edge-1', 'cdn-edge-2', 'cdn-edge-3'], renewalSuccess: true },
|
|
{ id: '4', name: 'legacy-app', commonName: 'legacy.old.internal', sans: [], status: 'Expired', environment: 'legacy', owner: 'Dave Miller', team: 'Legacy', issuer: 'Internal CA', expiresAt: '2025-12-01', issuerType: 'Local', deployments: ['iis-legacy-1'], renewalSuccess: false },
|
|
{ id: '5', name: 'staging.example.com', commonName: 'staging.example.com', sans: ['*.staging.example.com'], status: 'Active', environment: 'staging', owner: 'Emma Davis', team: 'Platform', issuer: 'Let\'s Encrypt', expiresAt: '2026-08-20', issuerType: 'ACME', deployments: ['nginx-staging-1'], renewalSuccess: true },
|
|
{ id: '6', name: 'mail.example.com', commonName: 'mail.example.com', sans: ['imap.example.com', 'smtp.example.com'], status: 'Expiring', environment: 'prod', owner: 'Frank Garcia', team: 'Infrastructure', issuer: 'Sectigo', expiresAt: '2026-04-10', issuerType: 'GenericCA', deployments: ['mail-server-1', 'mail-server-2'], renewalSuccess: true },
|
|
{ id: '7', name: 'vpn-gateway', commonName: 'vpn.company.internal', sans: [], status: 'Pending', environment: 'prod', owner: 'Grace Lee', team: 'Security', issuer: 'Internal CA', expiresAt: '2026-11-05', issuerType: 'Local', deployments: ['vpn-gateway-primary'], renewalSuccess: false },
|
|
{ id: '8', name: 'auth.example.com', commonName: 'auth.example.com', sans: ['oauth.example.com'], status: 'RenewalInProgress', environment: 'prod', owner: 'Henry Brown', team: 'Identity', issuer: 'Let\'s Encrypt', expiresAt: '2026-05-18', issuerType: 'ACME', deployments: ['auth-cluster-1', 'auth-cluster-2', 'auth-cluster-3'], renewalSuccess: true },
|
|
{ id: '9', name: 'api-v2.example.com', commonName: 'api-v2.example.com', sans: [], status: 'Active', environment: 'prod', owner: 'Iris Martinez', team: 'API', issuer: 'DigiCert', expiresAt: '2026-09-12', issuerType: 'GenericCA', deployments: ['api-v2-1', 'api-v2-2'], renewalSuccess: true },
|
|
{ id: '10', name: 'analytics.internal', commonName: 'analytics.internal', sans: [], status: 'Active', environment: 'internal', owner: 'Jack Wilson', team: 'Data', issuer: 'Internal CA', expiresAt: '2026-07-22', issuerType: 'Local', deployments: ['analytics-cluster'], renewalSuccess: true },
|
|
{ id: '11', name: 'backup.example.com', commonName: 'backup.example.com', sans: [], status: 'Failed', environment: 'prod', owner: 'Karen Taylor', team: 'Infrastructure', issuer: 'Let\'s Encrypt', expiresAt: '2025-10-15', issuerType: 'ACME', deployments: ['backup-vault'], renewalSuccess: false },
|
|
{ id: '12', name: 'config-server', commonName: 'config.internal', sans: [], status: 'Active', environment: 'internal', owner: 'Leo Anderson', team: 'Platform', issuer: 'Internal CA', expiresAt: '2026-12-30', issuerType: 'Local', deployments: ['config-primary', 'config-replica'], renewalSuccess: true },
|
|
{ id: '13', name: 'websocket.example.com', commonName: 'websocket.example.com', sans: ['ws.example.com'], status: 'Expiring', environment: 'prod', owner: 'Maya Patel', team: 'Platform', issuer: 'Let\'s Encrypt', expiresAt: '2026-03-28', issuerType: 'ACME', deployments: ['ws-load-balancer'], renewalSuccess: true },
|
|
{ id: '14', name: 'iot-gateway', commonName: 'iot.company.internal', sans: ['*.device.internal'], status: 'Active', environment: 'prod', owner: 'Noah Thompson', team: 'IoT', issuer: 'Internal CA', expiresAt: '2026-10-08', issuerType: 'Local', deployments: ['iot-gateway-primary', 'iot-gateway-backup'], renewalSuccess: true },
|
|
{ id: '15', name: 'monitoring.internal', commonName: 'monitoring.internal', sans: [], status: 'Active', environment: 'internal', owner: 'Olivia Moore', team: 'Operations', issuer: 'Internal CA', expiresAt: '2027-02-14', issuerType: 'Local', deployments: ['prometheus-primary'], renewalSuccess: true },
|
|
];
|
|
|
|
const mockIssuers = [
|
|
{ id: '1', name: 'Let\'s Encrypt', type: 'ACME', status: 'enabled', certCount: 5 },
|
|
{ id: '2', name: 'Internal CA', type: 'Local', status: 'enabled', certCount: 6 },
|
|
{ id: '3', name: 'DigiCert', type: 'GenericCA', status: 'enabled', certCount: 2 },
|
|
{ id: '4', name: 'Sectigo', type: 'GenericCA', status: 'enabled', certCount: 1 },
|
|
];
|
|
|
|
const mockTargets = [
|
|
{ id: '1', name: 'nginx-prod-1', type: 'NGINX', agent: 'agent-prod-1', status: 'online', certCount: 3 },
|
|
{ id: '2', name: 'nginx-prod-2', type: 'NGINX', agent: 'agent-prod-1', status: 'online', certCount: 2 },
|
|
{ id: '3', name: 'f5-load-balancer', type: 'F5', agent: 'agent-prod-2', status: 'online', certCount: 4 },
|
|
{ id: '4', name: 'iis-legacy-1', type: 'IIS', agent: 'agent-legacy', status: 'offline', certCount: 1 },
|
|
{ id: '5', name: 'mail-server-1', type: 'NGINX', agent: 'agent-mail', status: 'online', certCount: 2 },
|
|
{ id: '6', name: 'mail-server-2', type: 'NGINX', agent: 'agent-mail', status: 'online', certCount: 2 },
|
|
];
|
|
|
|
const mockAgents = [
|
|
{ id: '1', name: 'agent-prod-1', hostname: 'prod-worker-1.internal', status: 'online', lastHeartbeat: new Date(Date.now() - 10000) },
|
|
{ id: '2', name: 'agent-prod-2', hostname: 'prod-worker-2.internal', status: 'online', lastHeartbeat: new Date(Date.now() - 30000) },
|
|
{ id: '3', name: 'agent-mail', hostname: 'mail-worker.internal', status: 'online', lastHeartbeat: new Date(Date.now() - 5000) },
|
|
{ id: '4', name: 'agent-legacy', hostname: 'legacy-worker.internal', status: 'offline', lastHeartbeat: new Date(Date.now() - 3600000) },
|
|
];
|
|
|
|
const mockPolicies = [
|
|
{ id: '1', name: 'Minimum 30-day warning', description: 'Alert when certs expire within 30 days', enabled: true, violations: 3 },
|
|
{ id: '2', name: 'ACME only for new certs', description: 'Only issue certs via ACME', enabled: true, violations: 0 },
|
|
{ id: '3', name: 'No self-signed in prod', description: 'Self-signed certs not allowed in production', enabled: true, violations: 1 },
|
|
{ id: '4', name: 'Maximum validity 1 year', description: 'Certs valid for no more than 1 year', enabled: false, violations: 0 },
|
|
];
|
|
|
|
const mockAuditLog = [
|
|
{ id: '1', timestamp: new Date(Date.now() - 300000), actor: 'alice@example.com', action: 'RENEWAL_SUCCESS', resource: 'api.example.com', details: 'Certificate renewed successfully' },
|
|
{ id: '2', timestamp: new Date(Date.now() - 600000), actor: 'automation', action: 'EXPIRY_WARNING', resource: 'dashboard.internal', details: 'Certificate expiring in 11 days' },
|
|
{ id: '3', timestamp: new Date(Date.now() - 900000), actor: 'bob@example.com', action: 'CERTIFICATE_DEPLOYED', resource: 'auth.example.com', details: 'Deployed to auth-cluster-1' },
|
|
{ id: '4', timestamp: new Date(Date.now() - 1200000), actor: 'automation', action: 'RENEWAL_INITIATED', resource: 'cdn.example.com', details: 'Automatic renewal started' },
|
|
{ id: '5', timestamp: new Date(Date.now() - 1500000), actor: 'carol@example.com', action: 'CERTIFICATE_CREATED', resource: 'new-service.example.com', details: 'New certificate created manually' },
|
|
{ id: '6', timestamp: new Date(Date.now() - 1800000), actor: 'automation', action: 'RENEWAL_FAILED', resource: 'backup.example.com', details: 'Renewal failed: rate limit exceeded' },
|
|
{ id: '7', timestamp: new Date(Date.now() - 2100000), actor: 'dave@example.com', action: 'POLICY_VIOLATION', resource: 'legacy-app', details: 'Self-signed cert detected in production' },
|
|
{ id: '8', timestamp: new Date(Date.now() - 2400000), actor: 'automation', action: 'AGENT_HEARTBEAT', resource: 'agent-prod-1', details: 'Agent heartbeat received' },
|
|
{ id: '9', timestamp: new Date(Date.now() - 2700000), actor: 'emma@example.com', action: 'CERTIFICATE_ARCHIVED', resource: 'old-service.example.com', details: 'Certificate archived' },
|
|
{ id: '10', timestamp: new Date(Date.now() - 3000000), actor: 'automation', action: 'DEPLOYMENT_SUCCESS', resource: 'mail.example.com', details: 'Successfully deployed to all targets' },
|
|
];
|
|
|
|
// Helper functions
|
|
const calculateDaysUntilExpiry = (expiryDate) => {
|
|
const today = new Date();
|
|
const expiry = new Date(expiryDate);
|
|
const diff = expiry - today;
|
|
return Math.ceil(diff / (1000 * 60 * 60 * 24));
|
|
};
|
|
|
|
const formatDate = (date) => {
|
|
return new Date(date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
|
};
|
|
|
|
const formatDateTime = (date) => {
|
|
return new Date(date).toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
|
};
|
|
|
|
const getExpiryStatus = (daysUntil) => {
|
|
if (daysUntil < 0) return { status: 'Expired', badge: 'danger' };
|
|
if (daysUntil < 7) return { status: 'Expiring', badge: 'danger' };
|
|
if (daysUntil < 30) return { status: 'Expiring', badge: 'warning' };
|
|
return { status: 'Active', badge: 'success' };
|
|
};
|
|
|
|
const getStatusBadgeClass = (status) => {
|
|
const map = {
|
|
'Active': 'success',
|
|
'Expiring': 'warning',
|
|
'Expired': 'danger',
|
|
'Pending': 'gray',
|
|
'RenewalInProgress': 'info',
|
|
'Failed': 'danger',
|
|
};
|
|
return map[status] || 'gray';
|
|
};
|
|
|
|
// API calls with fallback to mock data
|
|
const fetchFromAPI = async (endpoint) => {
|
|
try {
|
|
const response = await fetch(`/api/v1${endpoint}`);
|
|
if (!response.ok) throw new Error('API error');
|
|
return await response.json();
|
|
} catch (error) {
|
|
return null; // Fallback to mock data
|
|
}
|
|
};
|
|
|
|
// Components
|
|
function StatCard({ title, value, icon, color, trend }) {
|
|
const iconClass = {
|
|
'success': 'success',
|
|
'warning': 'warning',
|
|
'danger': 'danger',
|
|
'info': 'info',
|
|
}[color] || 'info';
|
|
|
|
return (
|
|
<div className="stat-card">
|
|
<div className={`stat-icon ${iconClass}`}>
|
|
<Icon name={icon} size={24} />
|
|
</div>
|
|
<div className="stat-content">
|
|
<h3>{title}</h3>
|
|
<p>{value}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CertificateTable({ certificates, onSelectCert, searchTerm, statusFilter, envFilter, onSort }) {
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const itemsPerPage = 10;
|
|
|
|
let filtered = certificates.filter(cert => {
|
|
const matchesSearch = !searchTerm ||
|
|
cert.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
cert.commonName.toLowerCase().includes(searchTerm.toLowerCase());
|
|
const matchesStatus = !statusFilter || cert.status === statusFilter;
|
|
const matchesEnv = !envFilter || cert.environment === envFilter;
|
|
return matchesSearch && matchesStatus && matchesEnv;
|
|
});
|
|
|
|
const totalPages = Math.ceil(filtered.length / itemsPerPage);
|
|
const start = (currentPage - 1) * itemsPerPage;
|
|
const paged = filtered.slice(start, start + itemsPerPage);
|
|
|
|
const daysLeft = (expiryDate) => {
|
|
const days = calculateDaysUntilExpiry(expiryDate);
|
|
if (days < 0) return `Expired ${Math.abs(days)}d ago`;
|
|
if (days === 0) return 'Today';
|
|
if (days === 1) return 'Tomorrow';
|
|
return `${days}d`;
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<table className="table">
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Common Name</th>
|
|
<th>SANs</th>
|
|
<th>Status</th>
|
|
<th>Environment</th>
|
|
<th>Owner</th>
|
|
<th>Team</th>
|
|
<th>Issuer</th>
|
|
<th>Expires</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{paged.map(cert => (
|
|
<tr key={cert.id} style={{ cursor: 'pointer' }} onClick={() => onSelectCert(cert)}>
|
|
<td><strong>{cert.name}</strong></td>
|
|
<td>{cert.commonName}</td>
|
|
<td>{cert.sans.length > 0 ? `+${cert.sans.length}` : '—'}</td>
|
|
<td>
|
|
<span className={`badge badge-${getStatusBadgeClass(cert.status)}`}>
|
|
{cert.status}
|
|
</span>
|
|
</td>
|
|
<td>{cert.environment}</td>
|
|
<td>{cert.owner}</td>
|
|
<td>{cert.team}</td>
|
|
<td>{cert.issuer}</td>
|
|
<td>
|
|
<span className={`countdown ${calculateDaysUntilExpiry(cert.expiresAt) < 30 ? (calculateDaysUntilExpiry(cert.expiresAt) < 7 ? 'danger' : 'warning') : ''}`}>
|
|
{daysLeft(cert.expiresAt)}
|
|
</span>
|
|
</td>
|
|
<td>
|
|
<button className="btn btn-secondary btn-sm" onClick={(e) => { e.stopPropagation(); onSelectCert(cert); }}>
|
|
<Icon name="eye" size={16} />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
{paged.length === 0 && (
|
|
<div className="empty-state">
|
|
<Icon name="search" size={48} />
|
|
<p>No certificates found</p>
|
|
</div>
|
|
)}
|
|
{totalPages > 1 && (
|
|
<div className="pagination">
|
|
<button disabled={currentPage === 1} onClick={() => setCurrentPage(p => Math.max(1, p - 1))}>
|
|
Previous
|
|
</button>
|
|
<span>Page {currentPage} of {totalPages}</span>
|
|
<button disabled={currentPage === totalPages} onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}>
|
|
Next
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CertificateDetailModal({ cert, onClose, certificates }) {
|
|
const [activeTab, setActiveTab] = useState('overview');
|
|
const daysLeft = calculateDaysUntilExpiry(cert.expiresAt);
|
|
|
|
return (
|
|
<div className="modal-overlay" onClick={onClose}>
|
|
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
|
<div className="modal-header">
|
|
<div>
|
|
<h2>{cert.name}</h2>
|
|
<p style={{ margin: '0.5rem 0 0 0', color: 'var(--text-secondary)', fontSize: '0.9rem' }}>
|
|
{cert.commonName}
|
|
</p>
|
|
</div>
|
|
<button className="modal-close" onClick={onClose}>
|
|
<Icon name="x" size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="tabs">
|
|
<button
|
|
className={`tab ${activeTab === 'overview' ? 'active' : ''}`}
|
|
onClick={() => setActiveTab('overview')}
|
|
>
|
|
Overview
|
|
</button>
|
|
<button
|
|
className={`tab ${activeTab === 'history' ? 'active' : ''}`}
|
|
onClick={() => setActiveTab('history')}
|
|
>
|
|
History
|
|
</button>
|
|
<button
|
|
className={`tab ${activeTab === 'deployments' ? 'active' : ''}`}
|
|
onClick={() => setActiveTab('deployments')}
|
|
>
|
|
Deployments
|
|
</button>
|
|
<button
|
|
className={`tab ${activeTab === 'audit' ? 'active' : ''}`}
|
|
onClick={() => setActiveTab('audit')}
|
|
>
|
|
Audit
|
|
</button>
|
|
</div>
|
|
|
|
{activeTab === 'overview' && (
|
|
<div>
|
|
<div style={{ marginBottom: '1.5rem' }}>
|
|
<h3 style={{ margin: '0 0 1rem 0' }}>Status</h3>
|
|
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
|
|
<span className={`badge badge-${getStatusBadgeClass(cert.status)}`}>
|
|
{cert.status}
|
|
</span>
|
|
<span style={{ padding: '0.25rem 0.75rem', color: 'var(--text-secondary)', fontSize: '0.875rem' }}>
|
|
{daysLeft < 0 ? `Expired ${Math.abs(daysLeft)} days ago` : `Expires in ${daysLeft} days`}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="metadata-grid">
|
|
<div className="metadata-item">
|
|
<label>Common Name</label>
|
|
<p>{cert.commonName}</p>
|
|
</div>
|
|
<div className="metadata-item">
|
|
<label>Environment</label>
|
|
<p>{cert.environment}</p>
|
|
</div>
|
|
<div className="metadata-item">
|
|
<label>Owner</label>
|
|
<p>{cert.owner}</p>
|
|
</div>
|
|
<div className="metadata-item">
|
|
<label>Team</label>
|
|
<p>{cert.team}</p>
|
|
</div>
|
|
<div className="metadata-item">
|
|
<label>Issuer</label>
|
|
<p>{cert.issuer}</p>
|
|
</div>
|
|
<div className="metadata-item">
|
|
<label>Issuer Type</label>
|
|
<p>{cert.issuerType}</p>
|
|
</div>
|
|
<div className="metadata-item">
|
|
<label>Expires At</label>
|
|
<p>{formatDate(cert.expiresAt)}</p>
|
|
</div>
|
|
<div className="metadata-item">
|
|
<label>Serial Number</label>
|
|
<p style={{ fontSize: '0.8rem', fontFamily: 'monospace' }}>4A:3F:2B:1D:9E:5C</p>
|
|
</div>
|
|
</div>
|
|
|
|
{cert.sans.length > 0 && (
|
|
<div style={{ marginTop: '1.5rem' }}>
|
|
<h3 style={{ margin: '0 0 1rem 0' }}>Subject Alternative Names</h3>
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
|
{cert.sans.map((san, i) => (
|
|
<span key={i} style={{
|
|
padding: '0.25rem 0.75rem',
|
|
background: 'var(--bg-primary)',
|
|
border: '1px solid var(--border)',
|
|
borderRadius: '0.375rem',
|
|
fontSize: '0.875rem'
|
|
}}>
|
|
{san}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'history' && (
|
|
<div>
|
|
<table className="table" style={{ fontSize: '0.85rem' }}>
|
|
<thead>
|
|
<tr>
|
|
<th>Version</th>
|
|
<th>Not Before</th>
|
|
<th>Not After</th>
|
|
<th>Fingerprint</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td>1</td>
|
|
<td>{formatDate(new Date(Date.now() - 90 * 24 * 60 * 60 * 1000))}</td>
|
|
<td>{formatDate(cert.expiresAt)}</td>
|
|
<td style={{ fontFamily: 'monospace', fontSize: '0.75rem' }}>AB:CD:EF:12:34:56...</td>
|
|
</tr>
|
|
<tr>
|
|
<td>0</td>
|
|
<td>{formatDate(new Date(Date.now() - 180 * 24 * 60 * 60 * 1000))}</td>
|
|
<td>{formatDate(new Date(Date.now() - 90 * 24 * 60 * 60 * 1000))}</td>
|
|
<td style={{ fontFamily: 'monospace', fontSize: '0.75rem' }}>12:34:56:AB:CD:EF...</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'deployments' && (
|
|
<div>
|
|
{cert.deployments.length > 0 ? (
|
|
cert.deployments.map(dep => (
|
|
<div key={dep} className="deployment-item">
|
|
<div className="deployment-item-info">
|
|
<h4>{dep}</h4>
|
|
<p>Status: Deployed • Updated 2h ago</p>
|
|
</div>
|
|
<span className="badge badge-success">
|
|
<span className="badge-dot"></span>
|
|
Healthy
|
|
</span>
|
|
</div>
|
|
))
|
|
) : (
|
|
<div className="empty-state">
|
|
<Icon name="server" size={48} />
|
|
<p>No deployments</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'audit' && (
|
|
<div>
|
|
<table className="table" style={{ fontSize: '0.85rem' }}>
|
|
<thead>
|
|
<tr>
|
|
<th>Timestamp</th>
|
|
<th>Action</th>
|
|
<th>Actor</th>
|
|
<th>Details</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td>{formatDateTime(new Date())}</td>
|
|
<td>VIEWED</td>
|
|
<td>user@example.com</td>
|
|
<td>Certificate details viewed</td>
|
|
</tr>
|
|
<tr>
|
|
<td>{formatDateTime(new Date(Date.now() - 3600000))}</td>
|
|
<td>RENEWAL_SUCCESS</td>
|
|
<td>automation</td>
|
|
<td>Certificate renewed</td>
|
|
</tr>
|
|
<tr>
|
|
<td>{formatDateTime(new Date(Date.now() - 7200000))}</td>
|
|
<td>DEPLOYED</td>
|
|
<td>admin</td>
|
|
<td>Deployed to production</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
<div className="detail-actions">
|
|
<button className="btn btn-primary">
|
|
<Icon name="refresh-cw" size={16} />
|
|
Trigger Renewal
|
|
</button>
|
|
<button className="btn btn-primary">
|
|
<Icon name="deployment" size={16} />
|
|
Trigger Deployment
|
|
</button>
|
|
<button className="btn btn-secondary">
|
|
<Icon name="download" size={16} />
|
|
Export
|
|
</button>
|
|
<button className="btn btn-secondary">
|
|
<Icon name="trash-2" size={16} />
|
|
Archive
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Dashboard({ certificates, issuers, targets, agents, policies, auditLog }) {
|
|
const activeCount = certificates.filter(c => c.status === 'Active').length;
|
|
const expiringCount = certificates.filter(c => c.status === 'Expiring').length;
|
|
const expiredCount = certificates.filter(c => c.status === 'Expired').length;
|
|
const renewalSuccessRate = Math.round((certificates.filter(c => c.renewalSuccess).length / certificates.length) * 100);
|
|
|
|
const expiryBuckets = {
|
|
'7d': certificates.filter(c => {
|
|
const days = calculateDaysUntilExpiry(c.expiresAt);
|
|
return days >= 0 && days <= 7;
|
|
}).length,
|
|
'14d': certificates.filter(c => {
|
|
const days = calculateDaysUntilExpiry(c.expiresAt);
|
|
return days > 7 && days <= 14;
|
|
}).length,
|
|
'30d': certificates.filter(c => {
|
|
const days = calculateDaysUntilExpiry(c.expiresAt);
|
|
return days > 14 && days <= 30;
|
|
}).length,
|
|
'60d': certificates.filter(c => {
|
|
const days = calculateDaysUntilExpiry(c.expiresAt);
|
|
return days > 30 && days <= 60;
|
|
}).length,
|
|
'90d': certificates.filter(c => {
|
|
const days = calculateDaysUntilExpiry(c.expiresAt);
|
|
return days > 60 && days <= 90;
|
|
}).length,
|
|
};
|
|
|
|
const maxBucket = Math.max(...Object.values(expiryBuckets), 1);
|
|
|
|
return (
|
|
<div>
|
|
<div className="stats-grid">
|
|
<StatCard title="Total Certificates" value={certificates.length} icon="certificate" color="info" />
|
|
<StatCard title="Expiring Soon" value={expiringCount} icon="alert-triangle" color="warning" />
|
|
<StatCard title="Expired" value={expiredCount} icon="alert-circle" color="danger" />
|
|
<StatCard title="Active" value={activeCount} icon="check-circle" color="success" />
|
|
<StatCard title="Renewal Success Rate" value={`${renewalSuccessRate}%`} icon="zap" color={renewalSuccessRate >= 95 ? 'success' : 'warning'} />
|
|
</div>
|
|
|
|
<div className="card">
|
|
<h3 className="card-title">
|
|
<Icon name="clock" size={20} />
|
|
Expiry Timeline
|
|
</h3>
|
|
<div className="chart-bar">
|
|
<div className="chart-bar-item">
|
|
<div className="chart-bar-label">0-7 days</div>
|
|
<div className="chart-bar-track">
|
|
<div className="chart-bar-fill fill-7d" style={{ width: `${(expiryBuckets['7d'] / maxBucket) * 100}%` }}>
|
|
{expiryBuckets['7d']} certs
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="chart-bar-item">
|
|
<div className="chart-bar-label">8-14 days</div>
|
|
<div className="chart-bar-track">
|
|
<div className="chart-bar-fill fill-14d" style={{ width: `${(expiryBuckets['14d'] / maxBucket) * 100}%` }}>
|
|
{expiryBuckets['14d']} certs
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="chart-bar-item">
|
|
<div className="chart-bar-label">15-30 days</div>
|
|
<div className="chart-bar-track">
|
|
<div className="chart-bar-fill fill-30d" style={{ width: `${(expiryBuckets['30d'] / maxBucket) * 100}%` }}>
|
|
{expiryBuckets['30d']} certs
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="chart-bar-item">
|
|
<div className="chart-bar-label">31-60 days</div>
|
|
<div className="chart-bar-track">
|
|
<div className="chart-bar-fill fill-60d" style={{ width: `${(expiryBuckets['60d'] / maxBucket) * 100}%` }}>
|
|
{expiryBuckets['60d']} certs
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="chart-bar-item">
|
|
<div className="chart-bar-label">61-90 days</div>
|
|
<div className="chart-bar-track">
|
|
<div className="chart-bar-fill fill-90d" style={{ width: `${(expiryBuckets['90d'] / maxBucket) * 100}%` }}>
|
|
{expiryBuckets['90d']} certs
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card">
|
|
<h3 className="card-title">
|
|
<Icon name="activity" size={20} />
|
|
Recent Activity
|
|
</h3>
|
|
<div className="activity-feed">
|
|
{auditLog.slice(0, 10).map(event => (
|
|
<div key={event.id} className="activity-item">
|
|
<div className="activity-icon">
|
|
<Icon name={
|
|
event.action.includes('SUCCESS') ? 'check-circle' :
|
|
event.action.includes('FAILED') ? 'alert-circle' :
|
|
event.action.includes('WARNING') ? 'alert-triangle' :
|
|
'activity'
|
|
} size={18} />
|
|
</div>
|
|
<div className="activity-content">
|
|
<h4>{event.action.replace(/_/g, ' ')}</h4>
|
|
<p>{event.resource} • {formatDateTime(event.timestamp)}</p>
|
|
<p style={{ fontSize: '0.75rem', marginTop: '0.25rem' }}>{event.details}</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function App() {
|
|
const [currentView, setCurrentView] = useState('dashboard');
|
|
const [selectedCert, setSelectedCert] = useState(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [demoMode, setDemoMode] = useState(false);
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [statusFilter, setStatusFilter] = useState('');
|
|
const [envFilter, setEnvFilter] = useState('');
|
|
const [sortBy, setSortBy] = useState('name');
|
|
const [policyToggles, setPolicyToggles] = useState({});
|
|
|
|
useEffect(() => {
|
|
// Check if API is available
|
|
const checkAPI = async () => {
|
|
const result = await fetchFromAPI('/health');
|
|
setDemoMode(result === null);
|
|
setIsLoading(false);
|
|
};
|
|
checkAPI();
|
|
}, []);
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="app-container">
|
|
<div className="main-content" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
<div className="loading">
|
|
<div className="spinner"></div>
|
|
<span style={{ marginLeft: '1rem' }}>Loading dashboard...</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const navItems = [
|
|
{ view: 'dashboard', label: 'Dashboard', icon: 'home' },
|
|
{ view: 'certificates', label: 'Certificates', icon: 'certificate' },
|
|
{ view: 'issuers', label: 'Issuers', icon: 'shield' },
|
|
{ view: 'targets', label: 'Deployment Targets', icon: 'target' },
|
|
{ view: 'agents', label: 'Agents', icon: 'users' },
|
|
{ view: 'policies', label: 'Policies', icon: 'zap' },
|
|
{ view: 'audit', label: 'Audit Log', icon: 'activity' },
|
|
];
|
|
|
|
const getViewTitle = () => {
|
|
const item = navItems.find(n => n.view === currentView);
|
|
return item ? item.label : 'Dashboard';
|
|
};
|
|
|
|
return (
|
|
<div className="app-container">
|
|
<div className="sidebar">
|
|
<div className="sidebar-logo">
|
|
<h1>certctl</h1>
|
|
<p>Certificate Control Plane</p>
|
|
</div>
|
|
<div className="nav-items">
|
|
{navItems.map(item => (
|
|
<button
|
|
key={item.view}
|
|
className={`nav-item ${currentView === item.view ? 'active' : ''}`}
|
|
onClick={() => setCurrentView(item.view)}
|
|
>
|
|
<Icon name={item.icon} size={18} />
|
|
{item.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="main-content">
|
|
<div className="top-bar">
|
|
<h2>{getViewTitle()}</h2>
|
|
{demoMode && <div className="demo-badge">Demo Mode</div>}
|
|
</div>
|
|
|
|
<div className="content-area">
|
|
{currentView === 'dashboard' && (
|
|
<Dashboard
|
|
certificates={mockCertificates}
|
|
issuers={mockIssuers}
|
|
targets={mockTargets}
|
|
agents={mockAgents}
|
|
policies={mockPolicies}
|
|
auditLog={mockAuditLog}
|
|
/>
|
|
)}
|
|
|
|
{currentView === 'certificates' && (
|
|
<div>
|
|
<div className="filter-controls">
|
|
<div>
|
|
<Icon name="search" size={18} style={{ color: 'var(--text-secondary)' }} />
|
|
<input
|
|
type="text"
|
|
className="input search-input"
|
|
placeholder="Search by name or domain..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label>Status</label>
|
|
<select
|
|
className="select"
|
|
value={statusFilter}
|
|
onChange={(e) => setStatusFilter(e.target.value)}
|
|
>
|
|
<option value="">All</option>
|
|
<option value="Active">Active</option>
|
|
<option value="Expiring">Expiring</option>
|
|
<option value="Expired">Expired</option>
|
|
<option value="Pending">Pending</option>
|
|
<option value="RenewalInProgress">Renewal In Progress</option>
|
|
<option value="Failed">Failed</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label>Environment</label>
|
|
<select
|
|
className="select"
|
|
value={envFilter}
|
|
onChange={(e) => setEnvFilter(e.target.value)}
|
|
>
|
|
<option value="">All</option>
|
|
<option value="prod">Production</option>
|
|
<option value="staging">Staging</option>
|
|
<option value="internal">Internal</option>
|
|
<option value="legacy">Legacy</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div className="card" style={{ marginBottom: 0 }}>
|
|
<CertificateTable
|
|
certificates={mockCertificates}
|
|
onSelectCert={setSelectedCert}
|
|
searchTerm={searchTerm}
|
|
statusFilter={statusFilter}
|
|
envFilter={envFilter}
|
|
onSort={setSortBy}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{currentView === 'issuers' && (
|
|
<div>
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '1rem' }}>
|
|
{mockIssuers.map(issuer => (
|
|
<div key={issuer.id} className="card">
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '1rem' }}>
|
|
<div>
|
|
<h3 style={{ margin: 0, marginBottom: '0.25rem' }}>{issuer.name}</h3>
|
|
<p style={{ margin: 0, fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
|
|
Type: {issuer.type}
|
|
</p>
|
|
</div>
|
|
<span className={`badge badge-${issuer.status === 'enabled' ? 'success' : 'gray'}`}>
|
|
{issuer.status}
|
|
</span>
|
|
</div>
|
|
<div style={{ padding: '1rem', background: 'var(--bg-primary)', borderRadius: '0.375rem', marginBottom: '1rem' }}>
|
|
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', marginBottom: '0.25rem' }}>Managed Certificates</div>
|
|
<div style={{ fontSize: '1.5rem', fontWeight: '600' }}>{issuer.certCount}</div>
|
|
</div>
|
|
<button className="btn btn-secondary" style={{ width: '100%' }}>
|
|
<Icon name="settings" size={16} />
|
|
Configure
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{currentView === 'targets' && (
|
|
<div className="card">
|
|
<table className="table">
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Type</th>
|
|
<th>Agent</th>
|
|
<th>Status</th>
|
|
<th>Certificates</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{mockTargets.map(target => (
|
|
<tr key={target.id}>
|
|
<td><strong>{target.name}</strong></td>
|
|
<td>{target.type}</td>
|
|
<td>{target.agent}</td>
|
|
<td>
|
|
<span className={`badge badge-${target.status === 'online' ? 'success' : 'danger'}`}>
|
|
<span className={`status-dot ${target.status}`}></span>
|
|
{target.status}
|
|
</span>
|
|
</td>
|
|
<td>{target.certCount}</td>
|
|
<td>
|
|
<button className="btn btn-secondary btn-sm">
|
|
<Icon name="eye" size={16} />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{currentView === 'agents' && (
|
|
<div className="card">
|
|
<table className="table">
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Hostname</th>
|
|
<th>Status</th>
|
|
<th>Last Heartbeat</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{mockAgents.map(agent => (
|
|
<tr key={agent.id}>
|
|
<td><strong>{agent.name}</strong></td>
|
|
<td>{agent.hostname}</td>
|
|
<td>
|
|
<span className={`badge badge-${agent.status === 'online' ? 'success' : 'danger'}`}>
|
|
<span className={`status-dot ${agent.status}`}></span>
|
|
{agent.status}
|
|
</span>
|
|
</td>
|
|
<td style={{ fontSize: '0.85rem' }}>
|
|
{formatDateTime(agent.lastHeartbeat)}
|
|
</td>
|
|
<td>
|
|
<button className="btn btn-secondary btn-sm">
|
|
<Icon name="settings" size={16} />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{currentView === 'policies' && (
|
|
<div>
|
|
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
|
<h3 className="card-title">
|
|
<Icon name="shield" size={20} />
|
|
Policy Rules
|
|
</h3>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
|
{mockPolicies.map(policy => (
|
|
<div key={policy.id} style={{
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
padding: '1rem',
|
|
background: 'var(--bg-primary)',
|
|
border: '1px solid var(--border)',
|
|
borderRadius: '0.375rem',
|
|
}}>
|
|
<div>
|
|
<h4 style={{ margin: 0, marginBottom: '0.25rem' }}>{policy.name}</h4>
|
|
<p style={{ margin: 0, fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
|
|
{policy.description}
|
|
</p>
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
|
<div style={{ textAlign: 'right', marginRight: '1rem' }}>
|
|
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>Violations</div>
|
|
<div style={{ fontSize: '1.25rem', fontWeight: '600', color: policy.violations > 0 ? 'var(--danger)' : 'var(--success)' }}>
|
|
{policy.violations}
|
|
</div>
|
|
</div>
|
|
<div
|
|
className="toggle"
|
|
style={{
|
|
backgroundColor: policy.enabled ? 'var(--success)' : 'var(--bg-tertiary)',
|
|
borderColor: policy.enabled ? 'var(--success)' : 'var(--border)',
|
|
}}
|
|
onClick={() => {
|
|
setPolicyToggles(t => ({
|
|
...t,
|
|
[policy.id]: !t[policy.id] ?? !policy.enabled
|
|
}));
|
|
}}
|
|
>
|
|
<div className="toggle-knob" style={{
|
|
left: (policyToggles[policy.id] ?? policy.enabled) ? '18px' : '2px',
|
|
}}></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card">
|
|
<h3 className="card-title">
|
|
<Icon name="alert-triangle" size={20} />
|
|
Recent Violations
|
|
</h3>
|
|
<table className="table">
|
|
<thead>
|
|
<tr>
|
|
<th>Policy</th>
|
|
<th>Resource</th>
|
|
<th>Timestamp</th>
|
|
<th>Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td>Minimum 30-day warning</td>
|
|
<td>dashboard.internal</td>
|
|
<td>{formatDateTime(new Date(Date.now() - 600000))}</td>
|
|
<td><span className="badge badge-warning">Open</span></td>
|
|
</tr>
|
|
<tr>
|
|
<td>No self-signed in prod</td>
|
|
<td>legacy-app</td>
|
|
<td>{formatDateTime(new Date(Date.now() - 2100000))}</td>
|
|
<td><span className="badge badge-danger">Open</span></td>
|
|
</tr>
|
|
<tr>
|
|
<td>Minimum 30-day warning</td>
|
|
<td>websocket.example.com</td>
|
|
<td>{formatDateTime(new Date(Date.now() - 3600000))}</td>
|
|
<td><span className="badge badge-warning">Open</span></td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{currentView === 'audit' && (
|
|
<div>
|
|
<div className="filter-controls">
|
|
<div>
|
|
<label>Action Type</label>
|
|
<select className="select">
|
|
<option>All Actions</option>
|
|
<option>Renewal</option>
|
|
<option>Deployment</option>
|
|
<option>Policy Violation</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label>Date Range</label>
|
|
<select className="select">
|
|
<option>Last 7 days</option>
|
|
<option>Last 30 days</option>
|
|
<option>Last 90 days</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div className="card" style={{ marginBottom: 0 }}>
|
|
<table className="table">
|
|
<thead>
|
|
<tr>
|
|
<th>Timestamp</th>
|
|
<th>Actor</th>
|
|
<th>Action</th>
|
|
<th>Resource</th>
|
|
<th>Details</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{mockAuditLog.map(event => (
|
|
<tr key={event.id}>
|
|
<td>{formatDateTime(event.timestamp)}</td>
|
|
<td>{event.actor}</td>
|
|
<td>
|
|
<span style={{ fontFamily: 'monospace', fontSize: '0.85rem' }}>
|
|
{event.action}
|
|
</span>
|
|
</td>
|
|
<td>{event.resource}</td>
|
|
<td style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
|
|
{event.details}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{selectedCert && (
|
|
<CertificateDetailModal
|
|
cert={selectedCert}
|
|
onClose={() => setSelectedCert(null)}
|
|
certificates={mockCertificates}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
|
root.render(<App />);
|
|
</script>
|
|
</body>
|
|
</html>
|