feat: M13 — GUI operations (bulk ops, deployment timeline, policy editor, target wizard, audit export, short-lived creds)

Bulk certificate operations: multi-select checkboxes on certificates list with
bulk action bar for triggering renewal, revocation (with RFC 5280 reason modal
and progress bar), and owner reassignment across selected certificates.

Deployment status timeline: visual 4-step lifecycle pipeline (Requested → Issued
→ Deploying → Active) on certificate detail page, powered by per-cert job queries
with animated status indicators for active steps and failure states.

Inline policy editor: edit/save/cancel interface on certificate detail page for
changing renewal policy and certificate profile assignments via dropdown selectors
with lazy-loaded policy and profile lists.

Target connector configuration wizard: 3-step modal (Select Type → Configure →
Review) with type-specific configuration fields for NGINX, Apache, HAProxy, F5
BIG-IP, and IIS targets including required field validation.

Audit trail export: CSV and JSON download buttons on audit page with applied
filters preserved in export. Added action filter input for narrower searches.

Short-lived credentials dashboard: new page at /short-lived showing ephemeral
certificates (profile TTL < 1 hour) with live TTL countdown, auto-refresh every
10 seconds, profile lookup, and stats bar (active/expired/profiles).

DataTable enhanced with optional selectable/selectedKeys/onSelectionChange props
for checkbox multi-select with select-all toggle and row highlighting.

Frontend tests expanded from 53 to 79: full API client endpoint coverage for
profiles, owners, teams, agent groups, revocation, approval/rejection, policy
violations, and issuer creation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shankar0123
2026-03-22 15:07:10 -04:00
parent 762c523d59
commit 8af4e42f44
11 changed files with 1206 additions and 30 deletions
+2 -2
View File
@@ -365,7 +365,7 @@ make docker-clean # Stop + remove volumes
## Roadmap
### V1 (v1.0.0 released)
All nine development milestones (M1M9) are complete. The backend covers the full certificate lifecycle: Local CA and ACME v2 issuers, NGINX/Apache/HAProxy/F5/IIS target connectors, threshold-based expiration alerting, agent-side ECDSA P-256 key generation, API auth with rate limiting, and a React dashboard with 16 pages wired to the real API. The CI pipeline runs build, vet, test with coverage gates (service layer 30%+, handler layer 50%+), frontend type checking, Vitest test suite, and Vite production build on every push. 600+ tests total: 465 Go test functions + 138 subtests (207 service, 226 handler, integration with 40+ subtests, 23 connector) plus 53 frontend Vitest tests covering API client functions and utility helpers. Docker images are published to GitHub Container Registry on every version tag via the release workflow.
All nine development milestones (M1M9) are complete. The backend covers the full certificate lifecycle: Local CA and ACME v2 issuers, NGINX/Apache/HAProxy/F5/IIS target connectors, threshold-based expiration alerting, agent-side ECDSA P-256 key generation, API auth with rate limiting, and a React dashboard with 17 pages wired to the real API. The CI pipeline runs build, vet, test with coverage gates (service layer 30%+, handler layer 50%+), frontend type checking, Vitest test suite, and Vite production build on every push. 660+ tests total: 465 Go test functions + 138 subtests (207 service, 226 handler, integration with 40+ subtests, 23 connector) plus 79 frontend Vitest tests covering all API client endpoints, utilities, and M13 operations. Docker images are published to GitHub Container Registry on every version tag via the release workflow.
### V2: Operational Maturity
- **M10: Agent Metadata + Targets** ✅ — agents report OS, architecture, IP, hostname, version via heartbeat; Apache httpd and HAProxy target connectors
@@ -373,7 +373,7 @@ All nine development milestones (M1M9) are complete. The backend covers the f
- **M12: Sub-CA + DNS-01 + step-ca** ✅ — Local CA sub-CA mode (enterprise root chain with RSA/ECDSA/PKCS#8), ACME DNS-01 challenges (script-based DNS hooks for any provider, wildcard cert support), step-ca issuer connector (native /sign API with JWK provisioner auth)
- **M15a: Core Revocation** ✅ — revocation API with all RFC 5280 reason codes, JSON CRL endpoint, webhook + email revocation notifications, best-effort issuer notification, `certificate_revocations` table with idempotent recording, 48 new tests
- **M15b: OCSP + Revocation GUI** ✅ — embedded OCSP responder (GET /api/v1/ocsp/{issuer_id}/{serial}), DER-encoded X.509 CRL (GET /api/v1/crl/{issuer_id}), short-lived cert exemption (TTL < 1h skip CRL/OCSP), revocation GUI with reason modal, ~31 new tests
- **M13: GUI Operations** — bulk cert operations (renew, revoke, reassign), deployment timeline, inline policy editor, target config wizard, audit export, short-lived credentials dashboard
- **M13: GUI Operations** — bulk cert operations (multi-select → renew, revoke, reassign owner), deployment status timeline, inline policy/profile editor, target connector configuration wizard, audit trail export (CSV/JSON), short-lived credentials dashboard view
- **M14: Observability** — expiration calendar/heatmap, Prometheus metrics endpoint, structured logging improvements, deployment rollback
- **M16: Operator Tooling** — CLI tool (`certctl`), Slack/Teams/PagerDuty/OpsGenie notifiers, bulk certificate import
- **M17: Additional Connectors** — OpenSSL/Custom CA issuer connector
+1 -1
View File
@@ -92,7 +92,7 @@ The agent runs two background loops: a heartbeat (every 60 seconds) to signal it
The web dashboard is the primary operational interface for certctl. It is built with Vite + React + TypeScript and uses TanStack Query for server state management (caching, background refetching, optimistic updates).
**Current views (16 pages)**: certificate inventory (list with "New Certificate" creation modal + detail with version history, deploy, archive, and trigger renewal actions), agent fleet (list + detail with system info), job queue (status, retry, cancel, approve/reject), notification inbox (threshold alert grouping, mark-as-read), audit trail (time range and actor/action filters), policy management (rules with enable/disable toggle + delete + violations), issuers (list with test connection + delete), targets (list with delete), owners (list with team resolution + delete), teams (list with delete), agent groups (list with dynamic match criteria badges + enable/disable + delete), certificate profiles (list with crypto constraints), summary dashboard, and login page.
**Current views (17 pages)**: certificate inventory (list with multi-select bulk operations + "New Certificate" creation modal + detail with deployment status timeline, inline policy/profile editor, version history, deploy, revoke, archive, and trigger renewal actions), agent fleet (list + detail with system info), job queue (status, retry, cancel, approve/reject), notification inbox (threshold alert grouping, mark-as-read), audit trail (time range, actor, action filters + CSV/JSON export), policy management (rules with enable/disable toggle + delete + violations), issuers (list with test connection + delete), targets (list with 3-step configuration wizard + delete), owners (list with team resolution + delete), teams (list with delete), agent groups (list with dynamic match criteria badges + enable/disable + delete), certificate profiles (list with crypto constraints), short-lived credentials dashboard (TTL countdown, profile filtering, auto-refresh), summary dashboard, and login page.
The dashboard includes an **ErrorBoundary component** for graceful error recovery — if a view crashes, the boundary catches the error and displays a user-friendly message instead of breaking the entire dashboard. It also includes a **demo mode** that activates when the API is unreachable — it renders realistic mock data for screenshots and offline presentations.
+249
View File
@@ -10,23 +10,50 @@ import {
triggerDeployment,
updateCertificate,
archiveCertificate,
revokeCertificate,
getAgents,
getAgent,
registerAgent,
getJobs,
cancelJob,
approveRenewal,
rejectRenewal,
getNotifications,
markNotificationRead,
getAuditEvents,
getPolicies,
createPolicy,
updatePolicy,
deletePolicy,
getPolicyViolations,
getIssuers,
createIssuer,
testIssuerConnection,
deleteIssuer,
getTargets,
createTarget,
deleteTarget,
getProfiles,
getProfile,
createProfile,
updateProfile,
deleteProfile,
getOwners,
getOwner,
createOwner,
updateOwner,
deleteOwner,
getTeams,
getTeam,
createTeam,
updateTeam,
deleteTeam,
getAgentGroups,
getAgentGroup,
createAgentGroup,
updateAgentGroup,
deleteAgentGroup,
getAgentGroupMembers,
getHealth,
} from './client';
@@ -209,6 +236,15 @@ describe('API Client', () => {
expect(init.method).toBe('POST');
expect(JSON.parse(init.body)).toEqual({ target_id: 't-nginx' });
});
it('revokeCertificate sends POST with reason', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ status: 'revoked' }));
await revokeCertificate('mc-test', 'keyCompromise');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/certificates/mc-test/revoke');
expect(init.method).toBe('POST');
expect(JSON.parse(init.body)).toEqual({ reason: 'keyCompromise' });
});
});
// ─── Agents ─────────────────────────────────────────
@@ -357,6 +393,219 @@ describe('API Client', () => {
});
});
// ─── Approval ──────────────────────────────────────
describe('Renewal Approvals', () => {
it('approveRenewal sends POST', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'approved' }));
await approveRenewal('job-123');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/jobs/job-123/approve');
expect(init.method).toBe('POST');
});
it('rejectRenewal sends POST with reason', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'rejected' }));
await rejectRenewal('job-123', 'not authorized');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/jobs/job-123/reject');
expect(init.method).toBe('POST');
expect(JSON.parse(init.body)).toEqual({ reason: 'not authorized' });
});
});
// ─── Profiles ────────────────────────────────────────
describe('Profiles', () => {
it('getProfiles sends GET', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
await getProfiles();
expect(mockFetch.mock.calls[0][0]).toContain('/api/v1/profiles');
});
it('getProfile fetches by ID', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'prof-1', name: 'Standard' }));
const profile = await getProfile('prof-1');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/profiles/prof-1');
expect(profile.id).toBe('prof-1');
});
it('createProfile sends POST', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'prof-new', name: 'New Profile' }));
await createProfile({ name: 'New Profile' });
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/profiles');
expect(init.method).toBe('POST');
});
it('updateProfile sends PUT', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'prof-1', name: 'Updated' }));
await updateProfile('prof-1', { name: 'Updated' });
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/profiles/prof-1');
expect(init.method).toBe('PUT');
});
it('deleteProfile sends DELETE', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'deleted' }));
await deleteProfile('prof-1');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/profiles/prof-1');
expect(init.method).toBe('DELETE');
});
});
// ─── Owners ──────────────────────────────────────────
describe('Owners', () => {
it('getOwners sends GET', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
await getOwners();
expect(mockFetch.mock.calls[0][0]).toContain('/api/v1/owners');
});
it('getOwner fetches by ID', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'o-alice', name: 'Alice' }));
const owner = await getOwner('o-alice');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/owners/o-alice');
expect(owner.name).toBe('Alice');
});
it('createOwner sends POST', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'o-new', name: 'Bob' }));
await createOwner({ name: 'Bob', email: 'bob@example.com' });
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/owners');
expect(init.method).toBe('POST');
});
it('updateOwner sends PUT', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'o-alice', name: 'Alice Updated' }));
await updateOwner('o-alice', { name: 'Alice Updated' });
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/owners/o-alice');
expect(init.method).toBe('PUT');
});
it('deleteOwner sends DELETE', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'deleted' }));
await deleteOwner('o-alice');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/owners/o-alice');
expect(init.method).toBe('DELETE');
});
});
// ─── Teams ───────────────────────────────────────────
describe('Teams', () => {
it('getTeams sends GET', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
await getTeams();
expect(mockFetch.mock.calls[0][0]).toContain('/api/v1/teams');
});
it('getTeam fetches by ID', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 't-platform', name: 'Platform' }));
const team = await getTeam('t-platform');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/teams/t-platform');
expect(team.name).toBe('Platform');
});
it('createTeam sends POST', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 't-new', name: 'New Team' }));
await createTeam({ name: 'New Team' });
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/teams');
expect(init.method).toBe('POST');
});
it('updateTeam sends PUT', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 't-platform', name: 'Updated' }));
await updateTeam('t-platform', { name: 'Updated' });
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/teams/t-platform');
expect(init.method).toBe('PUT');
});
it('deleteTeam sends DELETE', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'deleted' }));
await deleteTeam('t-platform');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/teams/t-platform');
expect(init.method).toBe('DELETE');
});
});
// ─── Agent Groups ────────────────────────────────────
describe('Agent Groups', () => {
it('getAgentGroups sends GET', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
await getAgentGroups();
expect(mockFetch.mock.calls[0][0]).toContain('/api/v1/agent-groups');
});
it('getAgentGroup fetches by ID', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'ag-linux', name: 'Linux Servers' }));
const group = await getAgentGroup('ag-linux');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/agent-groups/ag-linux');
expect(group.name).toBe('Linux Servers');
});
it('createAgentGroup sends POST', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'ag-new', name: 'New Group' }));
await createAgentGroup({ name: 'New Group' });
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/agent-groups');
expect(init.method).toBe('POST');
});
it('updateAgentGroup sends PUT', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'ag-linux', name: 'Updated' }));
await updateAgentGroup('ag-linux', { name: 'Updated' });
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/agent-groups/ag-linux');
expect(init.method).toBe('PUT');
});
it('deleteAgentGroup sends DELETE', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'deleted' }));
await deleteAgentGroup('ag-linux');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/agent-groups/ag-linux');
expect(init.method).toBe('DELETE');
});
it('getAgentGroupMembers fetches members', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
await getAgentGroupMembers('ag-linux');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/agent-groups/ag-linux/members');
});
});
// ─── Policy Violations ───────────────────────────────
describe('Policy Violations', () => {
it('getPolicyViolations sends GET', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
await getPolicyViolations('pol-1');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/policies/pol-1/violations');
});
});
// ─── Issuer Create ───────────────────────────────────
describe('Issuer Create', () => {
it('createIssuer sends POST', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'iss-new', name: 'New Issuer' }));
await createIssuer({ name: 'New Issuer', type: 'local_ca' });
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/issuers');
expect(init.method).toBe('POST');
});
});
// ─── Audit ──────────────────────────────────────────
describe('Audit', () => {
+62 -14
View File
@@ -12,9 +12,12 @@ interface DataTableProps<T> {
emptyMessage?: string;
isLoading?: boolean;
keyField?: string;
selectable?: boolean;
selectedKeys?: Set<string>;
onSelectionChange?: (keys: Set<string>) => void;
}
export default function DataTable<T>({ columns, data, onRowClick, emptyMessage, isLoading, keyField = 'id' }: DataTableProps<T>) {
export default function DataTable<T>({ columns, data, onRowClick, emptyMessage, isLoading, keyField = 'id', selectable, selectedKeys, onSelectionChange }: DataTableProps<T>) {
if (isLoading) {
return (
<div className="flex items-center justify-center py-16 text-slate-400">
@@ -35,11 +38,41 @@ export default function DataTable<T>({ columns, data, onRowClick, emptyMessage,
);
}
const allKeys = data.map((item) => (item as Record<string, unknown>)[keyField] as string);
const allSelected = selectable && selectedKeys && allKeys.length > 0 && allKeys.every(k => selectedKeys.has(k));
const toggleAll = () => {
if (!onSelectionChange) return;
if (allSelected) {
onSelectionChange(new Set());
} else {
onSelectionChange(new Set(allKeys));
}
};
const toggleOne = (key: string) => {
if (!onSelectionChange || !selectedKeys) return;
const next = new Set(selectedKeys);
if (next.has(key)) next.delete(key);
else next.add(key);
onSelectionChange(next);
};
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b-2 border-slate-700">
{selectable && (
<th className="px-3 py-3 w-10">
<input
type="checkbox"
checked={allSelected || false}
onChange={toggleAll}
className="rounded border-slate-600 bg-slate-900 text-blue-600 focus:ring-blue-500 focus:ring-offset-0 cursor-pointer"
/>
</th>
)}
{columns.map(col => (
<th key={col.key} className={`px-4 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider ${col.className || ''}`}>
{col.label}
@@ -48,19 +81,34 @@ export default function DataTable<T>({ columns, data, onRowClick, emptyMessage,
</tr>
</thead>
<tbody>
{data.map((item, i) => (
<tr
key={(item as Record<string, unknown>)[keyField] as string ?? `row-${i}`}
onClick={() => onRowClick?.(item)}
className={`border-b border-slate-700/50 transition-colors hover:bg-blue-500/5 ${onRowClick ? 'cursor-pointer' : ''}`}
>
{columns.map(col => (
<td key={col.key} className={`px-4 py-3 ${col.className || ''}`}>
{col.render(item)}
</td>
))}
</tr>
))}
{data.map((item, i) => {
const rowKey = (item as Record<string, unknown>)[keyField] as string ?? `row-${i}`;
const isSelected = selectable && selectedKeys?.has(rowKey);
return (
<tr
key={rowKey}
onClick={() => onRowClick?.(item)}
className={`border-b border-slate-700/50 transition-colors hover:bg-blue-500/5 ${onRowClick ? 'cursor-pointer' : ''} ${isSelected ? 'bg-blue-500/10' : ''}`}
>
{selectable && (
<td className="px-3 py-3 w-10">
<input
type="checkbox"
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"
/>
</td>
)}
{columns.map(col => (
<td key={col.key} className={`px-4 py-3 ${col.className || ''}`}>
{col.render(item)}
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
+1
View File
@@ -14,6 +14,7 @@ const nav = [
{ to: '/owners', label: 'Owners', icon: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z' },
{ to: '/teams', label: 'Teams', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z' },
{ to: '/agent-groups', label: 'Agent Groups', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10 M9 3v2m6-2v2' },
{ to: '/short-lived', label: 'Short-Lived', icon: 'M13 10V3L4 14h7v7l9-11h-7z' },
{ to: '/audit', label: 'Audit Trail', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' },
];
+2
View File
@@ -21,6 +21,7 @@ import OwnersPage from './pages/OwnersPage';
import TeamsPage from './pages/TeamsPage';
import AgentGroupsPage from './pages/AgentGroupsPage';
import AuditPage from './pages/AuditPage';
import ShortLivedPage from './pages/ShortLivedPage';
import './index.css';
const queryClient = new QueryClient({
@@ -57,6 +58,7 @@ createRoot(document.getElementById('root')!).render(
<Route path="teams" element={<TeamsPage />} />
<Route path="agent-groups" element={<AgentGroupsPage />} />
<Route path="audit" element={<AuditPage />} />
<Route path="short-lived" element={<ShortLivedPage />} />
</Route>
</Routes>
</BrowserRouter>
+63 -3
View File
@@ -18,6 +18,7 @@ const actionColors: Record<string, string> = {
expiration_alert_sent: 'text-amber-400',
agent_registered: 'text-blue-400',
policy_violated: 'text-red-400',
certificate_revoked: 'text-red-400',
};
const RESOURCE_TYPES = ['', 'certificate', 'agent', 'job', 'notification', 'policy', 'target', 'issuer'];
@@ -29,14 +30,49 @@ const TIME_RANGES = [
{ label: 'Last 30 days', value: '30d' },
];
function downloadFile(content: string, filename: string, type: string) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function exportCSV(events: AuditEvent[]) {
const headers = ['ID', 'Action', 'Actor', 'Actor Type', 'Resource Type', 'Resource ID', 'Details', 'Timestamp'];
const rows = events.map(e => [
e.id,
e.action,
e.actor,
e.actor_type,
e.resource_type,
e.resource_id,
JSON.stringify(e.details || {}),
e.timestamp,
]);
const csv = [headers, ...rows].map(row => row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(',')).join('\n');
downloadFile(csv, `audit-trail-${new Date().toISOString().slice(0, 10)}.csv`, 'text/csv');
}
function exportJSON(events: AuditEvent[]) {
const json = JSON.stringify(events, null, 2);
downloadFile(json, `audit-trail-${new Date().toISOString().slice(0, 10)}.json`, 'application/json');
}
export default function AuditPage() {
const [resourceType, setResourceType] = useState('');
const [actorFilter, setActorFilter] = useState('');
const [timeRange, setTimeRange] = useState('');
const [actionFilter, setActionFilter] = useState('');
const params: Record<string, string> = {};
if (resourceType) params.resource_type = resourceType;
if (actorFilter) params.actor = actorFilter;
if (actionFilter) params.action = actionFilter;
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['audit', params],
@@ -98,9 +134,26 @@ export default function AuditPage() {
{ key: 'time', label: 'Time', render: (e) => <span className="text-xs text-slate-400">{formatDateTime(e.timestamp)}</span> },
];
const hasFilters = resourceType || actorFilter || timeRange || actionFilter;
return (
<>
<PageHeader title="Audit Trail" subtitle={data ? `${filtered.length} events` : undefined} />
<PageHeader
title="Audit Trail"
subtitle={data ? `${filtered.length} events` : undefined}
action={
filtered.length > 0 ? (
<div className="flex gap-2">
<button onClick={() => exportCSV(filtered)} className="btn btn-ghost text-xs border border-slate-600">
Export CSV
</button>
<button onClick={() => exportJSON(filtered)} className="btn btn-ghost text-xs border border-slate-600">
Export JSON
</button>
</div>
) : undefined
}
/>
<div className="px-4 py-3 flex flex-wrap gap-3 border-b border-slate-700/50">
<select
value={resourceType}
@@ -119,6 +172,13 @@ export default function AuditPage() {
onChange={(e) => setActorFilter(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"
/>
<input
type="text"
placeholder="Filter by action..."
value={actionFilter}
onChange={(e) => 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"
/>
<select
value={timeRange}
onChange={(e) => setTimeRange(e.target.value)}
@@ -128,9 +188,9 @@ export default function AuditPage() {
<option key={r.value} value={r.value}>{r.label}</option>
))}
</select>
{(resourceType || actorFilter || timeRange) && (
{hasFilters && (
<button
onClick={() => { setResourceType(''); setActorFilter(''); setTimeRange(''); }}
onClick={() => { setResourceType(''); setActorFilter(''); setTimeRange(''); setActionFilter(''); }}
className="text-xs text-slate-400 hover:text-slate-200 transition-colors"
>
Clear filters
+216 -7
View File
@@ -1,18 +1,219 @@
import { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getCertificate, getCertificateVersions, triggerRenewal, triggerDeployment, archiveCertificate, revokeCertificate, getTargets } from '../api/client';
import { getCertificate, getCertificateVersions, triggerRenewal, triggerDeployment, archiveCertificate, revokeCertificate, updateCertificate, getTargets, getJobs, getPolicies, getProfiles } from '../api/client';
import { REVOCATION_REASONS } from '../api/types';
import PageHeader from '../components/PageHeader';
import StatusBadge from '../components/StatusBadge';
import ErrorState from '../components/ErrorState';
import { formatDate, formatDateTime, daysUntil, expiryColor } from '../api/utils';
import { formatDate, formatDateTime, daysUntil, expiryColor, timeAgo } from '../api/utils';
import type { Job } from '../api/types';
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
function InfoRow({ label, value, editable, onEdit }: { label: string; value: React.ReactNode; editable?: boolean; onEdit?: () => void }) {
return (
<div className="flex justify-between py-2 border-b border-slate-700/50">
<div className="flex justify-between py-2 border-b border-slate-700/50 group">
<span className="text-sm text-slate-400">{label}</span>
<span className="text-sm text-slate-200">{value}</span>
<div className="flex items-center gap-2">
<span className="text-sm text-slate-200">{value}</span>
{editable && onEdit && (
<button onClick={onEdit} className="opacity-0 group-hover:opacity-100 transition-opacity text-xs text-blue-400 hover:text-blue-300">
Edit
</button>
)}
</div>
</div>
);
}
// Timeline step component for deployment status
function TimelineStep({ label, status, time, isLast }: { label: string; status: 'completed' | 'active' | 'pending' | 'failed'; time?: string; isLast?: boolean }) {
const dotStyles = {
completed: 'bg-emerald-500 ring-emerald-500/30',
active: 'bg-blue-500 ring-blue-500/30 animate-pulse',
pending: 'bg-slate-600 ring-slate-600/30',
failed: 'bg-red-500 ring-red-500/30',
};
const lineStyles = {
completed: 'bg-emerald-500/50',
active: 'bg-blue-500/30',
pending: 'bg-slate-700',
failed: 'bg-red-500/30',
};
const textStyles = {
completed: 'text-emerald-400',
active: 'text-blue-400',
pending: 'text-slate-500',
failed: 'text-red-400',
};
return (
<div className="flex items-start gap-3 relative">
<div className="flex flex-col items-center">
<div className={`w-3 h-3 rounded-full ring-4 ${dotStyles[status]} flex-shrink-0 mt-0.5`} />
{!isLast && <div className={`w-0.5 h-8 ${lineStyles[status]}`} />}
</div>
<div className="pb-6">
<div className={`text-sm font-medium ${textStyles[status]}`}>{label}</div>
{time && <div className="text-xs text-slate-500 mt-0.5">{time}</div>}
</div>
</div>
);
}
function DeploymentTimeline({ certId, certStatus, createdAt, issuedAt }: { certId: string; certStatus: string; createdAt: string; issuedAt: string }) {
const { data: jobsData } = useQuery({
queryKey: ['jobs', { certificate_id: certId }],
queryFn: () => getJobs({ certificate_id: certId }),
});
const jobs = jobsData?.data || [];
const issuanceJobs = jobs.filter((j: Job) => j.type === 'Issuance' || j.type === 'Renewal');
const deployJobs = jobs.filter((j: Job) => j.type === 'Deployment');
const latestIssuance = issuanceJobs[0];
const latestDeploy = deployJobs[0];
// Determine step statuses
const getRequestedStatus = () => 'completed' as const;
const getRequestedTime = () => formatDateTime(createdAt);
const getIssuedStatus = () => {
if (issuedAt) return 'completed' as const;
if (latestIssuance?.status === 'Running' || latestIssuance?.status === 'AwaitingCSR' || latestIssuance?.status === 'AwaitingApproval') return 'active' as const;
if (latestIssuance?.status === 'Failed') return 'failed' as const;
return 'pending' as const;
};
const getIssuedTime = () => {
if (issuedAt) return formatDateTime(issuedAt);
if (latestIssuance) return `${latestIssuance.status}${timeAgo(latestIssuance.created_at)}`;
return undefined;
};
const getDeployStatus = () => {
if (!issuedAt) return 'pending' as const;
if (latestDeploy?.status === 'Completed') return 'completed' as const;
if (latestDeploy?.status === 'Running') return 'active' as const;
if (latestDeploy?.status === 'Failed') return 'failed' as const;
if (latestDeploy?.status === 'Pending') return 'active' as const;
return 'pending' as const;
};
const getDeployTime = () => {
if (latestDeploy?.status === 'Completed') return formatDateTime(latestDeploy.completed_at);
if (latestDeploy) return `${latestDeploy.status}${timeAgo(latestDeploy.created_at)}`;
return undefined;
};
const getActiveStatus = () => {
if (certStatus === 'Active') return 'completed' as const;
if (certStatus === 'Revoked') return 'failed' as const;
if (certStatus === 'Expired') return 'failed' as const;
if (latestDeploy?.status === 'Completed') return 'completed' as const;
return 'pending' as const;
};
const getActiveTime = () => {
if (certStatus === 'Revoked') return 'Revoked';
if (certStatus === 'Expired') return 'Expired';
if (certStatus === 'Active') return 'Currently active';
return undefined;
};
return (
<div className="card p-5">
<h3 className="text-sm font-semibold text-slate-300 mb-4">Lifecycle Timeline</h3>
<div className="pl-1">
<TimelineStep label="Requested" status={getRequestedStatus()} time={getRequestedTime()} />
<TimelineStep label="Issued" status={getIssuedStatus()} time={getIssuedTime()} />
<TimelineStep label="Deploying" status={getDeployStatus()} time={getDeployTime()} />
<TimelineStep label={certStatus === 'Revoked' ? 'Revoked' : certStatus === 'Expired' ? 'Expired' : 'Active'}
status={getActiveStatus()} time={getActiveTime()} isLast />
</div>
</div>
);
}
function InlinePolicyEditor({ certId, currentPolicyId, currentProfileId }: { certId: string; currentPolicyId: string; currentProfileId: string }) {
const queryClient = useQueryClient();
const [editing, setEditing] = useState(false);
const [policyId, setPolicyId] = useState(currentPolicyId);
const [profileId, setProfileId] = useState(currentProfileId);
const { data: policies } = useQuery({
queryKey: ['policies'],
queryFn: () => getPolicies(),
enabled: editing,
});
const { data: profiles } = useQuery({
queryKey: ['profiles'],
queryFn: () => getProfiles(),
enabled: editing,
});
const saveMutation = useMutation({
mutationFn: () => updateCertificate(certId, {
renewal_policy_id: policyId,
certificate_profile_id: profileId,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['certificate', certId] });
setEditing(false);
},
});
if (!editing) {
return (
<div className="card p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-slate-300">Policy & Profile</h3>
<button onClick={() => setEditing(true)} className="text-xs text-blue-400 hover:text-blue-300 transition-colors">
Edit
</button>
</div>
<InfoRow label="Renewal Policy" value={currentPolicyId || '—'} />
<InfoRow label="Certificate Profile" value={currentProfileId || '—'} />
</div>
);
}
return (
<div className="card p-5 border-blue-500/30">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-blue-400">Edit Policy & Profile</h3>
<div className="flex gap-2">
<button onClick={() => { setEditing(false); setPolicyId(currentPolicyId); setProfileId(currentProfileId); }}
className="text-xs text-slate-400 hover:text-slate-300">Cancel</button>
<button onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}
className="text-xs text-blue-400 hover:text-blue-300 font-medium disabled:opacity-50">
{saveMutation.isPending ? 'Saving...' : 'Save'}
</button>
</div>
</div>
{saveMutation.isError && (
<div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-lg px-3 py-2 text-sm mb-3">
{saveMutation.error instanceof Error ? saveMutation.error.message : 'Failed to save'}
</div>
)}
<div className="space-y-3">
<div>
<label className="text-xs text-slate-400 block mb-1">Renewal Policy</label>
<select value={policyId} onChange={e => 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">
<option value="">None</option>
{policies?.data?.map(p => (
<option key={p.id} value={p.id}>{p.name} ({p.type})</option>
))}
</select>
</div>
<div>
<label className="text-xs text-slate-400 block mb-1">Certificate Profile</label>
<select value={profileId} onChange={e => setProfileId(e.target.value)}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200">
<option value="">None</option>
{profiles?.data?.map(p => (
<option key={p.id} value={p.id}>{p.name} max TTL {p.max_ttl_seconds ? `${Math.round(p.max_ttl_seconds / 86400)}d` : '∞'}</option>
))}
</select>
</div>
</div>
</div>
);
}
@@ -203,6 +404,9 @@ export default function CertificateDetailPage() {
</div>
)}
{/* Deployment Status Timeline */}
<DeploymentTimeline certId={id!} certStatus={cert.status} createdAt={cert.created_at} issuedAt={cert.issued_at} />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Certificate Info */}
<div className="card p-5">
@@ -229,8 +433,6 @@ export default function CertificateDetailPage() {
} />
<InfoRow label="Environment" value={cert.environment || '—'} />
<InfoRow label="Issuer" value={cert.issuer_id} />
<InfoRow label="Profile" value={cert.certificate_profile_id || '—'} />
<InfoRow label="Renewal Policy" value={cert.renewal_policy_id || '—'} />
<InfoRow label="Owner" value={cert.owner_id} />
<InfoRow label="Team" value={cert.team_id} />
{isRevoked && (
@@ -250,6 +452,13 @@ export default function CertificateDetailPage() {
</div>
</div>
{/* Inline Policy Editor */}
<InlinePolicyEditor
certId={id!}
currentPolicyId={cert.renewal_policy_id || ''}
currentProfileId={cert.certificate_profile_id || ''}
/>
{/* Tags */}
{cert.tags && Object.keys(cert.tags).length > 0 && (
<div className="card p-5">
+220 -1
View File
@@ -1,7 +1,8 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { getCertificates, createCertificate } from '../api/client';
import { getCertificates, createCertificate, triggerRenewal, revokeCertificate, updateCertificate, getOwners } from '../api/client';
import { REVOCATION_REASONS } from '../api/types';
import PageHeader from '../components/PageHeader';
import DataTable from '../components/DataTable';
import type { Column } from '../components/DataTable';
@@ -99,12 +100,149 @@ function CreateCertificateModal({ onClose, onSuccess }: { onClose: () => void; o
);
}
function BulkRevokeModal({ ids, onClose, onSuccess }: { ids: string[]; onClose: () => void; onSuccess: () => void }) {
const [reason, setReason] = useState('unspecified');
const [progress, setProgress] = useState(0);
const [error, setError] = useState('');
const [running, setRunning] = useState(false);
const handleRevoke = async () => {
setRunning(true);
setError('');
let succeeded = 0;
for (const id of ids) {
try {
await revokeCertificate(id, reason);
succeeded++;
setProgress(succeeded);
} catch (err) {
setError(`Failed on ${id}: ${err instanceof Error ? err.message : 'Unknown error'}`);
break;
}
}
if (!error) onSuccess();
};
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-slate-800 border border-slate-600 rounded-xl p-6 w-full max-w-md shadow-2xl" onClick={e => e.stopPropagation()}>
<h2 className="text-lg font-semibold text-red-400 mb-2">Bulk Revoke</h2>
<p className="text-sm text-slate-400 mb-4">
Revoke {ids.length} certificate{ids.length > 1 ? 's' : ''}. This cannot be undone.
</p>
{error && <div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-lg px-3 py-2 text-sm mb-3">{error}</div>}
{running && (
<div className="mb-3">
<div className="flex justify-between text-xs text-slate-400 mb-1">
<span>Progress</span>
<span>{progress}/{ids.length}</span>
</div>
<div className="w-full bg-slate-700 rounded-full h-2">
<div className="bg-red-500 h-2 rounded-full transition-all" style={{ width: `${(progress / ids.length) * 100}%` }} />
</div>
</div>
)}
<label className="text-xs text-slate-400 block mb-2">Revocation Reason (RFC 5280)</label>
<select value={reason} onChange={e => setReason(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"
disabled={running}
>
{REVOCATION_REASONS.map(r => (
<option key={r.value} value={r.value}>{r.label}</option>
))}
</select>
<div className="flex justify-end gap-3">
<button onClick={onClose} className="btn btn-ghost text-sm" disabled={running}>Cancel</button>
<button onClick={handleRevoke} disabled={running}
className="btn text-sm bg-red-600 hover:bg-red-500 text-white disabled:opacity-50">
{running ? `Revoking (${progress}/${ids.length})...` : `Revoke ${ids.length} Certificates`}
</button>
</div>
</div>
</div>
);
}
function BulkReassignModal({ ids, onClose, onSuccess }: { ids: string[]; onClose: () => void; onSuccess: () => void }) {
const [ownerId, setOwnerId] = useState('');
const [progress, setProgress] = useState(0);
const [error, setError] = useState('');
const [running, setRunning] = useState(false);
const { data: owners } = useQuery({
queryKey: ['owners'],
queryFn: () => getOwners(),
});
const handleReassign = async () => {
if (!ownerId) return;
setRunning(true);
setError('');
let succeeded = 0;
for (const id of ids) {
try {
await updateCertificate(id, { owner_id: ownerId } as Partial<Certificate>);
succeeded++;
setProgress(succeeded);
} catch (err) {
setError(`Failed on ${id}: ${err instanceof Error ? err.message : 'Unknown error'}`);
break;
}
}
if (!error) onSuccess();
};
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-slate-800 border border-slate-600 rounded-xl p-6 w-full max-w-md shadow-2xl" onClick={e => e.stopPropagation()}>
<h2 className="text-lg font-semibold text-slate-200 mb-2">Reassign Owner</h2>
<p className="text-sm text-slate-400 mb-4">
Reassign {ids.length} certificate{ids.length > 1 ? 's' : ''} to a new owner.
</p>
{error && <div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-lg px-3 py-2 text-sm mb-3">{error}</div>}
{running && (
<div className="mb-3">
<div className="flex justify-between text-xs text-slate-400 mb-1">
<span>Progress</span>
<span>{progress}/{ids.length}</span>
</div>
<div className="w-full bg-slate-700 rounded-full h-2">
<div className="bg-blue-500 h-2 rounded-full transition-all" style={{ width: `${(progress / ids.length) * 100}%` }} />
</div>
</div>
)}
<label className="text-xs text-slate-400 block mb-2">New Owner</label>
<select value={ownerId} onChange={e => 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"
disabled={running}
>
<option value="">Select owner...</option>
{owners?.data?.map(o => (
<option key={o.id} value={o.id}>{o.name} ({o.email})</option>
))}
</select>
<div className="flex justify-end gap-3">
<button onClick={onClose} className="btn btn-ghost text-sm" disabled={running}>Cancel</button>
<button onClick={handleReassign} disabled={running || !ownerId}
className="btn btn-primary text-sm disabled:opacity-50">
{running ? `Reassigning (${progress}/${ids.length})...` : `Reassign ${ids.length} Certificates`}
</button>
</div>
</div>
</div>
);
}
export default function CertificatesPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [statusFilter, setStatusFilter] = useState('');
const [envFilter, setEnvFilter] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [showBulkRevoke, setShowBulkRevoke] = useState(false);
const [showBulkReassign, setShowBulkReassign] = useState(false);
const [bulkRenewProgress, setBulkRenewProgress] = useState<{ done: number; total: number; running: boolean } | null>(null);
const params: Record<string, string> = {};
if (statusFilter) params.status = statusFilter;
@@ -116,6 +254,22 @@ export default function CertificatesPage() {
refetchInterval: 30000,
});
const handleBulkRenewal = async () => {
const ids = Array.from(selectedIds);
setBulkRenewProgress({ done: 0, total: ids.length, running: true });
for (let i = 0; i < ids.length; i++) {
try {
await triggerRenewal(ids[i]);
} catch {
// continue on individual failures
}
setBulkRenewProgress({ done: i + 1, total: ids.length, running: i + 1 < ids.length });
}
queryClient.invalidateQueries({ queryKey: ['certificates'] });
setSelectedIds(new Set());
setTimeout(() => setBulkRenewProgress(null), 3000);
};
const columns: Column<Certificate>[] = [
{
key: 'name',
@@ -146,6 +300,9 @@ export default function CertificatesPage() {
{ key: 'owner', label: 'Owner', render: (c) => <span className="text-slate-400 text-xs">{c.owner_id}</span> },
];
const selectedArray = Array.from(selectedIds);
const hasSelection = selectedArray.length > 0;
return (
<>
<PageHeader
@@ -157,6 +314,43 @@ export default function CertificatesPage() {
</button>
}
/>
{/* Bulk Action Bar */}
{hasSelection && (
<div className="px-6 py-3 bg-blue-500/10 border-b border-blue-500/20 flex items-center justify-between">
<span className="text-sm text-blue-400 font-medium">{selectedArray.length} selected</span>
<div className="flex gap-2">
<button onClick={handleBulkRenewal} disabled={bulkRenewProgress?.running}
className="btn btn-primary text-xs disabled:opacity-50">
{bulkRenewProgress?.running
? `Renewing (${bulkRenewProgress.done}/${bulkRenewProgress.total})...`
: 'Trigger Renewal'}
</button>
<button onClick={() => setShowBulkRevoke(true)}
className="btn btn-ghost text-xs text-amber-400 hover:text-amber-300 border border-amber-600/50">
Revoke
</button>
<button onClick={() => setShowBulkReassign(true)}
className="btn btn-ghost text-xs text-blue-400 hover:text-blue-300 border border-blue-600/50">
Reassign Owner
</button>
<button onClick={() => setSelectedIds(new Set())}
className="btn btn-ghost text-xs text-slate-400">
Clear
</button>
</div>
</div>
)}
{/* Bulk Renewal Success */}
{bulkRenewProgress && !bulkRenewProgress.running && (
<div className="px-6 py-2 bg-emerald-500/10 border-b border-emerald-500/20">
<span className="text-sm text-emerald-400">
Triggered renewal for {bulkRenewProgress.done} certificate{bulkRenewProgress.done > 1 ? 's' : ''}.
</span>
</div>
)}
<div className="px-6 py-3 flex gap-3 border-b border-slate-700/50">
<select
value={statusFilter}
@@ -192,6 +386,9 @@ export default function CertificatesPage() {
isLoading={isLoading}
onRowClick={(c) => navigate(`/certificates/${c.id}`)}
emptyMessage="No certificates found"
selectable
selectedKeys={selectedIds}
onSelectionChange={setSelectedIds}
/>
)}
</div>
@@ -204,6 +401,28 @@ export default function CertificatesPage() {
}}
/>
)}
{showBulkRevoke && (
<BulkRevokeModal
ids={selectedArray}
onClose={() => setShowBulkRevoke(false)}
onSuccess={() => {
setShowBulkRevoke(false);
setSelectedIds(new Set());
queryClient.invalidateQueries({ queryKey: ['certificates'] });
}}
/>
)}
{showBulkReassign && (
<BulkReassignModal
ids={selectedArray}
onClose={() => setShowBulkReassign(false)}
onSuccess={() => {
setShowBulkReassign(false);
setSelectedIds(new Set());
queryClient.invalidateQueries({ queryKey: ['certificates'] });
}}
/>
)}
</>
);
}
+156
View File
@@ -0,0 +1,156 @@
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { getCertificates, getProfiles } from '../api/client';
import PageHeader from '../components/PageHeader';
import DataTable from '../components/DataTable';
import type { Column } from '../components/DataTable';
import StatusBadge from '../components/StatusBadge';
import ErrorState from '../components/ErrorState';
import { formatDateTime, daysUntil } from '../api/utils';
import type { Certificate, CertificateProfile } from '../api/types';
function formatTTL(seconds: number): string {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
if (seconds < 86400) return `${Math.round(seconds / 3600)}h`;
return `${Math.round(seconds / 86400)}d`;
}
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 };
}
export default function ShortLivedPage() {
const navigate = useNavigate();
const { data: certsData, isLoading: certsLoading, error: certsError, refetch } = useQuery({
queryKey: ['certificates', {}],
queryFn: () => getCertificates(),
refetchInterval: 10000, // Refresh every 10s for short-lived certs
});
const { data: profilesData } = useQuery({
queryKey: ['profiles'],
queryFn: () => getProfiles(),
});
// Build profile lookup
const profileMap = new Map<string, CertificateProfile>();
profilesData?.data?.forEach(p => profileMap.set(p.id, p));
// Filter to short-lived certificates (profile with allow_short_lived and max_ttl < 1 hour)
const shortLivedProfileIds = new Set(
(profilesData?.data || [])
.filter(p => p.allow_short_lived && p.max_ttl_seconds > 0 && p.max_ttl_seconds < 3600)
.map(p => p.id)
);
// Include certs that match short-lived profiles OR certs that expire within 1 hour
const allCerts = certsData?.data || [];
const shortLivedCerts = allCerts.filter(c => {
if (c.status === 'Archived') return false;
if (shortLivedProfileIds.has(c.certificate_profile_id)) return true;
// Also include any cert with < 1 hour of life remaining that is active
const secsRemaining = (new Date(c.expires_at).getTime() - Date.now()) / 1000;
if (secsRemaining > 0 && secsRemaining < 3600 && c.status === 'Active') return true;
return false;
});
// Sort by expiration (soonest first)
shortLivedCerts.sort((a, b) => new Date(a.expires_at).getTime() - new Date(b.expires_at).getTime());
// Stats
const active = shortLivedCerts.filter(c => c.status === 'Active' && daysUntil(c.expires_at) >= 0).length;
const expired = shortLivedCerts.filter(c => c.status === 'Expired' || daysUntil(c.expires_at) < 0).length;
const profiles = new Set(shortLivedCerts.map(c => c.certificate_profile_id).filter(Boolean));
const columns: Column<Certificate>[] = [
{
key: 'name',
label: 'Certificate',
render: (c) => (
<div>
<div className="font-medium text-slate-200">{c.common_name}</div>
<div className="text-xs text-slate-500 mt-0.5">{c.id}</div>
</div>
),
},
{ key: 'status', label: 'Status', render: (c) => <StatusBadge status={c.status} /> },
{
key: 'ttl',
label: 'TTL Remaining',
render: (c) => {
const ttl = ttlRemaining(c.expires_at);
return (
<div className="flex items-center gap-2">
<div className={`font-mono text-sm font-medium ${ttl.color}`}>{ttl.text}</div>
{ttl.seconds > 0 && ttl.seconds < 300 && (
<div className="w-2 h-2 rounded-full bg-red-500 animate-pulse" />
)}
</div>
);
},
},
{
key: 'profile',
label: 'Profile',
render: (c) => {
const profile = profileMap.get(c.certificate_profile_id);
return (
<div>
<div className="text-sm text-slate-300">{profile?.name || c.certificate_profile_id || '—'}</div>
{profile && <div className="text-xs text-slate-500">Max TTL: {formatTTL(profile.max_ttl_seconds)}</div>}
</div>
);
},
},
{ key: 'env', label: 'Environment', render: (c) => <span className="text-slate-300">{c.environment || '—'}</span> },
{ key: 'issuer', label: 'Issuer', render: (c) => <span className="text-slate-400 text-xs">{c.issuer_id}</span> },
{ key: 'expires', label: 'Expires At', render: (c) => <span className="text-xs text-slate-400">{formatDateTime(c.expires_at)}</span> },
];
return (
<>
<PageHeader
title="Short-Lived Credentials"
subtitle={`${shortLivedCerts.length} active ephemeral certificates`}
/>
{/* Stats bar */}
<div className="px-6 py-3 flex gap-6 border-b border-slate-700/50">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-emerald-400" />
<span className="text-xs text-slate-400">Active:</span>
<span className="text-xs font-medium text-emerald-400">{active}</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-red-400" />
<span className="text-xs text-slate-400">Expired:</span>
<span className="text-xs font-medium text-red-400">{expired}</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-blue-400" />
<span className="text-xs text-slate-400">Profiles:</span>
<span className="text-xs font-medium text-blue-400">{profiles.size}</span>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{certsError ? (
<ErrorState error={certsError as Error} onRetry={() => refetch()} />
) : (
<DataTable
columns={columns}
data={shortLivedCerts}
isLoading={certsLoading}
onRowClick={(c) => navigate(`/certificates/${c.id}`)}
emptyMessage="No short-lived credentials found. Certificates with profiles that have TTL < 1 hour will appear here."
/>
)}
</div>
</>
);
}
+234 -2
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { getTargets, deleteTarget } from '../api/client';
import { getTargets, createTarget, deleteTarget } from '../api/client';
import PageHeader from '../components/PageHeader';
import DataTable from '../components/DataTable';
import type { Column } from '../components/DataTable';
@@ -16,8 +17,222 @@ const typeLabels: Record<string, string> = {
haproxy: 'HAProxy',
};
const TARGET_TYPES = [
{ value: 'nginx', label: 'NGINX', description: 'Deploy to NGINX web server via file write + config validation + reload' },
{ value: 'apache', label: 'Apache httpd', description: 'Separate cert/chain/key files, apachectl configtest, graceful reload' },
{ value: 'haproxy', label: 'HAProxy', description: 'Combined PEM file (cert+chain+key), optional validate, reload' },
{ value: 'f5_bigip', label: 'F5 BIG-IP', description: 'iControl REST via proxy agent (V3 implementation)' },
{ value: 'iis', label: 'IIS', description: 'Windows IIS via agent-local PowerShell or proxy WinRM (V3 implementation)' },
];
const CONFIG_FIELDS: Record<string, { key: string; label: string; placeholder: string; required?: boolean }[]> = {
nginx: [
{ key: 'cert_path', label: 'Certificate Path', placeholder: '/etc/nginx/ssl/cert.pem', required: true },
{ key: 'key_path', label: 'Key Path', placeholder: '/etc/nginx/ssl/key.pem', required: true },
{ key: 'chain_path', label: 'Chain Path', placeholder: '/etc/nginx/ssl/chain.pem' },
{ key: 'reload_cmd', label: 'Reload Command', placeholder: 'nginx -t && systemctl reload nginx' },
],
apache: [
{ key: 'cert_path', label: 'Certificate Path', placeholder: '/etc/apache2/ssl/cert.pem', required: true },
{ key: 'key_path', label: 'Key Path', placeholder: '/etc/apache2/ssl/key.pem', required: true },
{ key: 'chain_path', label: 'Chain Path', placeholder: '/etc/apache2/ssl/chain.pem' },
{ key: 'reload_cmd', label: 'Reload Command', placeholder: 'apachectl configtest && apachectl graceful' },
],
haproxy: [
{ key: 'pem_path', label: 'Combined PEM Path', placeholder: '/etc/haproxy/certs/combined.pem', required: true },
{ key: 'reload_cmd', label: 'Reload Command', placeholder: 'systemctl reload haproxy' },
{ key: 'validate_cmd', label: 'Validate Command (optional)', placeholder: 'haproxy -c -f /etc/haproxy/haproxy.cfg' },
],
f5_bigip: [
{ key: 'management_ip', label: 'Management IP', placeholder: '192.168.1.100', required: true },
{ key: 'partition', label: 'Partition', placeholder: 'Common' },
{ key: 'proxy_agent_id', label: 'Proxy Agent ID', placeholder: 'agent-f5-proxy' },
],
iis: [
{ key: 'site_name', label: 'IIS Site Name', placeholder: 'Default Web Site', required: true },
{ key: 'binding_ip', label: 'Binding IP', placeholder: '*' },
{ key: 'binding_port', label: 'Binding Port', placeholder: '443' },
{ key: 'cert_store', label: 'Certificate Store', placeholder: 'My' },
],
};
function CreateTargetWizard({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) {
const [step, setStep] = useState<'type' | 'config' | 'review'>('type');
const [targetType, setTargetType] = useState('');
const [name, setName] = useState('');
const [hostname, setHostname] = useState('');
const [agentId, setAgentId] = useState('');
const [config, setConfig] = useState<Record<string, string>>({});
const [error, setError] = useState('');
const mutation = useMutation({
mutationFn: () => createTarget({
name,
type: targetType,
hostname,
agent_id: agentId,
config: Object.fromEntries(Object.entries(config).filter(([, v]) => v)),
}),
onSuccess: () => onSuccess(),
onError: (err: Error) => setError(err.message),
});
const fields = CONFIG_FIELDS[targetType] || [];
const canProceedToReview = name && targetType && fields.filter(f => f.required).every(f => config[f.key]);
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-slate-800 border border-slate-600 rounded-xl p-6 w-full max-w-lg shadow-2xl" onClick={e => e.stopPropagation()}>
{/* Step indicators */}
<div className="flex items-center gap-3 mb-6">
{['Select Type', 'Configure', 'Review'].map((label, i) => {
const stepNames = ['type', 'config', 'review'] as const;
const currentIdx = stepNames.indexOf(step);
const isActive = i === currentIdx;
const isDone = i < currentIdx;
return (
<div key={label} className="flex items-center gap-2">
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium ${
isDone ? 'bg-emerald-500 text-white' : isActive ? 'bg-blue-500 text-white' : 'bg-slate-700 text-slate-400'
}`}>
{isDone ? '✓' : i + 1}
</div>
<span className={`text-xs ${isActive ? 'text-slate-200' : 'text-slate-500'}`}>{label}</span>
{i < 2 && <div className="w-8 h-px bg-slate-700" />}
</div>
);
})}
</div>
{error && <div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-lg px-3 py-2 text-sm mb-4">{error}</div>}
{/* Step 1: Select Type */}
{step === 'type' && (
<div>
<h2 className="text-lg font-semibold text-slate-200 mb-4">Select Target Type</h2>
<div className="space-y-2">
{TARGET_TYPES.map(t => (
<button
key={t.value}
onClick={() => { setTargetType(t.value); setConfig({}); }}
className={`w-full text-left px-4 py-3 rounded-lg border transition-colors ${
targetType === t.value
? 'border-blue-500 bg-blue-500/10'
: 'border-slate-600 hover:border-slate-500 bg-slate-900'
}`}
>
<div className="text-sm font-medium text-slate-200">{t.label}</div>
<div className="text-xs text-slate-400 mt-0.5">{t.description}</div>
</button>
))}
</div>
<div className="flex justify-end gap-3 mt-6">
<button onClick={onClose} className="btn btn-ghost text-sm">Cancel</button>
<button onClick={() => setStep('config')} disabled={!targetType}
className="btn btn-primary text-sm disabled:opacity-50">Next</button>
</div>
</div>
)}
{/* Step 2: Configure */}
{step === 'config' && (
<div>
<h2 className="text-lg font-semibold text-slate-200 mb-4">
Configure {typeLabels[targetType] || targetType} Target
</h2>
<div className="space-y-3">
<div>
<label className="text-xs text-slate-400 block mb-1">Target Name *</label>
<input value={name} onChange={e => 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"
placeholder="web-server-1" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-slate-400 block mb-1">Hostname</label>
<input value={hostname} onChange={e => 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"
placeholder="web1.example.com" />
</div>
<div>
<label className="text-xs text-slate-400 block mb-1">Agent ID</label>
<input value={agentId} onChange={e => 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"
placeholder="agent-web1" />
</div>
</div>
{fields.map(f => (
<div key={f.key}>
<label className="text-xs text-slate-400 block mb-1">{f.label} {f.required ? '*' : ''}</label>
<input value={config[f.key] || ''} onChange={e => 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"
placeholder={f.placeholder} />
</div>
))}
</div>
<div className="flex justify-between gap-3 mt-6">
<button onClick={() => setStep('type')} className="btn btn-ghost text-sm">Back</button>
<div className="flex gap-3">
<button onClick={onClose} className="btn btn-ghost text-sm">Cancel</button>
<button onClick={() => setStep('review')} disabled={!canProceedToReview}
className="btn btn-primary text-sm disabled:opacity-50">Review</button>
</div>
</div>
</div>
)}
{/* Step 3: Review */}
{step === 'review' && (
<div>
<h2 className="text-lg font-semibold text-slate-200 mb-4">Review Target</h2>
<div className="bg-slate-900 rounded-lg p-4 space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-slate-400">Name</span>
<span className="text-slate-200">{name}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-400">Type</span>
<span className="text-slate-200">{typeLabels[targetType] || targetType}</span>
</div>
{hostname && (
<div className="flex justify-between">
<span className="text-slate-400">Hostname</span>
<span className="text-slate-200 font-mono text-xs">{hostname}</span>
</div>
)}
{agentId && (
<div className="flex justify-between">
<span className="text-slate-400">Agent</span>
<span className="text-slate-200 font-mono text-xs">{agentId}</span>
</div>
)}
{Object.entries(config).filter(([, v]) => v).map(([k, v]) => (
<div key={k} className="flex justify-between">
<span className="text-slate-400">{k.replace(/_/g, ' ')}</span>
<span className="text-slate-200 font-mono text-xs truncate max-w-xs">{v}</span>
</div>
))}
</div>
<div className="flex justify-between gap-3 mt-6">
<button onClick={() => setStep('config')} className="btn btn-ghost text-sm">Back</button>
<div className="flex gap-3">
<button onClick={onClose} className="btn btn-ghost text-sm">Cancel</button>
<button onClick={() => mutation.mutate()} disabled={mutation.isPending}
className="btn btn-primary text-sm disabled:opacity-50">
{mutation.isPending ? 'Creating...' : 'Create Target'}
</button>
</div>
</div>
</div>
)}
</div>
</div>
);
}
export default function TargetsPage() {
const queryClient = useQueryClient();
const [showCreate, setShowCreate] = useState(false);
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['targets'],
@@ -83,7 +298,15 @@ export default function TargetsPage() {
return (
<>
<PageHeader title="Deployment Targets" subtitle={data ? `${data.total} targets` : undefined} />
<PageHeader
title="Deployment Targets"
subtitle={data ? `${data.total} targets` : undefined}
action={
<button onClick={() => setShowCreate(true)} className="btn btn-primary text-xs">
+ New Target
</button>
}
/>
<div className="flex-1 overflow-y-auto">
{error ? (
<ErrorState error={error as Error} onRetry={() => refetch()} />
@@ -91,6 +314,15 @@ export default function TargetsPage() {
<DataTable columns={columns} data={data?.data || []} isLoading={isLoading} emptyMessage="No deployment targets" />
)}
</div>
{showCreate && (
<CreateTargetWizard
onClose={() => setShowCreate(false)}
onSuccess={() => {
setShowCreate(false);
queryClient.invalidateQueries({ queryKey: ['targets'] });
}}
/>
)}
</>
);
}