mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 21:41:39 +00:00
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:
@@ -365,7 +365,7 @@ make docker-clean # Stop + remove volumes
|
||||
## Roadmap
|
||||
|
||||
### V1 (v1.0.0 released)
|
||||
All nine development milestones (M1–M9) 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 (M1–M9) 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 (M1–M9) 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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'] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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'] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user