Files
certctl/web/src/pages/TargetsPage.tsx
T
shankar0123 a53a4b845b fix(gui,api): close C-001 + C-002 — ownership + agent FK contract
C-001 — CreateCertificate was server-accepted with null owner_id,
team_id, renewal_policy_id because the GUI neither collected the fields
nor enforced them, even though the backend's ManagedCertificate schema
and handler contract treat them as required. Fix the contract at all
four layers:

  - web/src/pages/CertificatesPage.tsx: replace owner_id/team_id free-
    text inputs with <select> elements fed by getOwners/getTeams/
    getPolicies queries; mark all three required; gate the Create
    button on owner_id + team_id + renewal_policy_id being set.
  - internal/api/handler/certificates.go: ValidateRequired for
    owner_id, team_id, renewal_policy_id on CreateCertificate so the
    handler returns HTTP 400 with the offending field name before the
    service layer is reached.
  - internal/mcp/types.go: drop ',omitempty' from
    CreateCertificateInput.RenewalPolicyID so the MCP schema reflects
    the required contract; Update inputs keep partial-update semantics.
  - api/openapi.yaml: 'required: [name, common_name, renewal_policy_id,
    issuer_id, owner_id, team_id]' was already present on the Create
    schema; clarified DeploymentTarget.agent_id description to note the
    FK contract.

C-002 — CreateTargetWizard accepted an empty or bogus agent_id and the
service inserted directly, producing a Postgres 23503 FK-violation that
bubbled out as a generic HTTP 500. The FK itself (migration 000001 line
104: agent_id TEXT NOT NULL REFERENCES agents(id)) is correct; we keep
the schema strict and add validation at three layers:

  - internal/service/target.go: introduce
    ErrAgentNotFound sentinel and pre-validate agent_id in
    TargetService.CreateTarget — empty string returns
    'agent_id is required'; a nonexistent id returns the full
    'referenced agent does not exist: <id>' error. Both wrap
    ErrAgentNotFound via fmt.Errorf %w so callers can use errors.Is.
  - internal/api/handler/targets.go: ValidateRequired on agent_id; map
    errors.Is(err, service.ErrAgentNotFound) to HTTP 400 instead of
    letting it fall through to the generic 500 branch.
  - internal/mcp/types.go: drop ',omitempty' from
    CreateTargetInput.AgentID to match the required contract.
  - web/src/pages/TargetsPage.tsx: replace the free-text Agent ID input
    with a <select> populated from getAgents(); include agent in the
    canProceedToReview gate so Next is disabled until an agent is
    chosen.

Regression coverage (21 new subtests total):

  - TestCreateCertificate_MissingRequiredField_Returns400 — 6 subtests,
    one per required field, each proves the handler guard fires before
    the mock service is called.
  - TestCreateTarget_MissingAgentID_Returns400 — handler guard.
  - TestCreateTarget_NonexistentAgent_Returns400 — pins the
    ErrAgentNotFound -> 400 translation.
  - TestTargetService_CreateTarget_MissingAgentID — errors.Is sentinel.
  - TestTargetService_CreateTarget_NonexistentAgentID — errors.Is.
  - The existing TestTargetService_CreateTarget_Success, along with
    TestCreateTarget_{MissingName,MissingType,NameTooLong}_* handler
    tests, were updated to seed a real agent or include agent_id in
    the request body so the happy paths still run cleanly.

Gates (Phase 4):
  - go build/vet/test/race: green
  - go test -cover: internal/service 68.7% (gate 55%),
    internal/api/handler 78.9% (gate 60%)
  - golangci-lint on service+handler+mcp: 0 issues
  - govulncheck: no reachable vulns
  - tsc --noEmit: clean
  - vitest: 223/223 passing

See cowork/certctl-coverage-gap-audit.md entries C-001 and C-002.
2026-04-18 16:01:40 +00:00

502 lines
25 KiB
TypeScript

import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { getTargets, createTarget, deleteTarget, getAgents } 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 } from '../api/utils';
import type { Target } from '../api/types';
const typeLabels: Record<string, string> = {
NGINX: 'NGINX',
Apache: 'Apache',
HAProxy: 'HAProxy',
Traefik: 'Traefik',
Caddy: 'Caddy',
Envoy: 'Envoy',
Postfix: 'Postfix',
Dovecot: 'Dovecot',
F5: 'F5 BIG-IP',
IIS: 'IIS',
SSH: 'SSH',
WinCertStore: 'Windows Cert Store',
JavaKeystore: 'Java Keystore',
KubernetesSecrets: 'Kubernetes Secrets',
};
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: 'Traefik', label: 'Traefik', description: 'File provider deployment — writes cert/key to watched directory, auto-reload' },
{ value: 'Caddy', label: 'Caddy', description: 'Admin API hot-reload or file-based deployment with configurable mode' },
{ value: 'Envoy', label: 'Envoy', description: 'File-based deployment — writes cert/key to watched directory. Optional SDS file generation.' },
{ value: 'Postfix', label: 'Postfix', description: 'Postfix MTA — file write + postfix reload' },
{ value: 'Dovecot', label: 'Dovecot', description: 'Dovecot IMAP/POP3 — file write + doveadm reload' },
{ value: 'F5', label: 'F5 BIG-IP', description: 'iControl REST — cert upload, SSL profile update via proxy agent' },
{ value: 'IIS', label: 'IIS', description: 'Windows IIS via agent-local PowerShell or remote WinRM proxy agent' },
{ value: 'SSH', label: 'SSH', description: 'Agentless deployment via SSH/SFTP — deploy to any Linux/Unix server without installing an agent' },
{ value: 'WinCertStore', label: 'Windows Cert Store', description: 'Import certificates into Windows Certificate Store for Exchange, RDP, SQL Server, ADFS' },
{ value: 'JavaKeystore', label: 'Java Keystore', description: 'Deploy to JKS/PKCS#12 keystores for Tomcat, Jetty, Kafka, Elasticsearch, and JVM services' },
{ value: 'KubernetesSecrets', label: 'Kubernetes Secrets', description: 'Deploy as kubernetes.io/tls Secrets for Ingress controllers, service meshes, and workloads' },
];
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_command', label: 'Reload Command', placeholder: 'nginx -s reload' },
{ key: 'validate_command', label: 'Validate Command', placeholder: 'nginx -t' },
],
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_command', label: 'Reload Command', placeholder: 'apachectl graceful' },
{ key: 'validate_command', label: 'Validate Command', placeholder: 'apachectl configtest' },
],
HAProxy: [
{ key: 'pem_path', label: 'Combined PEM Path', placeholder: '/etc/haproxy/certs/combined.pem', required: true },
{ key: 'reload_command', label: 'Reload Command', placeholder: 'systemctl reload haproxy' },
{ key: 'validate_command', label: 'Validate Command (optional)', placeholder: 'haproxy -c -f /etc/haproxy/haproxy.cfg' },
],
Traefik: [
{ key: 'cert_dir', label: 'Certificate Directory', placeholder: '/etc/traefik/certs', required: true },
{ key: 'cert_file', label: 'Certificate Filename', placeholder: 'cert.pem (default)' },
{ key: 'key_file', label: 'Key Filename', placeholder: 'key.pem (default)' },
],
Caddy: [
{ key: 'mode', label: 'Deployment Mode', placeholder: 'api (default) or file', required: true },
{ key: 'admin_api', label: 'Admin API URL', placeholder: 'http://localhost:2019 (default)' },
{ key: 'cert_dir', label: 'Certificate Directory (file mode)', placeholder: '/etc/caddy/certs' },
{ key: 'cert_file', label: 'Certificate Filename', placeholder: 'cert.pem (default)' },
{ key: 'key_file', label: 'Key Filename', placeholder: 'key.pem (default)' },
],
Envoy: [
{ key: 'cert_dir', label: 'Certificate Directory', placeholder: '/etc/envoy/certs', required: true },
{ key: 'cert_filename', label: 'Certificate Filename', placeholder: 'cert.pem (default)' },
{ key: 'key_filename', label: 'Key Filename', placeholder: 'key.pem (default)' },
{ key: 'chain_filename', label: 'Chain Filename (optional)', placeholder: 'chain.pem (leave empty to append to cert)' },
{ key: 'sds_config', label: 'Generate SDS Config', placeholder: 'true or false' },
],
Postfix: [
{ key: 'cert_path', label: 'Certificate Path', placeholder: '/etc/postfix/certs/cert.pem' },
{ key: 'key_path', label: 'Key Path', placeholder: '/etc/postfix/certs/key.pem' },
{ key: 'chain_path', label: 'Chain Path (optional)', placeholder: '/etc/postfix/certs/chain.pem' },
{ key: 'reload_command', label: 'Reload Command', placeholder: 'postfix reload' },
{ key: 'validate_command', label: 'Validate Command', placeholder: 'postfix check' },
],
Dovecot: [
{ key: 'mode', label: 'Mode', placeholder: 'dovecot (auto-set)' },
{ key: 'cert_path', label: 'Certificate Path', placeholder: '/etc/dovecot/certs/cert.pem' },
{ key: 'key_path', label: 'Key Path', placeholder: '/etc/dovecot/certs/key.pem' },
{ key: 'chain_path', label: 'Chain Path (optional)', placeholder: '/etc/dovecot/certs/chain.pem' },
{ key: 'reload_command', label: 'Reload Command', placeholder: 'doveadm reload' },
{ key: 'validate_command', label: 'Validate Command', placeholder: 'doveconf -n' },
],
F5: [
{ key: 'host', label: 'Management Host', placeholder: 'f5.internal.example.com', required: true },
{ key: 'port', label: 'Management Port', placeholder: '443' },
{ key: 'username', label: 'Username', placeholder: 'admin', required: true },
{ key: 'password', label: 'Password', placeholder: 'F5 admin password', required: true },
{ key: 'partition', label: 'Partition', placeholder: 'Common' },
{ key: 'ssl_profile', label: 'SSL Profile', placeholder: 'clientssl_api', required: true },
{ key: 'insecure', label: 'Skip TLS Verify', placeholder: 'true (default)' },
{ key: 'timeout', label: 'Timeout (seconds)', placeholder: '30' },
],
IIS: [
{ key: 'hostname', label: 'Target Hostname', placeholder: 'iis-server.example.com' },
{ key: 'site_name', label: 'IIS Site Name', placeholder: 'Default Web Site', required: true },
{ key: 'cert_store', label: 'Certificate Store', placeholder: 'My', required: true },
{ key: 'port', label: 'HTTPS Port', placeholder: '443' },
{ key: 'ip_address', label: 'Binding IP', placeholder: '*' },
{ key: 'binding_info', label: 'Host Header (SNI)', placeholder: 'www.example.com' },
{ key: 'sni', label: 'Enable SNI', placeholder: 'true or false' },
{ key: 'mode', label: 'Deployment Mode', placeholder: 'local (default) or winrm' },
{ key: 'winrm_host', label: 'WinRM Host (remote mode)', placeholder: 'iis-server.example.com' },
{ key: 'winrm_port', label: 'WinRM Port', placeholder: '5985 (HTTP) or 5986 (HTTPS)' },
{ key: 'winrm_username', label: 'WinRM Username', placeholder: 'Administrator' },
{ key: 'winrm_password', label: 'WinRM Password', placeholder: '(sensitive)' },
{ key: 'winrm_https', label: 'WinRM Use HTTPS', placeholder: 'true or false' },
{ key: 'winrm_insecure', label: 'WinRM Skip TLS Verify', placeholder: 'false' },
{ key: 'winrm_timeout', label: 'WinRM Timeout (seconds)', placeholder: '60' },
],
SSH: [
{ key: 'host', label: 'SSH Host', placeholder: '192.168.1.100 or server.example.com', required: true },
{ key: 'port', label: 'SSH Port', placeholder: '22 (default)' },
{ key: 'user', label: 'SSH Username', placeholder: 'root or certctl', required: true },
{ key: 'auth_method', label: 'Auth Method', placeholder: 'key (default) or password' },
{ key: 'private_key_path', label: 'Private Key Path', placeholder: '/home/certctl/.ssh/id_ed25519' },
{ key: 'private_key', label: 'Inline Private Key PEM', placeholder: 'Paste PEM key (alternative to path)' },
{ key: 'password', label: 'SSH Password', placeholder: 'Leave empty for key auth' },
{ key: 'passphrase', label: 'Key Passphrase', placeholder: 'For encrypted private keys' },
{ key: 'cert_path', label: 'Remote Certificate Path', placeholder: '/etc/ssl/certs/cert.pem', required: true },
{ key: 'key_path', label: 'Remote Key Path', placeholder: '/etc/ssl/private/key.pem', required: true },
{ key: 'chain_path', label: 'Remote Chain Path (optional)', placeholder: '/etc/ssl/certs/chain.pem' },
{ key: 'cert_mode', label: 'Cert File Permissions', placeholder: '0644 (default)' },
{ key: 'key_mode', label: 'Key File Permissions', placeholder: '0600 (default)' },
{ key: 'reload_command', label: 'Reload Command (optional)', placeholder: 'systemctl reload nginx' },
{ key: 'timeout', label: 'Connection Timeout (seconds)', placeholder: '30 (default)' },
],
WinCertStore: [
{ key: 'store_name', label: 'Certificate Store', placeholder: 'My (default)', required: true },
{ key: 'store_location', label: 'Store Location', placeholder: 'LocalMachine (default) or CurrentUser' },
{ key: 'friendly_name', label: 'Friendly Name (optional)', placeholder: 'My Production Cert' },
{ key: 'remove_expired', label: 'Remove Expired Certs', placeholder: 'false (default)' },
{ key: 'mode', label: 'Deployment Mode', placeholder: 'local (default) or winrm' },
{ key: 'winrm_host', label: 'WinRM Host (remote mode)', placeholder: 'win-server.example.com' },
{ key: 'winrm_port', label: 'WinRM Port', placeholder: '5985 (HTTP) or 5986 (HTTPS)' },
{ key: 'winrm_username', label: 'WinRM Username', placeholder: 'Administrator' },
{ key: 'winrm_password', label: 'WinRM Password', placeholder: '(sensitive)' },
{ key: 'winrm_https', label: 'WinRM Use HTTPS', placeholder: 'true or false' },
{ key: 'winrm_insecure', label: 'WinRM Skip TLS Verify', placeholder: 'false' },
],
JavaKeystore: [
{ key: 'keystore_path', label: 'Keystore Path', placeholder: '/opt/app/conf/keystore.p12', required: true },
{ key: 'keystore_password', label: 'Keystore Password', placeholder: 'changeit', required: true },
{ key: 'keystore_type', label: 'Keystore Type', placeholder: 'PKCS12 (default) or JKS' },
{ key: 'alias', label: 'Key Alias', placeholder: 'server (default)' },
{ key: 'create_keystore', label: 'Create Keystore If Missing', placeholder: 'true (default)' },
{ key: 'reload_command', label: 'Reload Command (optional)', placeholder: 'systemctl restart tomcat' },
{ key: 'keytool_path', label: 'Keytool Path (optional)', placeholder: 'keytool (default, from PATH)' },
],
KubernetesSecrets: [
{ key: 'namespace', label: 'Namespace', placeholder: 'default', required: true },
{ key: 'secret_name', label: 'Secret Name', placeholder: 'my-tls-secret', required: true },
{ key: 'labels', label: 'Labels (JSON)', placeholder: '{"app": "my-app"}' },
{ key: 'kubeconfig_path', label: 'Kubeconfig Path (optional)', placeholder: '/home/agent/.kube/config' },
],
};
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 [agentId, setAgentId] = useState('');
const [config, setConfig] = useState<Record<string, string>>({});
const [error, setError] = useState('');
// C-002: agent_id is a NOT NULL FK in deployment_targets (migration 000001
// line 104). Load registered agents so the user picks a valid FK instead of
// typing a free-text ID that would 400 at the service layer (or, pre-fix,
// bubble up as a Postgres 23503 foreign-key violation → 500).
const { data: agentsResp } = useQuery({
queryKey: ['agents', 'form'],
queryFn: () => getAgents({ per_page: '500' }),
});
const agents = agentsResp?.data || [];
// Fields that backends expect as boolean (Go bool)
const BOOL_FIELDS = new Set([
'sni', 'insecure', 'sds_config', 'remove_expired', 'create_keystore',
'winrm_https', 'winrm_insecure',
]);
// Fields that backends expect as integer (Go int)
const INT_FIELDS = new Set([
'port', 'timeout', 'winrm_port', 'winrm_timeout', 'timeout_seconds',
]);
// Coerce string form values to their Go types
const coerceValue = (key: string, val: string): unknown => {
if (BOOL_FIELDS.has(key)) return val === 'true';
if (INT_FIELDS.has(key)) { const n = parseInt(val, 10); return isNaN(n) ? val : n; }
return val;
};
// Build config payload with type-specific transformations
const buildConfigPayload = () => {
const flat = Object.fromEntries(Object.entries(config).filter(([, v]) => v));
// Dovecot uses the same Postfix connector with mode="dovecot"
if (targetType === 'Dovecot' && !flat['mode']) {
flat['mode'] = 'dovecot';
}
// IIS backend expects WinRM fields nested under "winrm" key
if (targetType === 'IIS') {
const iisWinrmKeys = ['winrm_host', 'winrm_port', 'winrm_username', 'winrm_password', 'winrm_https', 'winrm_insecure', 'winrm_timeout'];
const winrmObj: Record<string, unknown> = {};
const result: Record<string, unknown> = {};
for (const [k, v] of Object.entries(flat)) {
if (iisWinrmKeys.includes(k)) {
winrmObj[k] = coerceValue(k, v);
} else {
result[k] = coerceValue(k, v);
}
}
if (Object.keys(winrmObj).length > 0) {
result['winrm'] = winrmObj;
}
return result;
}
// All other target types: coerce values to proper Go types
const result: Record<string, unknown> = {};
for (const [k, v] of Object.entries(flat)) {
result[k] = coerceValue(k, v);
}
return result;
};
const mutation = useMutation({
mutationFn: () => createTarget({
name,
type: targetType,
agent_id: agentId,
config: buildConfigPayload(),
}),
onSuccess: () => onSuccess(),
onError: (err: Error) => setError(err.message),
});
const fields = CONFIG_FIELDS[targetType] || [];
const canProceedToReview = name && targetType && agentId && fields.filter(f => f.required).every(f => config[f.key]);
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-surface border border-surface-border rounded p-5 w-full max-w-lg shadow-xl" 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-600 text-white' : isActive ? 'bg-brand-400 text-white' : 'bg-surface-border text-ink-muted'
}`}>
{isDone ? '✓' : i + 1}
</div>
<span className={`text-xs ${isActive ? 'text-ink' : 'text-ink-faint'}`}>{label}</span>
{i < 2 && <div className="w-8 h-px bg-surface-border" />}
</div>
);
})}
</div>
{error && <div className="bg-red-50 border border-red-200 text-red-700 rounded px-3 py-2 text-sm mb-4">{error}</div>}
{/* Step 1: Select Type */}
{step === 'type' && (
<div>
<h2 className="text-lg font-semibold text-ink 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 border transition-colors ${
targetType === t.value
? 'border-brand-400 bg-brand-50'
: 'border-surface-border hover:border-surface-border bg-white'
}`}
>
<div className="text-sm font-medium text-ink">{t.label}</div>
<div className="text-xs text-ink-muted 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-ink mb-4">
Configure {typeLabels[targetType] || targetType} Target
</h2>
<div className="space-y-3">
<div>
<label className="text-xs text-ink-muted block mb-1">Target Name *</label>
<input value={name} onChange={e => setName(e.target.value)}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
placeholder="web-server-1" />
</div>
<div>
<label className="text-xs text-ink-muted block mb-1">Agent *</label>
<select value={agentId} onChange={e => setAgentId(e.target.value)}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400">
<option value="">Select an agent...</option>
{agents.map(a => (
<option key={a.id} value={a.id}>
{a.hostname || a.id} ({a.id})
</option>
))}
</select>
</div>
{fields.map(f => (
<div key={f.key}>
<label className="text-xs text-ink-muted 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-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
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-ink mb-4">Review Target</h2>
<div className="bg-page rounded p-4 space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-ink-muted">Name</span>
<span className="text-ink">{name}</span>
</div>
<div className="flex justify-between">
<span className="text-ink-muted">Type</span>
<span className="text-ink">{typeLabels[targetType] || targetType}</span>
</div>
{agentId && (
<div className="flex justify-between">
<span className="text-ink-muted">Agent</span>
<span className="text-ink 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-ink-muted">{k.replace(/_/g, ' ')}</span>
<span className="text-ink 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'],
queryFn: () => getTargets(),
});
const deleteMutation = useMutation({
mutationFn: deleteTarget,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['targets'] }),
});
const columns: Column<Target>[] = [
{
key: 'name',
label: 'Target',
render: (t) => (
<div>
<Link to={`/targets/${t.id}`} className="font-medium text-accent hover:text-accent-bright" onClick={(e) => e.stopPropagation()}>
{t.name}
</Link>
<div className="text-xs text-ink-faint font-mono">{t.id}</div>
</div>
),
},
{
key: 'type',
label: 'Type',
render: (t) => (
<span className="badge badge-neutral">{typeLabels[t.type] || t.type}</span>
),
},
{
key: 'agent',
label: 'Agent',
render: (t) => <span className="text-xs text-ink-muted font-mono">{t.agent_id || '\u2014'}</span>,
},
{
key: 'enabled',
label: 'Status',
render: (t) => <StatusBadge status={t.enabled ? 'Enabled' : 'Disabled'} />,
},
{
key: 'test_status',
label: 'Connection',
render: (t) => {
if (!t.test_status || t.test_status === 'untested') return <span className="text-xs text-ink-faint"></span>;
return <StatusBadge status={t.test_status === 'success' ? 'Connected' : 'Failed'} />;
},
},
{
key: 'created',
label: 'Created',
render: (t) => <span className="text-xs text-ink-muted">{formatDateTime(t.created_at)}</span>,
},
{
key: 'actions',
label: '',
render: (t) => (
<button
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete target ${t.name}?`)) deleteMutation.mutate(t.id); }}
className="text-xs text-red-600 hover:text-red-700 transition-colors"
>
Delete
</button>
),
},
];
return (
<>
<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()} />
) : (
<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'] });
}}
/>
)}
</>
);
}