Files
certctl/web/src/pages/JobDetailPage.tsx
T
Shankar f1a58d6b4c fix: resolve frontend-to-backend mapping gaps across API types, config fields, and issuer IDs
Full audit of all ~100 backend API endpoints against frontend client functions
and TypeScript interfaces. Fixes field name mismatches, missing client functions,
phantom interface fields, type coercion for Go bool/int config fields, and
issuer type ID alignment with backend domain constants.

Backend:
- issuer.go/target.go: GUI-created entities default enabled=true (Go bool
  zero value was overriding DB DEFAULT)

Frontend types (types.ts):
- Certificate: fingerprint→fingerprint_sha256, phantom fields made optional
- CertificateVersion: fingerprint→fingerprint_sha256, chain_pem→pem_chain,
  removed phantom version/cert_pem fields
- Job: error_message→last_error (matches Go json tag)

Frontend client (client.ts):
- Added getNotification(id) and getAuditEvent(id) for existing backend routes

Frontend pages:
- CertificateDetailPage: derives serial/fingerprint/issuedAt from latest
  CertificateVersion instead of empty Certificate fields
- JobsPage/JobDetailPage: error_message→last_error
- TargetsPage: reload_cmd→reload_command, validate_cmd→validate_command,
  added missing config fields per backend structs (validate_command for
  NGINX/Apache, hostname/winrm_timeout for IIS, private_key/passphrase/
  cert_mode/key_mode for SSH, winrm_https/winrm_insecure for WinCertStore,
  create_keystore for JavaKeystore, mode for Dovecot), type coercion via
  buildConfigPayload() with BOOL_FIELDS/INT_FIELDS sets, IIS WinRM nesting
- TargetDetailPage: added passphrase to sensitiveKeys redaction
- issuerTypes.ts: type IDs aligned to backend constants (acme→ACME,
  local→GenericCA, stepca→StepCA, openssl→OpenSSL), backward compat aliases
  preserved, step-ca config fields updated to match backend struct

Utilities (utils.ts):
- formatDate/formatDateTime accept string|undefined|null

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 21:09:48 -04:00

184 lines
7.3 KiB
TypeScript

import { useParams, Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { getJob, getJobVerification, getAuditEvents } from '../api/client';
import PageHeader from '../components/PageHeader';
import StatusBadge from '../components/StatusBadge';
import ErrorState from '../components/ErrorState';
import { formatDateTime, timeAgo } from '../api/utils';
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex justify-between py-2 border-b border-surface-border/50">
<span className="text-sm text-ink-muted">{label}</span>
<span className="text-sm text-ink">{value}</span>
</div>
);
}
function VerificationBadge({ status }: { status?: string }) {
if (!status) return <span className="text-xs text-ink-faint"></span>;
const styles: Record<string, string> = {
success: 'bg-emerald-100 text-emerald-700',
failed: 'bg-red-100 text-red-700',
pending: 'bg-yellow-100 text-yellow-700',
skipped: 'bg-gray-100 text-gray-600',
};
const labels: Record<string, string> = {
success: 'Verified',
failed: 'Failed',
pending: 'Pending',
skipped: 'Skipped',
};
return (
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${styles[status] || 'bg-gray-100 text-gray-600'}`}>
{labels[status] || status}
</span>
);
}
export default function JobDetailPage() {
const { id } = useParams<{ id: string }>();
const { data: job, isLoading, error, refetch } = useQuery({
queryKey: ['job', id],
queryFn: () => getJob(id!),
enabled: !!id,
refetchInterval: 10000,
});
const { data: verification } = useQuery({
queryKey: ['job-verification', id],
queryFn: () => getJobVerification(id!),
enabled: !!id && job?.type === 'Deployment' && job?.status === 'Completed',
retry: false,
});
const { data: auditData } = useQuery({
queryKey: ['audit', { resource_id: id }],
queryFn: () => getAuditEvents({ resource_id: id!, per_page: '10' }),
enabled: !!id,
});
if (error) {
return (
<>
<PageHeader title="Job Details" />
<ErrorState error={error as Error} onRetry={() => refetch()} />
</>
);
}
if (isLoading || !job) {
return (
<>
<PageHeader title="Job Details" />
<div className="flex items-center justify-center py-20">
<div className="text-sm text-ink-muted">Loading job...</div>
</div>
</>
);
}
return (
<>
<PageHeader
title={`Job ${job.id}`}
subtitle={`${job.type} job`}
/>
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Job details */}
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
<h3 className="text-sm font-semibold text-ink-muted mb-4">Job Information</h3>
<InfoRow label="ID" value={<span className="font-mono text-xs">{job.id}</span>} />
<InfoRow label="Type" value={job.type} />
<InfoRow label="Status" value={<StatusBadge status={job.status} />} />
<InfoRow label="Certificate" value={
<Link to={`/certificates/${job.certificate_id}`} className="text-xs text-accent hover:text-accent-bright font-mono">
{job.certificate_id}
</Link>
} />
{job.agent_id && (
<InfoRow label="Agent" value={
<Link to={`/agents/${job.agent_id}`} className="text-xs text-accent hover:text-accent-bright font-mono">
{job.agent_id}
</Link>
} />
)}
{job.target_id && (
<InfoRow label="Target" value={
<Link to={`/targets/${job.target_id}`} className="text-xs text-accent hover:text-accent-bright font-mono">
{job.target_id}
</Link>
} />
)}
<InfoRow label="Attempts" value={`${job.attempts} / ${job.max_attempts}`} />
{job.last_error && (
<InfoRow label="Error" value={
<span className="text-red-600 text-xs">{job.last_error}</span>
} />
)}
</div>
{/* Timeline */}
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
<h3 className="text-sm font-semibold text-ink-muted mb-4">Timeline</h3>
<InfoRow label="Created" value={formatDateTime(job.created_at)} />
<InfoRow label="Scheduled" value={formatDateTime(job.scheduled_at)} />
{job.started_at && <InfoRow label="Started" value={formatDateTime(job.started_at)} />}
{job.completed_at && <InfoRow label="Completed" value={formatDateTime(job.completed_at)} />}
{job.completed_at && job.started_at && (
<InfoRow label="Duration" value={timeAgo(job.started_at)} />
)}
</div>
</div>
{/* Verification section — only for deployment jobs */}
{job.type === 'Deployment' && (
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
<h3 className="text-sm font-semibold text-ink-muted mb-4">Post-Deployment Verification</h3>
{job.verification_status ? (
<div className="space-y-0">
<InfoRow label="Status" value={<VerificationBadge status={job.verification_status} />} />
{job.verified_at && <InfoRow label="Verified At" value={formatDateTime(job.verified_at)} />}
{job.verification_fingerprint && (
<InfoRow label="Fingerprint" value={<span className="font-mono text-xs">{job.verification_fingerprint}</span>} />
)}
{job.verification_error && (
<InfoRow label="Error" value={<span className="text-red-600 text-xs">{job.verification_error}</span>} />
)}
{verification && verification.verified && (
<InfoRow label="Expected Fingerprint" value={<span className="font-mono text-xs">{verification.expected_fingerprint}</span>} />
)}
</div>
) : (
<div className="text-sm text-ink-faint py-4 text-center">
{job.status === 'Completed' ? 'No verification data recorded' : 'Verification runs after deployment completes'}
</div>
)}
</div>
)}
{/* Audit trail */}
{auditData && auditData.data.length > 0 && (
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
<h3 className="text-sm font-semibold text-ink-muted mb-4">Related Audit Events</h3>
<div className="space-y-2">
{auditData.data.map(event => (
<div key={event.id} className="flex items-center justify-between py-2 border-b border-surface-border/50 last:border-0">
<div>
<span className="text-sm text-ink">{event.action}</span>
<span className="text-xs text-ink-faint ml-2">by {event.actor}</span>
</div>
<span className="text-xs text-ink-muted">{formatDateTime(event.timestamp)}</span>
</div>
))}
</div>
</div>
)}
</div>
</>
);
}