mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 14:21:37 +00:00
feat(M35): dynamic target configuration with encrypted config, test connection, and GUI updates
Mirror M34's dynamic issuer config pattern for deployment targets: AES-256-GCM encrypted config storage, sensitive field redaction in API responses, agent heartbeat-based test connection endpoint, and full frontend updates including test status indicators, source badges, and removal of stale hostname/status fields from the Target interface. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -185,7 +185,7 @@ func main() {
|
||||
logger.Error("failed to build issuer registry from database", "error", err)
|
||||
}
|
||||
logger.Info("issuer registry loaded", "issuers", issuerRegistry.Len())
|
||||
targetService := service.NewTargetService(targetRepo, auditService)
|
||||
targetService := service.NewTargetService(targetRepo, auditService, agentRepo, encryptionKey, logger)
|
||||
profileService := service.NewProfileService(profileRepo, auditService)
|
||||
teamService := service.NewTeamService(teamRepo, auditService)
|
||||
ownerService := service.NewOwnerService(ownerRepo, auditService)
|
||||
|
||||
@@ -18,6 +18,7 @@ type MockTargetService struct {
|
||||
CreateTargetFn func(target domain.DeploymentTarget) (*domain.DeploymentTarget, error)
|
||||
UpdateTargetFn func(id string, target domain.DeploymentTarget) (*domain.DeploymentTarget, error)
|
||||
DeleteTargetFn func(id string) error
|
||||
TestTargetConnectionFn func(id string) error
|
||||
}
|
||||
|
||||
func (m *MockTargetService) ListTargets(page, perPage int) ([]domain.DeploymentTarget, int64, error) {
|
||||
@@ -55,6 +56,13 @@ func (m *MockTargetService) DeleteTarget(id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockTargetService) TestTargetConnection(id string) error {
|
||||
if m.TestTargetConnectionFn != nil {
|
||||
return m.TestTargetConnectionFn(id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestListTargets_Success(t *testing.T) {
|
||||
now := time.Now()
|
||||
t1 := domain.DeploymentTarget{
|
||||
@@ -419,3 +427,69 @@ func TestDeleteTarget_EmptyID(t *testing.T) {
|
||||
t.Fatalf("expected status 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestTargetConnection_Success(t *testing.T) {
|
||||
mock := &MockTargetService{
|
||||
TestTargetConnectionFn: func(id string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewTargetHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/targets/t-nginx-01/test", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.TestTargetConnection(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp["status"] != "success" {
|
||||
t.Errorf("expected status 'success', got %v", resp["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestTargetConnection_Failed(t *testing.T) {
|
||||
mock := &MockTargetService{
|
||||
TestTargetConnectionFn: func(id string) error {
|
||||
return ErrMockServiceFailed
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewTargetHandler(mock)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/targets/t-nginx-01/test", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.TestTargetConnection(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp["status"] != "failed" {
|
||||
t.Errorf("expected status 'failed', got %v", resp["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestTargetConnection_MethodNotAllowed(t *testing.T) {
|
||||
handler := NewTargetHandler(&MockTargetService{})
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/targets/t-nginx-01/test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.TestTargetConnection(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("expected status 405, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ type TargetService interface {
|
||||
CreateTarget(target domain.DeploymentTarget) (*domain.DeploymentTarget, error)
|
||||
UpdateTarget(id string, target domain.DeploymentTarget) (*domain.DeploymentTarget, error)
|
||||
DeleteTarget(id string) error
|
||||
TestTargetConnection(id string) error
|
||||
}
|
||||
|
||||
// TargetHandler handles HTTP requests for deployment target operations.
|
||||
@@ -189,3 +190,36 @@ func (h TargetHandler) DeleteTarget(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// TestTargetConnection tests target connectivity by checking the assigned agent's heartbeat.
|
||||
// POST /api/v1/targets/{id}/test
|
||||
func (h TargetHandler) TestTargetConnection(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
// Extract target ID from path: /api/v1/targets/{id}/test
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1/targets/")
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) < 2 || parts[0] == "" {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "Target ID is required", requestID)
|
||||
return
|
||||
}
|
||||
id := parts[0]
|
||||
|
||||
if err := h.svc.TestTargetConnection(id); err != nil {
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"status": "failed",
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"status": "success",
|
||||
"message": "Agent is online and reachable",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -126,6 +126,7 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
|
||||
r.Register("GET /api/v1/targets/{id}", http.HandlerFunc(reg.Targets.GetTarget))
|
||||
r.Register("PUT /api/v1/targets/{id}", http.HandlerFunc(reg.Targets.UpdateTarget))
|
||||
r.Register("DELETE /api/v1/targets/{id}", http.HandlerFunc(reg.Targets.DeleteTarget))
|
||||
r.Register("POST /api/v1/targets/{id}/test", http.HandlerFunc(reg.Targets.TestTargetConnection))
|
||||
|
||||
// Agents routes: /api/v1/agents
|
||||
r.Register("GET /api/v1/agents", http.HandlerFunc(reg.Agents.ListAgents))
|
||||
|
||||
@@ -27,7 +27,11 @@ type DeploymentTarget struct {
|
||||
Type TargetType `json:"type"`
|
||||
AgentID string `json:"agent_id"`
|
||||
Config json.RawMessage `json:"config"`
|
||||
EncryptedConfig []byte `json:"-"` // AES-GCM encrypted full config (never exposed via API)
|
||||
Enabled bool `json:"enabled"`
|
||||
LastTestedAt *time.Time `json:"last_tested_at,omitempty"`
|
||||
TestStatus string `json:"test_status,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -786,6 +786,14 @@ func (m *mockTargetRepository) Create(ctx context.Context, target *domain.Deploy
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockTargetRepository) CreateIfNotExists(ctx context.Context, target *domain.DeploymentTarget) (bool, error) {
|
||||
if _, exists := m.targets[target.ID]; exists {
|
||||
return false, nil
|
||||
}
|
||||
m.targets[target.ID] = target
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (m *mockTargetRepository) Update(ctx context.Context, target *domain.DeploymentTarget) error {
|
||||
m.targets[target.ID] = target
|
||||
return nil
|
||||
@@ -1009,6 +1017,10 @@ func (m *mockTargetService) DeleteTarget(id string) error {
|
||||
return m.targetRepo.Delete(context.Background(), id)
|
||||
}
|
||||
|
||||
func (m *mockTargetService) TestTargetConnection(id string) error {
|
||||
return nil // No-op for integration tests
|
||||
}
|
||||
|
||||
type mockTeamService struct{}
|
||||
|
||||
func (m *mockTeamService) ListTeams(page, perPage int) ([]domain.Team, int64, error) {
|
||||
|
||||
@@ -68,6 +68,9 @@ type TargetRepository interface {
|
||||
Get(ctx context.Context, id string) (*domain.DeploymentTarget, error)
|
||||
// Create stores a new target.
|
||||
Create(ctx context.Context, target *domain.DeploymentTarget) error
|
||||
// CreateIfNotExists creates a target only if the ID doesn't already exist (ON CONFLICT DO NOTHING).
|
||||
// Returns true if created, false if already existed.
|
||||
CreateIfNotExists(ctx context.Context, target *domain.DeploymentTarget) (bool, error)
|
||||
// Update modifies an existing target.
|
||||
Update(ctx context.Context, target *domain.DeploymentTarget) error
|
||||
// Delete removes a target.
|
||||
|
||||
@@ -19,10 +19,40 @@ func NewTargetRepository(db *sql.DB) *TargetRepository {
|
||||
return &TargetRepository{db: db}
|
||||
}
|
||||
|
||||
// scanTarget scans a target row including optional M35 columns (encrypted_config, last_tested_at, test_status, source).
|
||||
func scanTarget(scanner interface {
|
||||
Scan(dest ...interface{}) error
|
||||
}, target *domain.DeploymentTarget) error {
|
||||
var lastTestedAt sql.NullTime
|
||||
var testStatus sql.NullString
|
||||
var source sql.NullString
|
||||
if err := scanner.Scan(
|
||||
&target.ID, &target.Name, &target.Type, &target.AgentID,
|
||||
&target.Config, &target.EncryptedConfig, &target.Enabled,
|
||||
&lastTestedAt, &testStatus, &source,
|
||||
&target.CreatedAt, &target.UpdatedAt,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if lastTestedAt.Valid {
|
||||
target.LastTestedAt = &lastTestedAt.Time
|
||||
}
|
||||
if testStatus.Valid {
|
||||
target.TestStatus = testStatus.String
|
||||
}
|
||||
if source.Valid {
|
||||
target.Source = source.String
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// targetSelectColumns is the standard column list for target queries.
|
||||
const targetSelectColumns = `id, name, type, agent_id, config, COALESCE(encrypted_config, ''::bytea), enabled, last_tested_at, COALESCE(test_status, 'untested'), COALESCE(source, 'database'), created_at, updated_at`
|
||||
|
||||
// List returns all targets
|
||||
func (r *TargetRepository) List(ctx context.Context) ([]*domain.DeploymentTarget, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, name, type, agent_id, config, enabled, created_at, updated_at
|
||||
SELECT `+targetSelectColumns+`
|
||||
FROM deployment_targets
|
||||
ORDER BY created_at DESC
|
||||
`)
|
||||
@@ -35,8 +65,7 @@ func (r *TargetRepository) List(ctx context.Context) ([]*domain.DeploymentTarget
|
||||
var targets []*domain.DeploymentTarget
|
||||
for rows.Next() {
|
||||
var target domain.DeploymentTarget
|
||||
if err := rows.Scan(&target.ID, &target.Name, &target.Type, &target.AgentID,
|
||||
&target.Config, &target.Enabled, &target.CreatedAt, &target.UpdatedAt); err != nil {
|
||||
if err := scanTarget(rows, &target); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan target: %w", err)
|
||||
}
|
||||
targets = append(targets, &target)
|
||||
@@ -52,12 +81,11 @@ func (r *TargetRepository) List(ctx context.Context) ([]*domain.DeploymentTarget
|
||||
// Get retrieves a target by ID
|
||||
func (r *TargetRepository) Get(ctx context.Context, id string) (*domain.DeploymentTarget, error) {
|
||||
var target domain.DeploymentTarget
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, name, type, agent_id, config, enabled, created_at, updated_at
|
||||
err := scanTarget(r.db.QueryRowContext(ctx, `
|
||||
SELECT `+targetSelectColumns+`
|
||||
FROM deployment_targets
|
||||
WHERE id = $1
|
||||
`, id).Scan(&target.ID, &target.Name, &target.Type, &target.AgentID,
|
||||
&target.Config, &target.Enabled, &target.CreatedAt, &target.UpdatedAt)
|
||||
`, id), &target)
|
||||
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
@@ -76,10 +104,11 @@ func (r *TargetRepository) Create(ctx context.Context, target *domain.Deployment
|
||||
}
|
||||
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
INSERT INTO deployment_targets (id, name, type, agent_id, config, enabled, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
INSERT INTO deployment_targets (id, name, type, agent_id, config, encrypted_config, enabled, last_tested_at, test_status, source, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING id
|
||||
`, target.ID, target.Name, target.Type, target.AgentID, target.Config, target.Enabled,
|
||||
`, target.ID, target.Name, target.Type, target.AgentID, target.Config, target.EncryptedConfig,
|
||||
target.Enabled, target.LastTestedAt, target.TestStatus, target.Source,
|
||||
target.CreatedAt, target.UpdatedAt).Scan(&target.ID)
|
||||
|
||||
if err != nil {
|
||||
@@ -89,6 +118,33 @@ func (r *TargetRepository) Create(ctx context.Context, target *domain.Deployment
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateIfNotExists creates a target only if the ID doesn't already exist (ON CONFLICT DO NOTHING).
|
||||
// Returns true if created, false if already existed.
|
||||
func (r *TargetRepository) CreateIfNotExists(ctx context.Context, target *domain.DeploymentTarget) (bool, error) {
|
||||
if target.ID == "" {
|
||||
target.ID = uuid.New().String()
|
||||
}
|
||||
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO deployment_targets (id, name, type, agent_id, config, encrypted_config, enabled, last_tested_at, test_status, source, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
`, target.ID, target.Name, target.Type, target.AgentID, target.Config, target.EncryptedConfig,
|
||||
target.Enabled, target.LastTestedAt, target.TestStatus, target.Source,
|
||||
target.CreatedAt, target.UpdatedAt)
|
||||
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to create target: %w", err)
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to get rows affected: %w", err)
|
||||
}
|
||||
|
||||
return rows > 0, nil
|
||||
}
|
||||
|
||||
// Update modifies an existing target
|
||||
func (r *TargetRepository) Update(ctx context.Context, target *domain.DeploymentTarget) error {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
@@ -97,10 +153,16 @@ func (r *TargetRepository) Update(ctx context.Context, target *domain.Deployment
|
||||
type = $2,
|
||||
agent_id = $3,
|
||||
config = $4,
|
||||
enabled = $5,
|
||||
updated_at = $6
|
||||
WHERE id = $7
|
||||
`, target.Name, target.Type, target.AgentID, target.Config, target.Enabled, target.UpdatedAt, target.ID)
|
||||
encrypted_config = $5,
|
||||
enabled = $6,
|
||||
last_tested_at = $7,
|
||||
test_status = $8,
|
||||
source = $9,
|
||||
updated_at = $10
|
||||
WHERE id = $11
|
||||
`, target.Name, target.Type, target.AgentID, target.Config, target.EncryptedConfig,
|
||||
target.Enabled, target.LastTestedAt, target.TestStatus, target.Source,
|
||||
target.UpdatedAt, target.ID)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update target: %w", err)
|
||||
@@ -141,7 +203,7 @@ func (r *TargetRepository) Delete(ctx context.Context, id string) error {
|
||||
// ListByCertificate returns all targets for a given certificate
|
||||
func (r *TargetRepository) ListByCertificate(ctx context.Context, certID string) ([]*domain.DeploymentTarget, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT dt.id, dt.name, dt.type, dt.agent_id, dt.config, dt.enabled, dt.created_at, dt.updated_at
|
||||
SELECT dt.id, dt.name, dt.type, dt.agent_id, dt.config, COALESCE(dt.encrypted_config, ''::bytea), dt.enabled, dt.last_tested_at, COALESCE(dt.test_status, 'untested'), COALESCE(dt.source, 'database'), dt.created_at, dt.updated_at
|
||||
FROM deployment_targets dt
|
||||
INNER JOIN certificate_target_mappings ctm ON dt.id = ctm.target_id
|
||||
WHERE ctm.certificate_id = $1
|
||||
@@ -156,8 +218,7 @@ func (r *TargetRepository) ListByCertificate(ctx context.Context, certID string)
|
||||
var targets []*domain.DeploymentTarget
|
||||
for rows.Next() {
|
||||
var target domain.DeploymentTarget
|
||||
if err := rows.Scan(&target.ID, &target.Name, &target.Type, &target.AgentID,
|
||||
&target.Config, &target.Enabled, &target.CreatedAt, &target.UpdatedAt); err != nil {
|
||||
if err := scanTarget(rows, &target); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan target: %w", err)
|
||||
}
|
||||
targets = append(targets, &target)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
@@ -193,7 +194,7 @@ func TestConcurrentTargetCRUD(t *testing.T) {
|
||||
Targets: make(map[string]*domain.DeploymentTarget),
|
||||
}
|
||||
|
||||
targetSvc := NewTargetService(mockTargetRepo, nil)
|
||||
targetSvc := NewTargetService(mockTargetRepo, nil, nil, nil, slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||
|
||||
var mu sync.Mutex
|
||||
createdTargets := make([]string, 0)
|
||||
@@ -402,7 +403,7 @@ func TestConcurrentMixedOperations(t *testing.T) {
|
||||
// Setup services
|
||||
auditSvc := &AuditService{auditRepo: mockAuditRepo}
|
||||
certSvc := NewCertificateService(mockCertRepo, nil, auditSvc)
|
||||
targetSvc := NewTargetService(mockTargetRepo, auditSvc)
|
||||
targetSvc := NewTargetService(mockTargetRepo, auditSvc, nil, nil, slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errChan := make(chan error, 30)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// sensitiveKeys are config key substrings that should be redacted in API responses.
|
||||
var sensitiveKeys = []string{"password", "secret", "token", "key", "hmac", "private", "credentials"}
|
||||
|
||||
// isSensitiveConfigKey checks if a config key contains sensitive substrings.
|
||||
func isSensitiveConfigKey(key string) bool {
|
||||
lower := strings.ToLower(key)
|
||||
for _, s := range sensitiveKeys {
|
||||
if strings.Contains(lower, s) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// redactConfigJSON replaces sensitive values in a JSON config with "********".
|
||||
func redactConfigJSON(configJSON json.RawMessage) json.RawMessage {
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(configJSON, &m); err != nil {
|
||||
return configJSON // Not a JSON object, return as-is
|
||||
}
|
||||
|
||||
for k, v := range m {
|
||||
if isSensitiveConfigKey(k) {
|
||||
if str, ok := v.(string); ok && str != "" {
|
||||
m[k] = "********"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
redacted, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return configJSON
|
||||
}
|
||||
return json.RawMessage(redacted)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -141,7 +142,7 @@ func TestTargetService_ListWithCancelledContext(t *testing.T) {
|
||||
mockTargetRepo := &mockTargetRepo{
|
||||
Targets: make(map[string]*domain.DeploymentTarget),
|
||||
}
|
||||
targetSvc := NewTargetService(mockTargetRepo, nil)
|
||||
targetSvc := NewTargetService(mockTargetRepo, nil, nil, nil, slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||
|
||||
_, _, err := targetSvc.List(ctx, 1, 50)
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/config"
|
||||
@@ -16,9 +15,6 @@ import (
|
||||
"github.com/shankar0123/certctl/internal/repository"
|
||||
)
|
||||
|
||||
// sensitiveKeys are config key substrings that should be redacted in API responses.
|
||||
var sensitiveKeys = []string{"password", "secret", "token", "key", "hmac", "private", "credentials"}
|
||||
|
||||
// IssuerService provides business logic for certificate issuer management.
|
||||
type IssuerService struct {
|
||||
issuerRepo repository.IssuerRepository
|
||||
@@ -703,39 +699,6 @@ func (s *IssuerService) updateTestStatus(ctx context.Context, iss *domain.Issuer
|
||||
}
|
||||
}
|
||||
|
||||
// redactConfigJSON replaces sensitive values in a JSON config with "********".
|
||||
func redactConfigJSON(configJSON json.RawMessage) json.RawMessage {
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(configJSON, &m); err != nil {
|
||||
return configJSON // Not a JSON object, return as-is
|
||||
}
|
||||
|
||||
for k, v := range m {
|
||||
if isSensitiveConfigKey(k) {
|
||||
if str, ok := v.(string); ok && str != "" {
|
||||
m[k] = "********"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
redacted, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return configJSON
|
||||
}
|
||||
return json.RawMessage(redacted)
|
||||
}
|
||||
|
||||
// isSensitiveConfigKey checks if a config key contains sensitive substrings.
|
||||
func isSensitiveConfigKey(key string) bool {
|
||||
lower := strings.ToLower(key)
|
||||
for _, s := range sensitiveKeys {
|
||||
if strings.Contains(lower, s) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// getEnvForSeed reads an environment variable for seed data construction.
|
||||
func getEnvForSeed(key string) string {
|
||||
return os.Getenv(key)
|
||||
|
||||
+244
-5
@@ -2,28 +2,58 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/crypto"
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
"github.com/shankar0123/certctl/internal/repository"
|
||||
)
|
||||
|
||||
// validTargetTypes is the set of allowed target types for validation.
|
||||
var validTargetTypes = map[domain.TargetType]bool{
|
||||
domain.TargetTypeNGINX: true,
|
||||
domain.TargetTypeApache: true,
|
||||
domain.TargetTypeHAProxy: true,
|
||||
domain.TargetTypeF5: true,
|
||||
domain.TargetTypeIIS: true,
|
||||
domain.TargetTypeTraefik: true,
|
||||
domain.TargetTypeCaddy: true,
|
||||
domain.TargetTypeEnvoy: true,
|
||||
domain.TargetTypePostfix: true,
|
||||
domain.TargetTypeDovecot: true,
|
||||
}
|
||||
|
||||
// isValidTargetType checks if a type string is a known target type.
|
||||
func isValidTargetType(t domain.TargetType) bool {
|
||||
return validTargetTypes[t]
|
||||
}
|
||||
|
||||
// TargetService provides business logic for deployment target management.
|
||||
type TargetService struct {
|
||||
targetRepo repository.TargetRepository
|
||||
agentRepo repository.AgentRepository
|
||||
auditService *AuditService
|
||||
encryptionKey []byte
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewTargetService creates a new target service.
|
||||
func NewTargetService(
|
||||
targetRepo repository.TargetRepository,
|
||||
auditService *AuditService,
|
||||
agentRepo repository.AgentRepository,
|
||||
encryptionKey []byte,
|
||||
logger *slog.Logger,
|
||||
) *TargetService {
|
||||
return &TargetService{
|
||||
targetRepo: targetRepo,
|
||||
agentRepo: agentRepo,
|
||||
auditService: auditService,
|
||||
encryptionKey: encryptionKey,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,11 +91,14 @@ func (s *TargetService) Get(ctx context.Context, id string) (*domain.DeploymentT
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// Create validates and stores a new deployment target.
|
||||
// Create validates and stores a new deployment target, encrypting sensitive config.
|
||||
func (s *TargetService) Create(ctx context.Context, target *domain.DeploymentTarget, actor string) error {
|
||||
if target.Name == "" {
|
||||
return fmt.Errorf("target name is required")
|
||||
}
|
||||
if !isValidTargetType(target.Type) {
|
||||
return fmt.Errorf("unsupported target type: %s", target.Type)
|
||||
}
|
||||
|
||||
if target.ID == "" {
|
||||
target.ID = generateID("target")
|
||||
@@ -77,33 +110,68 @@ func (s *TargetService) Create(ctx context.Context, target *domain.DeploymentTar
|
||||
if target.UpdatedAt.IsZero() {
|
||||
target.UpdatedAt = now
|
||||
}
|
||||
if target.TestStatus == "" {
|
||||
target.TestStatus = "untested"
|
||||
}
|
||||
if target.Source == "" {
|
||||
target.Source = "database"
|
||||
}
|
||||
|
||||
// Encrypt the full config and store redacted version in config column
|
||||
if len(target.Config) > 0 {
|
||||
encrypted, _, err := crypto.EncryptIfKeySet([]byte(target.Config), s.encryptionKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt config: %w", err)
|
||||
}
|
||||
target.EncryptedConfig = encrypted
|
||||
target.Config = redactConfigJSON(target.Config)
|
||||
}
|
||||
|
||||
if err := s.targetRepo.Create(ctx, target); err != nil {
|
||||
return fmt.Errorf("failed to create target: %w", err)
|
||||
}
|
||||
|
||||
if s.auditService != nil {
|
||||
if auditErr := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser, "create_target", "target", target.ID, nil); auditErr != nil {
|
||||
slog.Error("failed to record audit event", "error", auditErr)
|
||||
s.logger.Error("failed to record audit event", "error", auditErr)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update modifies an existing deployment target.
|
||||
// Update modifies an existing deployment target. Handles "********" preservation for sensitive fields.
|
||||
func (s *TargetService) Update(ctx context.Context, id string, target *domain.DeploymentTarget, actor string) error {
|
||||
if target.Name == "" {
|
||||
return fmt.Errorf("target name is required")
|
||||
}
|
||||
|
||||
target.ID = id
|
||||
target.UpdatedAt = time.Now()
|
||||
|
||||
// If config contains "********" values, merge with existing decrypted config
|
||||
if len(target.Config) > 0 {
|
||||
mergedConfig, err := s.mergeRedactedConfig(ctx, id, target.Config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to merge config: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt the merged config
|
||||
encrypted, _, encErr := crypto.EncryptIfKeySet(mergedConfig, s.encryptionKey)
|
||||
if encErr != nil {
|
||||
return fmt.Errorf("failed to encrypt config: %w", encErr)
|
||||
}
|
||||
target.EncryptedConfig = encrypted
|
||||
target.Config = redactConfigJSON(json.RawMessage(mergedConfig))
|
||||
}
|
||||
|
||||
if err := s.targetRepo.Update(ctx, target); err != nil {
|
||||
return fmt.Errorf("failed to update target %s: %w", id, err)
|
||||
}
|
||||
|
||||
if s.auditService != nil {
|
||||
if auditErr := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser, "update_target", "target", id, nil); auditErr != nil {
|
||||
slog.Error("failed to record audit event", "error", auditErr)
|
||||
s.logger.Error("failed to record audit event", "error", auditErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,13 +186,50 @@ func (s *TargetService) Delete(ctx context.Context, id string, actor string) err
|
||||
|
||||
if s.auditService != nil {
|
||||
if auditErr := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser, "delete_target", "target", id, nil); auditErr != nil {
|
||||
slog.Error("failed to record audit event", "error", auditErr)
|
||||
s.logger.Error("failed to record audit event", "error", auditErr)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestConnection tests a target's connectivity by checking the assigned agent's heartbeat status.
|
||||
// Target connectors run on agents, not on the server, so we can't instantiate a connector here.
|
||||
// Instead, we verify the agent is online and reachable.
|
||||
func (s *TargetService) TestConnection(ctx context.Context, id string) error {
|
||||
target, err := s.targetRepo.Get(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("target not found: %w", err)
|
||||
}
|
||||
|
||||
if target.AgentID == "" {
|
||||
s.updateTestStatus(ctx, target, "failed")
|
||||
return fmt.Errorf("target has no assigned agent")
|
||||
}
|
||||
|
||||
agent, err := s.agentRepo.Get(ctx, target.AgentID)
|
||||
if err != nil {
|
||||
s.updateTestStatus(ctx, target, "failed")
|
||||
return fmt.Errorf("assigned agent not found: %w", err)
|
||||
}
|
||||
|
||||
if agent.Status != domain.AgentStatusOnline {
|
||||
s.updateTestStatus(ctx, target, "failed")
|
||||
return fmt.Errorf("assigned agent %s is %s (expected Online)", agent.ID, agent.Status)
|
||||
}
|
||||
|
||||
// Check heartbeat freshness (agent must have heartbeated within the last 5 minutes)
|
||||
if agent.LastHeartbeatAt != nil {
|
||||
if time.Since(*agent.LastHeartbeatAt) > 5*time.Minute {
|
||||
s.updateTestStatus(ctx, target, "failed")
|
||||
return fmt.Errorf("assigned agent %s last heartbeat was %s ago (stale)", agent.ID, time.Since(*agent.LastHeartbeatAt).Round(time.Second))
|
||||
}
|
||||
}
|
||||
|
||||
s.updateTestStatus(ctx, target, "success")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListTargets returns paginated targets (handler interface method).
|
||||
func (s *TargetService) ListTargets(page, perPage int) ([]domain.DeploymentTarget, int64, error) {
|
||||
if page < 1 {
|
||||
@@ -157,6 +262,9 @@ func (s *TargetService) GetTarget(id string) (*domain.DeploymentTarget, error) {
|
||||
|
||||
// CreateTarget creates a new target (handler interface method).
|
||||
func (s *TargetService) CreateTarget(target domain.DeploymentTarget) (*domain.DeploymentTarget, error) {
|
||||
if !isValidTargetType(target.Type) {
|
||||
return nil, fmt.Errorf("unsupported target type: %s", target.Type)
|
||||
}
|
||||
if target.ID == "" {
|
||||
target.ID = generateID("target")
|
||||
}
|
||||
@@ -167,6 +275,23 @@ func (s *TargetService) CreateTarget(target domain.DeploymentTarget) (*domain.De
|
||||
if target.UpdatedAt.IsZero() {
|
||||
target.UpdatedAt = now
|
||||
}
|
||||
if target.TestStatus == "" {
|
||||
target.TestStatus = "untested"
|
||||
}
|
||||
if target.Source == "" {
|
||||
target.Source = "database"
|
||||
}
|
||||
|
||||
// Encrypt config
|
||||
if len(target.Config) > 0 {
|
||||
encrypted, _, err := crypto.EncryptIfKeySet([]byte(target.Config), s.encryptionKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt config: %w", err)
|
||||
}
|
||||
target.EncryptedConfig = encrypted
|
||||
target.Config = redactConfigJSON(target.Config)
|
||||
}
|
||||
|
||||
if err := s.targetRepo.Create(context.Background(), &target); err != nil {
|
||||
return nil, fmt.Errorf("failed to create target: %w", err)
|
||||
}
|
||||
@@ -176,6 +301,23 @@ func (s *TargetService) CreateTarget(target domain.DeploymentTarget) (*domain.De
|
||||
// UpdateTarget modifies a target (handler interface method).
|
||||
func (s *TargetService) UpdateTarget(id string, target domain.DeploymentTarget) (*domain.DeploymentTarget, error) {
|
||||
target.ID = id
|
||||
target.UpdatedAt = time.Now()
|
||||
|
||||
// Merge redacted fields with existing config
|
||||
if len(target.Config) > 0 {
|
||||
mergedConfig, err := s.mergeRedactedConfig(context.Background(), id, target.Config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to merge config: %w", err)
|
||||
}
|
||||
|
||||
encrypted, _, encErr := crypto.EncryptIfKeySet(mergedConfig, s.encryptionKey)
|
||||
if encErr != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt config: %w", encErr)
|
||||
}
|
||||
target.EncryptedConfig = encrypted
|
||||
target.Config = redactConfigJSON(json.RawMessage(mergedConfig))
|
||||
}
|
||||
|
||||
if err := s.targetRepo.Update(context.Background(), &target); err != nil {
|
||||
return nil, fmt.Errorf("failed to update target: %w", err)
|
||||
}
|
||||
@@ -186,3 +328,100 @@ func (s *TargetService) UpdateTarget(id string, target domain.DeploymentTarget)
|
||||
func (s *TargetService) DeleteTarget(id string) error {
|
||||
return s.targetRepo.Delete(context.Background(), id)
|
||||
}
|
||||
|
||||
// TestTargetConnection tests target connectivity (handler interface method).
|
||||
func (s *TargetService) TestTargetConnection(id string) error {
|
||||
return s.TestConnection(context.Background(), id)
|
||||
}
|
||||
|
||||
// --- Internal helpers ---
|
||||
|
||||
// getDecryptedConfig returns the decrypted config JSON for a target.
|
||||
func (s *TargetService) getDecryptedConfig(target *domain.DeploymentTarget) (json.RawMessage, error) {
|
||||
if len(target.EncryptedConfig) > 0 {
|
||||
decrypted, err := crypto.DecryptIfKeySet(target.EncryptedConfig, s.encryptionKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.RawMessage(decrypted), nil
|
||||
}
|
||||
if len(target.Config) > 0 {
|
||||
return target.Config, nil
|
||||
}
|
||||
return json.RawMessage("{}"), nil
|
||||
}
|
||||
|
||||
// mergeRedactedConfig merges incoming config (which may have "********" values)
|
||||
// with the existing decrypted config so sensitive fields are preserved.
|
||||
func (s *TargetService) mergeRedactedConfig(ctx context.Context, id string, incoming json.RawMessage) ([]byte, error) {
|
||||
// Parse incoming config
|
||||
var incomingMap map[string]interface{}
|
||||
if err := json.Unmarshal(incoming, &incomingMap); err != nil {
|
||||
s.logger.Warn("mergeRedactedConfig: incoming config is not a JSON object, using as-is", "target", id, "error", err)
|
||||
return incoming, nil
|
||||
}
|
||||
|
||||
// Check if any values are "********"
|
||||
hasRedacted := false
|
||||
for _, v := range incomingMap {
|
||||
if str, ok := v.(string); ok && str == "********" {
|
||||
hasRedacted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasRedacted {
|
||||
return incoming, nil // No redacted values, use incoming as-is
|
||||
}
|
||||
|
||||
// Load existing target to get real values
|
||||
existing, err := s.targetRepo.Get(ctx, id)
|
||||
if err != nil {
|
||||
s.logger.Warn("mergeRedactedConfig: could not load existing target, redacted values will be lost", "target", id, "error", err)
|
||||
return incoming, nil
|
||||
}
|
||||
|
||||
existingConfig, err := s.getDecryptedConfig(existing)
|
||||
if err != nil {
|
||||
s.logger.Warn("mergeRedactedConfig: could not decrypt existing config, redacted values will be lost", "target", id, "error", err)
|
||||
return incoming, nil
|
||||
}
|
||||
|
||||
var existingMap map[string]interface{}
|
||||
if err := json.Unmarshal(existingConfig, &existingMap); err != nil {
|
||||
s.logger.Warn("mergeRedactedConfig: existing config is not a JSON object, redacted values will be lost", "target", id, "error", err)
|
||||
return incoming, nil
|
||||
}
|
||||
|
||||
// Merge: for each "********" value in incoming, use existing value
|
||||
for k, v := range incomingMap {
|
||||
if str, ok := v.(string); ok && str == "********" {
|
||||
if existingVal, exists := existingMap[k]; exists {
|
||||
incomingMap[k] = existingVal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return json.Marshal(incomingMap)
|
||||
}
|
||||
|
||||
// updateTestStatus updates the test_status and last_tested_at fields in the database
|
||||
// and records an audit event.
|
||||
func (s *TargetService) updateTestStatus(ctx context.Context, target *domain.DeploymentTarget, status string) {
|
||||
now := time.Now()
|
||||
target.TestStatus = status
|
||||
target.LastTestedAt = &now
|
||||
target.UpdatedAt = now
|
||||
if err := s.targetRepo.Update(ctx, target); err != nil {
|
||||
s.logger.Error("failed to update test status", "target", target.ID, "status", status, "error", err)
|
||||
}
|
||||
|
||||
// Record audit event for connection test
|
||||
if s.auditService != nil {
|
||||
action := "target_test_connection_" + status
|
||||
details := map[string]interface{}{"target_type": string(target.Type), "result": status, "agent_id": target.AgentID}
|
||||
if auditErr := s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem, action, "target", target.ID, details); auditErr != nil {
|
||||
s.logger.Error("failed to record test connection audit event", "error", auditErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+189
-20
@@ -3,21 +3,26 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
)
|
||||
|
||||
// newTestTargetService creates a TargetService with mock repositories for testing.
|
||||
func newTestTargetService() (*TargetService, *mockTargetRepo, *mockAuditRepo) {
|
||||
func newTestTargetService() (*TargetService, *mockTargetRepo, *mockAuditRepo, *mockAgentRepo) {
|
||||
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
|
||||
auditRepo := newMockAuditRepository()
|
||||
auditSvc := NewAuditService(auditRepo)
|
||||
return NewTargetService(targetRepo, auditSvc), targetRepo, auditRepo
|
||||
agentRepo := &mockAgentRepo{Agents: make(map[string]*domain.Agent), HeartbeatUpdates: make(map[string]time.Time)}
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
return NewTargetService(targetRepo, auditSvc, agentRepo, nil, logger), targetRepo, auditRepo, agentRepo
|
||||
}
|
||||
|
||||
func TestTargetService_List_Success(t *testing.T) {
|
||||
svc, targetRepo, _ := newTestTargetService()
|
||||
svc, targetRepo, _, _ := newTestTargetService()
|
||||
ctx := context.Background()
|
||||
|
||||
// Add 3 targets
|
||||
@@ -44,7 +49,7 @@ func TestTargetService_List_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTargetService_List_DefaultPagination(t *testing.T) {
|
||||
svc, _, _ := newTestTargetService()
|
||||
svc, _, _, _ := newTestTargetService()
|
||||
ctx := context.Background()
|
||||
|
||||
// Call with invalid pagination (page=0, perPage=0)
|
||||
@@ -60,7 +65,7 @@ func TestTargetService_List_DefaultPagination(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTargetService_List_EmptyPage(t *testing.T) {
|
||||
svc, targetRepo, _ := newTestTargetService()
|
||||
svc, targetRepo, _, _ := newTestTargetService()
|
||||
ctx := context.Background()
|
||||
|
||||
// Add 3 targets
|
||||
@@ -87,7 +92,7 @@ func TestTargetService_List_EmptyPage(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTargetService_List_RepoError(t *testing.T) {
|
||||
svc, targetRepo, _ := newTestTargetService()
|
||||
svc, targetRepo, _, _ := newTestTargetService()
|
||||
ctx := context.Background()
|
||||
|
||||
// Set repo to return error
|
||||
@@ -104,7 +109,7 @@ func TestTargetService_List_RepoError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTargetService_Get_Success(t *testing.T) {
|
||||
svc, targetRepo, _ := newTestTargetService()
|
||||
svc, targetRepo, _, _ := newTestTargetService()
|
||||
ctx := context.Background()
|
||||
|
||||
target := &domain.DeploymentTarget{ID: "t-1", Name: "Target 1", Type: domain.TargetTypeNGINX}
|
||||
@@ -121,7 +126,7 @@ func TestTargetService_Get_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTargetService_Get_NotFound(t *testing.T) {
|
||||
svc, _, _ := newTestTargetService()
|
||||
svc, _, _, _ := newTestTargetService()
|
||||
ctx := context.Background()
|
||||
|
||||
result, err := svc.Get(ctx, "nonexistent")
|
||||
@@ -135,7 +140,7 @@ func TestTargetService_Get_NotFound(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTargetService_Create_Success(t *testing.T) {
|
||||
svc, targetRepo, auditRepo := newTestTargetService()
|
||||
svc, targetRepo, auditRepo, _ := newTestTargetService()
|
||||
ctx := context.Background()
|
||||
|
||||
target := &domain.DeploymentTarget{
|
||||
@@ -168,6 +173,14 @@ func TestTargetService_Create_Success(t *testing.T) {
|
||||
t.Errorf("expected timestamps to be set, CreatedAt=%v, UpdatedAt=%v", target.CreatedAt, target.UpdatedAt)
|
||||
}
|
||||
|
||||
// Verify test status and source defaults
|
||||
if target.TestStatus != "untested" {
|
||||
t.Errorf("expected test_status 'untested', got %s", target.TestStatus)
|
||||
}
|
||||
if target.Source != "database" {
|
||||
t.Errorf("expected source 'database', got %s", target.Source)
|
||||
}
|
||||
|
||||
// Verify audit event
|
||||
if len(auditRepo.Events) == 0 {
|
||||
t.Fatalf("expected audit event, got none")
|
||||
@@ -184,7 +197,7 @@ func TestTargetService_Create_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTargetService_Create_MissingName(t *testing.T) {
|
||||
svc, _, _ := newTestTargetService()
|
||||
svc, _, _, _ := newTestTargetService()
|
||||
ctx := context.Background()
|
||||
|
||||
target := &domain.DeploymentTarget{
|
||||
@@ -197,8 +210,23 @@ func TestTargetService_Create_MissingName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetService_Create_InvalidType(t *testing.T) {
|
||||
svc, _, _, _ := newTestTargetService()
|
||||
ctx := context.Background()
|
||||
|
||||
target := &domain.DeploymentTarget{
|
||||
Name: "Bad Target",
|
||||
Type: domain.TargetType("InvalidType"),
|
||||
}
|
||||
|
||||
err := svc.Create(ctx, target, "test-actor")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for invalid type, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetService_Create_RepoError(t *testing.T) {
|
||||
svc, targetRepo, _ := newTestTargetService()
|
||||
svc, targetRepo, _, _ := newTestTargetService()
|
||||
ctx := context.Background()
|
||||
|
||||
targetRepo.CreateErr = errNotFound
|
||||
@@ -215,7 +243,7 @@ func TestTargetService_Create_RepoError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTargetService_Update_Success(t *testing.T) {
|
||||
svc, targetRepo, auditRepo := newTestTargetService()
|
||||
svc, targetRepo, auditRepo, _ := newTestTargetService()
|
||||
ctx := context.Background()
|
||||
|
||||
// Create initial target
|
||||
@@ -251,7 +279,7 @@ func TestTargetService_Update_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTargetService_Update_MissingName(t *testing.T) {
|
||||
svc, _, _ := newTestTargetService()
|
||||
svc, _, _, _ := newTestTargetService()
|
||||
ctx := context.Background()
|
||||
|
||||
target := &domain.DeploymentTarget{
|
||||
@@ -265,7 +293,7 @@ func TestTargetService_Update_MissingName(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTargetService_Delete_Success(t *testing.T) {
|
||||
svc, targetRepo, auditRepo := newTestTargetService()
|
||||
svc, targetRepo, auditRepo, _ := newTestTargetService()
|
||||
ctx := context.Background()
|
||||
|
||||
// Create initial target
|
||||
@@ -295,7 +323,7 @@ func TestTargetService_Delete_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTargetService_Delete_RepoError(t *testing.T) {
|
||||
svc, targetRepo, _ := newTestTargetService()
|
||||
svc, targetRepo, _, _ := newTestTargetService()
|
||||
ctx := context.Background()
|
||||
|
||||
targetRepo.DeleteErr = errNotFound
|
||||
@@ -307,7 +335,7 @@ func TestTargetService_Delete_RepoError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTargetService_ListTargets_Success(t *testing.T) {
|
||||
svc, targetRepo, _ := newTestTargetService()
|
||||
svc, targetRepo, _, _ := newTestTargetService()
|
||||
|
||||
// Add targets
|
||||
target1 := &domain.DeploymentTarget{ID: "t-1", Name: "Target 1", Type: domain.TargetTypeNGINX}
|
||||
@@ -331,7 +359,7 @@ func TestTargetService_ListTargets_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTargetService_GetTarget_Success(t *testing.T) {
|
||||
svc, targetRepo, _ := newTestTargetService()
|
||||
svc, targetRepo, _, _ := newTestTargetService()
|
||||
|
||||
target := &domain.DeploymentTarget{ID: "t-1", Name: "Target 1", Type: domain.TargetTypeNGINX}
|
||||
targetRepo.AddTarget(target)
|
||||
@@ -347,7 +375,7 @@ func TestTargetService_GetTarget_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTargetService_CreateTarget_Success(t *testing.T) {
|
||||
svc, targetRepo, _ := newTestTargetService()
|
||||
svc, targetRepo, _, _ := newTestTargetService()
|
||||
|
||||
target := domain.DeploymentTarget{
|
||||
Name: "New Target",
|
||||
@@ -369,8 +397,22 @@ func TestTargetService_CreateTarget_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetService_CreateTarget_InvalidType(t *testing.T) {
|
||||
svc, _, _, _ := newTestTargetService()
|
||||
|
||||
target := domain.DeploymentTarget{
|
||||
Name: "Bad Target",
|
||||
Type: domain.TargetType("Unknown"),
|
||||
}
|
||||
|
||||
_, err := svc.CreateTarget(target)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for invalid type, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetService_UpdateTarget_Success(t *testing.T) {
|
||||
svc, targetRepo, _ := newTestTargetService()
|
||||
svc, targetRepo, _, _ := newTestTargetService()
|
||||
|
||||
// Create initial target
|
||||
target := &domain.DeploymentTarget{ID: "t-1", Name: "Old Name", Type: domain.TargetTypeNGINX}
|
||||
@@ -393,7 +435,7 @@ func TestTargetService_UpdateTarget_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTargetService_DeleteTarget_Success(t *testing.T) {
|
||||
svc, targetRepo, _ := newTestTargetService()
|
||||
svc, targetRepo, _, _ := newTestTargetService()
|
||||
|
||||
// Create initial target
|
||||
target := &domain.DeploymentTarget{ID: "t-1", Name: "Target To Delete", Type: domain.TargetTypeNGINX}
|
||||
@@ -410,3 +452,130 @@ func TestTargetService_DeleteTarget_Success(t *testing.T) {
|
||||
t.Errorf("target should be deleted from repo")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetService_TestConnection_AgentOnline(t *testing.T) {
|
||||
svc, targetRepo, _, agentRepo := newTestTargetService()
|
||||
ctx := context.Background()
|
||||
|
||||
// Set up agent
|
||||
heartbeat := time.Now()
|
||||
agent := &domain.Agent{
|
||||
ID: "agent-1",
|
||||
Name: "Test Agent",
|
||||
Status: domain.AgentStatusOnline,
|
||||
LastHeartbeatAt: &heartbeat,
|
||||
}
|
||||
agentRepo.Create(ctx, agent)
|
||||
|
||||
// Set up target assigned to agent
|
||||
target := &domain.DeploymentTarget{
|
||||
ID: "t-1",
|
||||
Name: "Test Target",
|
||||
Type: domain.TargetTypeNGINX,
|
||||
AgentID: "agent-1",
|
||||
}
|
||||
targetRepo.AddTarget(target)
|
||||
|
||||
// Test connection should succeed
|
||||
err := svc.TestConnection(ctx, "t-1")
|
||||
if err != nil {
|
||||
t.Fatalf("expected success, got error: %v", err)
|
||||
}
|
||||
|
||||
// Verify test status was updated
|
||||
stored := targetRepo.Targets["t-1"]
|
||||
if stored.TestStatus != "success" {
|
||||
t.Errorf("expected test_status 'success', got %s", stored.TestStatus)
|
||||
}
|
||||
if stored.LastTestedAt == nil {
|
||||
t.Error("expected last_tested_at to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetService_TestConnection_AgentOffline(t *testing.T) {
|
||||
svc, targetRepo, _, agentRepo := newTestTargetService()
|
||||
ctx := context.Background()
|
||||
|
||||
// Set up offline agent
|
||||
agent := &domain.Agent{
|
||||
ID: "agent-1",
|
||||
Name: "Offline Agent",
|
||||
Status: domain.AgentStatusOffline,
|
||||
}
|
||||
agentRepo.Create(ctx, agent)
|
||||
|
||||
// Set up target
|
||||
target := &domain.DeploymentTarget{
|
||||
ID: "t-1",
|
||||
Name: "Test Target",
|
||||
Type: domain.TargetTypeNGINX,
|
||||
AgentID: "agent-1",
|
||||
}
|
||||
targetRepo.AddTarget(target)
|
||||
|
||||
err := svc.TestConnection(ctx, "t-1")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for offline agent, got nil")
|
||||
}
|
||||
|
||||
stored := targetRepo.Targets["t-1"]
|
||||
if stored.TestStatus != "failed" {
|
||||
t.Errorf("expected test_status 'failed', got %s", stored.TestStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetService_TestConnection_NoAgent(t *testing.T) {
|
||||
svc, targetRepo, _, _ := newTestTargetService()
|
||||
ctx := context.Background()
|
||||
|
||||
target := &domain.DeploymentTarget{
|
||||
ID: "t-1",
|
||||
Name: "Test Target",
|
||||
Type: domain.TargetTypeNGINX,
|
||||
AgentID: "",
|
||||
}
|
||||
targetRepo.AddTarget(target)
|
||||
|
||||
err := svc.TestConnection(ctx, "t-1")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing agent, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetService_TestConnection_TargetNotFound(t *testing.T) {
|
||||
svc, _, _, _ := newTestTargetService()
|
||||
ctx := context.Background()
|
||||
|
||||
err := svc.TestConnection(ctx, "nonexistent")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent target, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetService_TestConnection_StaleHeartbeat(t *testing.T) {
|
||||
svc, targetRepo, _, agentRepo := newTestTargetService()
|
||||
ctx := context.Background()
|
||||
|
||||
// Set up agent with stale heartbeat (10 minutes ago)
|
||||
staleTime := time.Now().Add(-10 * time.Minute)
|
||||
agent := &domain.Agent{
|
||||
ID: "agent-1",
|
||||
Name: "Stale Agent",
|
||||
Status: domain.AgentStatusOnline,
|
||||
LastHeartbeatAt: &staleTime,
|
||||
}
|
||||
agentRepo.Create(ctx, agent)
|
||||
|
||||
target := &domain.DeploymentTarget{
|
||||
ID: "t-1",
|
||||
Name: "Test Target",
|
||||
Type: domain.TargetTypeNGINX,
|
||||
AgentID: "agent-1",
|
||||
}
|
||||
targetRepo.AddTarget(target)
|
||||
|
||||
err := svc.TestConnection(ctx, "t-1")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for stale heartbeat, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -637,6 +637,19 @@ func (m *mockTargetRepo) Create(ctx context.Context, target *domain.DeploymentTa
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockTargetRepo) CreateIfNotExists(ctx context.Context, target *domain.DeploymentTarget) (bool, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.CreateErr != nil {
|
||||
return false, m.CreateErr
|
||||
}
|
||||
if _, exists := m.Targets[target.ID]; exists {
|
||||
return false, nil
|
||||
}
|
||||
m.Targets[target.ID] = target
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (m *mockTargetRepo) Update(ctx context.Context, target *domain.DeploymentTarget) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Rollback migration 000010: Remove dynamic target configuration columns
|
||||
ALTER TABLE deployment_targets DROP COLUMN IF EXISTS encrypted_config;
|
||||
ALTER TABLE deployment_targets DROP COLUMN IF EXISTS last_tested_at;
|
||||
ALTER TABLE deployment_targets DROP COLUMN IF EXISTS test_status;
|
||||
ALTER TABLE deployment_targets DROP COLUMN IF EXISTS source;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Migration 000010: Add dynamic target configuration columns
|
||||
-- Supports M35: Dynamic Target Configuration (GUI)
|
||||
|
||||
-- encrypted_config stores AES-GCM encrypted config blob containing all fields including secrets.
|
||||
-- The existing `config` JSONB column is retained for backward compatibility and holds a redacted copy.
|
||||
ALTER TABLE deployment_targets ADD COLUMN IF NOT EXISTS encrypted_config BYTEA;
|
||||
|
||||
-- last_tested_at tracks when the target connection was last tested (agent heartbeat check).
|
||||
ALTER TABLE deployment_targets ADD COLUMN IF NOT EXISTS last_tested_at TIMESTAMPTZ;
|
||||
|
||||
-- test_status tracks the latest connection test result.
|
||||
ALTER TABLE deployment_targets ADD COLUMN IF NOT EXISTS test_status TEXT NOT NULL DEFAULT 'untested';
|
||||
|
||||
-- source tracks where the target configuration originated from.
|
||||
-- 'database' = created via GUI, 'env' = seeded from environment variables.
|
||||
ALTER TABLE deployment_targets ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT 'database';
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
getTargets,
|
||||
createTarget,
|
||||
deleteTarget,
|
||||
testTargetConnection,
|
||||
getProfiles,
|
||||
getProfile,
|
||||
createProfile,
|
||||
@@ -425,6 +426,14 @@ describe('API Client', () => {
|
||||
expect(url).toBe('/api/v1/targets/t-nginx');
|
||||
expect(init.method).toBe('DELETE');
|
||||
});
|
||||
|
||||
it('testTargetConnection sends POST', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ status: 'success', message: 'Agent is online' }));
|
||||
await testTargetConnection('t-nginx');
|
||||
const [url, init] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/v1/targets/t-nginx/test');
|
||||
expect(init.method).toBe('POST');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Approval ──────────────────────────────────────
|
||||
|
||||
@@ -232,6 +232,9 @@ export const updateTarget = (id: string, data: Partial<Target>) =>
|
||||
export const deleteTarget = (id: string) =>
|
||||
fetchJSON<{ message: string }>(`${BASE}/targets/${id}`, { method: 'DELETE' });
|
||||
|
||||
export const testTargetConnection = (id: string) =>
|
||||
fetchJSON<{ status: string; message: string }>(`${BASE}/targets/${id}/test`, { method: 'POST' });
|
||||
|
||||
// Profiles
|
||||
export const getProfiles = (params: Record<string, string> = {}) => {
|
||||
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
||||
|
||||
@@ -156,10 +156,12 @@ export interface Target {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
hostname: string;
|
||||
agent_id: string;
|
||||
config: Record<string, unknown>;
|
||||
status: string;
|
||||
enabled: boolean;
|
||||
last_tested_at?: string;
|
||||
test_status?: string;
|
||||
source?: string;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
@@ -660,7 +660,7 @@ export default function CertificateDetailPage() {
|
||||
>
|
||||
<option value="">Choose a target...</option>
|
||||
{targets?.data?.map(t => (
|
||||
<option key={t.id} value={t.id}>{t.name} ({t.type} — {t.hostname})</option>
|
||||
<option key={t.id} value={t.id}>{t.name} ({t.type})</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex justify-end gap-3">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getTarget, getJobs, updateTarget } from '../api/client';
|
||||
import { getTarget, getJobs, updateTarget, testTargetConnection } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import DataTable from '../components/DataTable';
|
||||
@@ -18,6 +18,9 @@ const typeLabels: Record<string, string> = {
|
||||
caddy: 'Caddy',
|
||||
f5_bigip: 'F5 BIG-IP',
|
||||
iis: 'IIS',
|
||||
envoy: 'Envoy',
|
||||
postfix: 'Postfix',
|
||||
dovecot: 'Dovecot',
|
||||
};
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
@@ -29,21 +32,59 @@ function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
function TestStatusIndicator({ status, testedAt }: { status?: string; testedAt?: string }) {
|
||||
if (!status || status === 'untested') {
|
||||
return <span className="text-xs text-ink-faint">Not tested</span>;
|
||||
}
|
||||
const styles: Record<string, string> = {
|
||||
success: 'bg-emerald-100 text-emerald-700',
|
||||
failed: 'bg-red-100 text-red-700',
|
||||
};
|
||||
const labels: Record<string, string> = {
|
||||
success: 'Connected',
|
||||
failed: 'Failed',
|
||||
};
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<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>
|
||||
{testedAt && <span className="text-xs text-ink-faint">{formatDateTime(testedAt)}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceBadge({ source }: { source?: string }) {
|
||||
if (!source || source === 'database') {
|
||||
return <span className="text-xs px-2 py-0.5 rounded-full bg-blue-100 text-blue-700 font-medium">GUI</span>;
|
||||
}
|
||||
if (source === 'env') {
|
||||
return <span className="text-xs px-2 py-0.5 rounded-full bg-amber-100 text-amber-700 font-medium">Env Var</span>;
|
||||
}
|
||||
return <span className="text-xs text-ink-faint">{source}</span>;
|
||||
}
|
||||
|
||||
export default function TargetDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editName, setEditName] = useState('');
|
||||
const [editHostname, setEditHostname] = useState('');
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: Partial<{ name: string; hostname: string }>) => updateTarget(id!, data),
|
||||
mutationFn: (data: Partial<{ name: string }>) => updateTarget(id!, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['target', id] });
|
||||
setIsEditing(false);
|
||||
},
|
||||
});
|
||||
|
||||
const testMutation = useMutation({
|
||||
mutationFn: () => testTargetConnection(id!),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['target', id] });
|
||||
},
|
||||
});
|
||||
|
||||
const { data: target, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['target', id],
|
||||
queryFn: () => getTarget(id!),
|
||||
@@ -126,19 +167,39 @@ export default function TargetDetailPage() {
|
||||
title={target.name}
|
||||
subtitle={typeLabels[target.type] || target.type}
|
||||
action={
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => testMutation.mutate()}
|
||||
disabled={testMutation.isPending}
|
||||
className="px-3 py-1.5 border border-surface-border rounded text-ink text-xs hover:bg-surface-hover transition-colors font-medium disabled:opacity-50"
|
||||
>
|
||||
{testMutation.isPending ? 'Testing...' : 'Test Connection'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditName(target.name);
|
||||
setEditHostname(target.hostname || '');
|
||||
setIsEditing(true);
|
||||
}}
|
||||
className="px-3 py-1.5 border border-surface-border rounded text-ink text-xs hover:bg-surface-hover transition-colors font-medium"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Test connection result banner */}
|
||||
{testMutation.isSuccess && (
|
||||
<div className="mx-6 mt-2 p-3 bg-emerald-50 border border-emerald-200 rounded text-sm text-emerald-700">
|
||||
Agent connection test passed — agent is online and responsive.
|
||||
</div>
|
||||
)}
|
||||
{testMutation.isError && (
|
||||
<div className="mx-6 mt-2 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">
|
||||
Connection test failed: {(testMutation.error as Error).message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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">
|
||||
{/* Target info */}
|
||||
@@ -147,8 +208,9 @@ export default function TargetDetailPage() {
|
||||
<InfoRow label="ID" value={<span className="font-mono text-xs">{target.id}</span>} />
|
||||
<InfoRow label="Name" value={target.name} />
|
||||
<InfoRow label="Type" value={typeLabels[target.type] || target.type} />
|
||||
<InfoRow label="Hostname" value={target.hostname || '—'} />
|
||||
<InfoRow label="Status" value={<StatusBadge status={target.status} />} />
|
||||
<InfoRow label="Enabled" value={<StatusBadge status={target.enabled ? 'Enabled' : 'Disabled'} />} />
|
||||
<InfoRow label="Source" value={<SourceBadge source={target.source} />} />
|
||||
<InfoRow label="Test Status" value={<TestStatusIndicator status={target.test_status} testedAt={target.last_tested_at} />} />
|
||||
{target.agent_id && (
|
||||
<InfoRow label="Agent" value={
|
||||
<Link to={`/agents/${target.agent_id}`} className="text-xs text-accent hover:text-accent-bright font-mono">
|
||||
@@ -157,6 +219,7 @@ export default function TargetDetailPage() {
|
||||
} />
|
||||
)}
|
||||
<InfoRow label="Created" value={formatDateTime(target.created_at)} />
|
||||
{target.updated_at && <InfoRow label="Updated" value={formatDateTime(target.updated_at)} />}
|
||||
</div>
|
||||
|
||||
{/* Config */}
|
||||
@@ -205,15 +268,11 @@ export default function TargetDetailPage() {
|
||||
{(updateMutation.error as Error).message}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={e => { e.preventDefault(); updateMutation.mutate({ name: editName, hostname: editHostname }); }} className="space-y-4">
|
||||
<form onSubmit={e => { e.preventDefault(); updateMutation.mutate({ name: editName }); }} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-ink mb-1">Name</label>
|
||||
<input value={editName} onChange={e => setEditName(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" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-ink mb-1">Hostname</label>
|
||||
<input value={editHostname} onChange={e => setEditHostname(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" />
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button type="submit" disabled={updateMutation.isPending} className="flex-1 btn btn-primary disabled:opacity-50">
|
||||
{updateMutation.isPending ? 'Saving...' : 'Save'}
|
||||
|
||||
@@ -118,7 +118,6 @@ function CreateTargetWizard({ onClose, onSuccess }: { onClose: () => void; onSuc
|
||||
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('');
|
||||
@@ -127,7 +126,6 @@ function CreateTargetWizard({ onClose, onSuccess }: { onClose: () => void; onSuc
|
||||
mutationFn: () => createTarget({
|
||||
name,
|
||||
type: targetType,
|
||||
hostname,
|
||||
agent_id: agentId,
|
||||
config: Object.fromEntries(Object.entries(config).filter(([, v]) => v)),
|
||||
}),
|
||||
@@ -205,20 +203,12 @@ function CreateTargetWizard({ onClose, onSuccess }: { onClose: () => void; onSuc
|
||||
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 className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-ink-muted block mb-1">Hostname</label>
|
||||
<input value={hostname} onChange={e => setHostname(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="web1.example.com" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-ink-muted block mb-1">Agent ID</label>
|
||||
<input 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"
|
||||
placeholder="agent-web1" />
|
||||
</div>
|
||||
</div>
|
||||
{fields.map(f => (
|
||||
<div key={f.key}>
|
||||
<label className="text-xs text-ink-muted block mb-1">{f.label} {f.required ? '*' : ''}</label>
|
||||
@@ -252,12 +242,6 @@ function CreateTargetWizard({ onClose, onSuccess }: { onClose: () => void; onSuc
|
||||
<span className="text-ink-muted">Type</span>
|
||||
<span className="text-ink">{typeLabels[targetType] || targetType}</span>
|
||||
</div>
|
||||
{hostname && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-ink-muted">Hostname</span>
|
||||
<span className="text-ink font-mono text-xs">{hostname}</span>
|
||||
</div>
|
||||
)}
|
||||
{agentId && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-ink-muted">Agent</span>
|
||||
@@ -322,20 +306,23 @@ export default function TargetsPage() {
|
||||
<span className="badge badge-neutral">{typeLabels[t.type] || t.type}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'hostname',
|
||||
label: 'Hostname',
|
||||
render: (t) => <span className="text-ink font-mono text-xs">{t.hostname || '\u2014'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'agent',
|
||||
label: 'Agent',
|
||||
render: (t) => <span className="text-xs text-ink-muted font-mono">{t.agent_id || '\u2014'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
key: 'enabled',
|
||||
label: 'Status',
|
||||
render: (t) => <StatusBadge status={t.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',
|
||||
|
||||
Reference in New Issue
Block a user