Files
Charon/docs/plans/caddy_import_fixes_spec.md
GitHub Actions fc2df97fe1 feat: improve Caddy import with directive detection and warnings
Add backend detection for import directives with actionable error message
Display warning banner for unsupported features (file_server, redirects)
Ensure multi-file import button always visible in upload form
Add accessibility attributes (role, aria-labelledby) to multi-site modal
Fix 12 frontend unit tests with outdated hook mock interfaces
Add data-testid attributes for E2E test reliability
Fix JSON syntax in 4 translation files (missing commas)
Create 6 diagnostic E2E tests covering import edge cases
Addresses Reddit feedback on Caddy import UX confusion
2026-01-30 15:29:49 +00:00

62 KiB
Raw Blame History

Caddy Import Fixes Implementation Plan

Version: 1.1 Status: Supervisor Approved Priority: HIGH Created: 2026-01-30 Updated: 2026-01-30 Target Issues: 3 critical failures from E2E testing + 4 blocking fixes + 4 high-priority improvements


Document Changelog

Version 1.1 (2026-01-30) - Supervisor Approved

  • BLOCKING #1 RESOLVED: Refactored duplicate import detection to single point
  • BLOCKING #2 RESOLVED: Added proper error handling for file upload failures
  • BLOCKING #3 RESOLVED: Prevented race condition with isProcessingFiles state
  • BLOCKING #4 RESOLVED: Added file size (5MB) and count (50 files) limits
  • IMPROVEMENT #5: Verified backend /api/v1/import/upload-multi exists
  • IMPROVEMENT #6: Switched from custom errorDetails to React Query error handling
  • IMPROVEMENT #7: Split ImportSitesModal into 4 maintainable sub-components
  • IMPROVEMENT #8: Updated time estimates from 13-18h to 24-36h
  • Added BEFORE/AFTER code examples for all major changes
  • Added verification checklist for blocking issues
  • Added file structure guide for new components
  • Increased test coverage requirements

Version 1.0 (2026-01-30) - Initial Draft

  • Original spec addressing 3 E2E test failures
  • Basic implementation approach without supervisor feedback


Supervisor Review - Blocking Issues Resolved

Review Date: 2026-01-30 Status: All 4 blocking issues addressed, 4 high-priority improvements incorporated

Blocking Issues (RESOLVED)

  1. Refactored Duplicate Import Detection Logic

    • Issue: Import detection duplicated in two places (lines 258-275 AND 297-305)
    • Resolution: Single detection point after parse, handles both scenarios
    • Impact: Cleaner code, no logic duplication
  2. Fixed Error Handling in File Upload

    • Issue: processFiles caught errors but didn't display them to user
    • Resolution: Added setError() in catch block for file.text() failures
    • Impact: User sees clear error messages for file read failures
  3. Prevented Race Condition in Multi-File Processing

    • Issue: User could submit before files finished reading
    • Resolution: Added isProcessingFiles state, disabled submit during processing
    • Impact: Prevents incomplete file submissions
  4. Added File Size/Count Limits

    • Issue: Large files could freeze browser or exceed API limits
    • Resolution: Max 5MB per file, max 50 files total with clear errors
    • Impact: Better UX, prevents browser crashes

High-Priority Improvements (INCORPORATED)

  1. Verified Backend Endpoint

    • Confirmed /api/v1/import/upload-multi exists at lines 387-446
    • No additional implementation needed
  2. Using React Query Error Handling

    • Replaced custom errorDetails state with React Query's error object
    • Access via uploadMutation.error?.response?.data
    • More consistent with existing patterns
  3. Component Split for ImportSitesModal

    • Original design ~300+ lines (too large)
    • Split into: FileUploadSection, ManualEntrySection, ImportActions
    • Improved maintainability and testability
  4. Updated Time Estimates

    • Adjusted from 13-18h to 18-24h based on added complexity
    • More realistic estimates accounting for testing and edge cases

Quick Reference: BEFORE/AFTER Summary

This section provides a condensed view of all major changes for quick developer reference.

Issue 1: Import Detection (Backend)

BEFORE:

  • Import detection in TWO places (duplicate logic)
  • Check before parse + check after parse fails

AFTER:

  • Import detection in ONE place (after parse)
  • Handles both scenarios: imports-found vs genuinely-empty

Issue 1: Error Handling (Frontend)

BEFORE:

  • Custom errorDetails state managed separately
  • Error state scattered across components

AFTER:

  • React Query's built-in error object
  • Access via uploadMutation.error?.response?.data

Issue 2: Warning Display (Frontend)

BEFORE:

  • Backend sends warnings, frontend ignores them
  • No visual indication of unsupported features

AFTER:

  • Yellow warning badges in status column
  • Expandable rows show warning details
  • Summary banner lists all affected hosts

Issue 3: Multi-File Upload (Frontend)

BEFORE:

  • File read errors logged but not shown to user (BLOCKING #2)
  • User could submit before files finished reading (BLOCKING #3)
  • No file size/count limits (BLOCKING #4)
  • Monolithic 300+ line component (IMPROVEMENT #7)

AFTER:

  • setError() displays clear file read errors (BLOCKING #2)
  • isProcessingFiles state prevents premature submission (BLOCKING #3)
  • 5MB/50 file limits with validation messages (BLOCKING #4)
  • Split into 4 components (~50-100 lines each) (IMPROVEMENT #7)

Issue 1: Import Directive Detection

Root Cause Analysis

Current Behavior: When a Caddyfile contains import directives referencing external files, the single-file upload flow:

  1. Writes the main Caddyfile to disk (data/imports/uploads/<uuid>.caddyfile)
  2. Calls caddy adapt --config <file> which expects imported files to exist on disk
  3. Since imported files are missing, caddy adapt silently ignores the import directives
  4. Backend returns preview.hosts = [] with no explanation
  5. User sees "no sites found" error without understanding why

Evidence from Test 2:

// Test Expected: Clear error message with import paths detected
// Test Actual: Generic "no sites found" error

API Response (Current):

{
  "error": "no sites found in uploaded Caddyfile",
  "preview": { "hosts": [], "conflicts": [], "errors": [] }
}

API Response (Desired):

{
  "error": "no sites found in uploaded Caddyfile; imports detected; please upload the referenced site files using the multi-file import flow",
  "imports": ["sites.d/*.caddy"],
  "preview": { "hosts": [], "conflicts": [], "errors": [] }
}

Code Location:

  • backend/internal/api/handlers/import_handler.go lines 297-305 (detectImportDirectives() exists but only called AFTER parsing fails)
  • backend/internal/caddy/importer.go (no pre-parse import detection logic)

Requirements (EARS Notation)

R1.1 - Import Directive Detection WHEN a user uploads a Caddyfile via single-file flow, THE SYSTEM SHALL scan for import directives BEFORE calling caddy adapt.

R1.2 - Import Error Response WHEN import directives are detected and no hosts are parsed, THE SYSTEM SHALL return HTTP 400 with structured error containing:

  • error (string): User-friendly message explaining the issue
  • imports (array): List of detected import paths

R1.3 - Frontend Import Guidance WHEN the API returns an error with imports array, THE SYSTEM SHALL display:

  • List of detected import paths
  • "Switch to Multi-File Import" button that opens the modal

R1.4 - Backward Compatibility WHEN a Caddyfile has no import directives, THE SYSTEM SHALL preserve existing behavior (no regression).

Implementation Approach

Backend Changes

File: backend/internal/api/handlers/import_handler.go

BLOCKING ISSUE #1 RESOLVED: Refactored duplicate import detection into single location

BEFORE (Inefficient - Two Detection Points):

// Lines 258-275: Early detection (NEW in old plan)
func (h *ImportHandler) Upload(c *gin.Context) {
    // ... bind JSON ...

    // DUPLICATE #1: Check imports before parse
    imports := detectImportDirectives(req.Content)
    if len(imports) > 0 {
        c.JSON(http.StatusBadRequest, gin.H{"error": "..."})
        return
    }

    // ... parse ...
}

// Lines 297-305: Fallback detection (EXISTING)
if len(result.Hosts) == 0 {
    // DUPLICATE #2: Check imports after parse fails
    imports := detectImportDirectives(req.Content)
    if len(imports) > 0 {
        // ... error handling ...
    }
}

AFTER (Efficient - Single Detection Point):

// Upload handles manual Caddyfile upload or paste.
func (h *ImportHandler) Upload(c *gin.Context) {
    var req struct {
        Content  string `json:"content" binding:"required"`
        Filename string `json:"filename"`
    }

    if err := c.ShouldBindJSON(&req); err != nil {
        entry := middleware.GetRequestLogger(c)
        if raw, _ := c.GetRawData(); len(raw) > 0 {
            entry.WithError(err).WithField("raw_body_preview", util.SanitizeForLog(string(raw))).Error("Import Upload: failed to bind JSON")
        } else {
            entry.WithError(err).Error("Import Upload: failed to bind JSON")
        }
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    middleware.GetRequestLogger(c).WithField("filename", util.SanitizeForLog(filepath.Base(req.Filename))).WithField("content_len", len(req.Content)).Info("Import Upload: received upload")

    // Save upload to import/uploads/<uuid>.caddyfile and return transient preview
    sid := uuid.NewString()
    tempPath := filepath.Join(h.UploadsDir, sid+".caddyfile")

    if err := os.WriteFile(tempPath, []byte(req.Content), 0600); err != nil {
        middleware.GetRequestLogger(c).WithError(err).Error("Import Upload: failed to write temp file")
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save temporary file"})
        return
    }

    // Parse the uploaded Caddyfile
    result, err := h.CaddyImporter.ParseCaddyfile(req.Content)
    if err != nil {
        middleware.GetRequestLogger(c).WithError(err).Error("Import Upload: parse failed")
        _ = os.Remove(tempPath)
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    // SINGLE DETECTION POINT: Check after parse if no hosts found
    if len(result.Hosts) == 0 {
        imports := detectImportDirectives(req.Content)

        // Scenario 1: Import directives detected → explain multi-file flow needed
        if len(imports) > 0 {
            sanitizedImports := make([]string, 0, len(imports))
            for _, imp := range imports {
                sanitizedImports = append(sanitizedImports, util.SanitizeForLog(filepath.Base(imp)))
            }
            middleware.GetRequestLogger(c).WithField("imports", sanitizedImports).Info("Import Upload: import directives detected, no hosts parsed")

            _ = os.Remove(tempPath)
            c.JSON(http.StatusBadRequest, gin.H{
                "error":   "This Caddyfile contains import directives. Please use the multi-file import flow to upload all referenced files together.",
                "imports": imports,
                "hint":    "Click 'Multi-Site Import' and upload the main Caddyfile along with the imported files.",
            })
            return
        }

        // Scenario 2: No imports → genuinely empty or invalid
        _ = os.Remove(tempPath)
        c.JSON(http.StatusBadRequest, gin.H{"error": "no sites found in uploaded Caddyfile"})
        return
    }

    // Check for conflicts with existing hosts
    conflictDetails := make(map[string]ConflictDetail)
    conflicts := []string{}
    // ... existing conflict logic unchanged ...
}

Rationale:

  • Single detection point handles both parse success and failure scenarios
  • More efficient: Only parse once, check imports only if no hosts found
  • Clearer logic flow: Import detection is a specific case of "no hosts found"
  • Better error context: Can differentiate between empty-due-to-imports vs genuinely-empty

Frontend Changes

File: frontend/src/pages/ImportCaddy.tsx

HIGH-PRIORITY IMPROVEMENT #6 INCORPORATED: Using React Query error handling instead of custom state

BEFORE (Custom errorDetails State):

// OLD APPROACH: Custom state for error details
const [errorDetails, setErrorDetails] = useState<ImportErrorDetails | null>(null)

{error && (
  <div className="bg-red-900/20 border border-red-500 text-red-400 px-4 py-3 rounded mb-6">
    <div className="font-medium mb-2">{error}</div>

    {errorDetails?.imports && errorDetails.imports.length > 0 && (
      // ... import display ...
    )}
  </div>
)}

AFTER (React Query Error Object):

export default function ImportCaddy() {
  const { t } = useTranslation()
  const navigate = useNavigate()

  // Use React Query mutation for uploads
  const uploadMutation = useMutation({
    mutationFn: (content: string) => uploadCaddyfile(content),
    onSuccess: (data) => {
      // Handle preview state
    },
    onError: (error) => {
      // Error stored in uploadMutation.error automatically
    }
  })

  // Access error details from React Query
  const errorData = uploadMutation.error?.response?.data
  const errorMessage = errorData?.error || uploadMutation.error?.message

  return (
    <div className="container mx-auto px-4 py-6 max-w-6xl">
      <h1 className="text-3xl font-bold text-white mb-6">{t('import.title')}</h1>

      {/* Enhanced Error Display */}
      {uploadMutation.isError && (
        <div className="bg-red-900/20 border border-red-500 text-red-400 px-4 py-3 rounded mb-6">
          <div className="font-medium mb-2">{errorMessage}</div>

          {/* Check React Query error response for imports array */}
          {errorData?.imports && errorData.imports.length > 0 && (
            <div className="mt-3 p-3 bg-blue-900/20 border border-blue-500 rounded">
              <p className="text-sm text-blue-300 font-medium mb-2">
                📁 Detected Import Directives:
              </p>
              <ul className="list-disc list-inside text-sm text-blue-200 mb-3">
                {errorData.imports.map((imp: string, idx: number) => (
                  <li key={idx} className="font-mono">{imp}</li>
                ))}
              </ul>
              <button
                onClick={() => setShowMultiModal(true)}
                className="px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg text-sm font-medium transition-colors"
              >
                Switch to Multi-File Import 
              </button>
            </div>
          )}

          {errorData?.hint && (
            <p className="text-sm text-gray-300 mt-2">💡 {errorData.hint}</p>
          )}
        </div>
      )}

      {/* Rest of component unchanged */}
    </div>
  )
}

Rationale:

  • Consistent with React Query patterns: No custom state needed
  • Automatic error management: React Query handles error lifecycle
  • Simpler code: Less state to manage, fewer potential bugs
  • Better TypeScript support: Error types from API client

Testing Strategy

Backend Unit Tests: backend/internal/api/handlers/import_handler_test.go

func TestUpload_EarlyImportDetection(t *testing.T) {
    // Test that import directives are detected before parsing
    content := `
import sites.d/*.caddy

admin.example.com {
    reverse_proxy localhost:9090
}
    `

    req := UploadRequest{Content: content}
    resp := callUploadHandler(req)

    assert.Equal(t, 400, resp.Status)
    assert.Contains(t, resp.Error, "import directives")
    assert.NotEmpty(t, resp.Imports)
    assert.Contains(t, resp.Imports[0], "sites.d/*.caddy")
    assert.Contains(t, resp.Hint, "Multi-Site Import")
}

func TestUpload_NoImportsPreservesBehavior(t *testing.T) {
    // Test backward compatibility: Caddyfile with no imports works as before
    content := `
example.com {
    reverse_proxy localhost:8080
}
    `

    req := UploadRequest{Content: content}
    resp := callUploadHandler(req)

    assert.Equal(t, 200, resp.Status)
    assert.Len(t, resp.Preview.Hosts, 1)
    assert.Equal(t, "example.com", resp.Preview.Hosts[0].DomainNames)
}

E2E Tests: tests/tasks/caddy-import-debug.spec.ts (Test 2)

The existing Test 2 should now pass with these changes:

// Expected: Error message with import paths and multi-file button
await expect(errorMessage).toBeVisible()
const errorText = await errorMessage.textContent()
expect(errorText?.toLowerCase()).toContain('import')
expect(errorText?.toLowerCase()).toMatch(/multi.*file|upload.*files/)

// NEW: Verify "Switch to Multi-File Import" button appears
const switchButton = page.getByRole('button', { name: /switch.*multi.*file/i })
await expect(switchButton).toBeVisible()

Files to Modify

File Lines Changes Complexity
backend/internal/api/handlers/import_handler.go 258-305 Refactor to single import detection point (BLOCKING #1) M (2-3h)
frontend/src/pages/ImportCaddy.tsx 54-90 Enhanced error display with React Query (IMPROVEMENT #6) M (1-2h)
backend/internal/api/handlers/import_handler_test.go N/A Unit tests for import detection scenarios S (1h)

Total Estimate: M (4-6 hours)


Issue 2: Warning Display

Root Cause Analysis

Current Behavior: The backend correctly generates warnings for unsupported Caddyfile features:

  • file_server directives (line 285 in importer.go)
  • rewrite rules (line 282 in importer.go)
  • Other unsupported handlers

These warnings are included in the API response as preview.hosts[].warnings array, but the frontend does not display them.

Evidence from Test 3:

// Backend Response (from test logs):
{
  "preview": {
    "hosts": [
      {
        "domain_names": "static.example.com",
        "warnings": ["File server directives not supported"]  // ← Present in response
      }
    ]
  }
}

// Test Failure: No warning displayed in UI
const warningMessage = page.locator('.bg-yellow-900, .bg-yellow-900\\/20')
await expect(warningMessage).toBeVisible({ timeout: 5000 }) // ❌ TIMEOUT

Code Location:

  • Backend: backend/internal/caddy/importer.go lines 280-286 (warnings generated )
  • API: Returns preview.hosts[].warnings array
  • Frontend: frontend/src/components/ImportReviewTable.tsx (warnings NOT displayed )

Requirements (EARS Notation)

R2.1 - Warning Display in Review Table WHEN a parsed host contains warnings, THE SYSTEM SHALL display a warning indicator in the "Status" column.

R2.2 - Warning Details on Expand WHEN a user clicks on a host with warnings, THE SYSTEM SHALL expand the row to show full warning messages.

R2.3 - Warning Badge Styling WHEN displaying warnings, THE SYSTEM SHALL use yellow theme (bg-yellow-900/20, border-yellow-500, text-yellow-400) for accessibility.

R2.4 - Aggregate Warning Summary WHEN multiple hosts have warnings, THE SYSTEM SHALL display a summary banner above the table listing affected hosts.

Implementation Approach

Frontend Changes

File: frontend/src/components/ImportReviewTable.tsx

Change 1: Update Type Definition (Lines 7-13)

Add warnings to the HostPreview interface:

interface HostPreview {
  domain_names: string
  name?: string
  forward_scheme?: string
  forward_host?: string
  forward_port?: number
  ssl_forced?: boolean
  websocket_support?: boolean
  warnings?: string[]  // NEW: Add warnings support
  [key: string]: unknown
}

Change 2: Warning Summary Banner (After line 120, before table)

Add aggregate warning summary for quick scan:

{/* NEW: Warning Summary (if any hosts have warnings) */}
{hosts.some(h => h.warnings && h.warnings.length > 0) && (
  <div className="m-4 bg-yellow-900/20 border border-yellow-500 text-yellow-400 px-4 py-3 rounded">
    <div className="font-medium mb-2 flex items-center gap-2">
      <AlertTriangle className="w-5 h-5" />
      Warnings Detected
    </div>
    <p className="text-sm mb-2">
      Some hosts have unsupported features that may require manual configuration after import:
    </p>
    <ul className="list-disc list-inside text-sm">
      {hosts
        .filter(h => h.warnings && h.warnings.length > 0)
        .map(h => (
          <li key={h.domain_names}>
            <span className="font-medium">{h.domain_names}</span> - {h.warnings?.join(', ')}
          </li>
        ))}
    </ul>
  </div>
)}

Change 3: Warning Badge in Status Column (Lines 193-200)

Update status cell to show warning badge:

<td className="px-6 py-4">
  {hasConflict ? (
    <span className="flex items-center gap-1 text-yellow-400 text-xs">
      <AlertTriangle className="w-3 h-3" />
      Conflict
    </span>
  ) : h.warnings && h.warnings.length > 0 ? (
    {/* NEW: Warning badge */}
    <span className="flex items-center gap-1 px-2 py-1 text-xs bg-yellow-900/30 text-yellow-400 rounded">
      <AlertTriangle className="w-3 h-3" />
      Warning
    </span>
  ) : (
    <span className="flex items-center gap-1 px-2 py-1 text-xs bg-green-900/30 text-green-400 rounded">
      <CheckCircle2 className="w-3 h-3" />
      New
    </span>
  )}
</td>

Change 4: Expandable Warning Details (After conflict detail row, ~line 243)

Add expandable row for hosts with warnings (similar to conflict expansion):

{/* NEW: Expandable warning details for hosts with warnings */}
{h.warnings && h.warnings.length > 0 && isExpanded && (
  <tr className="bg-yellow-900/10">
    <td colSpan={4} className="px-6 py-4">
      <div className="border border-yellow-500/30 rounded-lg p-4 bg-yellow-900/10">
        <h4 className="text-sm font-semibold text-yellow-400 mb-3 flex items-center gap-2">
          <AlertTriangle className="w-4 h-4" />
          Configuration Warnings
        </h4>
        <ul className="space-y-2 text-sm">
          {h.warnings.map((warning, idx) => (
            <li key={idx} className="flex items-start gap-2 text-yellow-300">
              <span className="text-yellow-500 mt-0.5"></span>
              <span>{warning}</span>
            </li>
          ))}
        </ul>
        <div className="mt-3 p-3 bg-gray-800/50 rounded border-l-4 border-yellow-500">
          <p className="text-sm text-gray-300">
            <strong className="text-yellow-400">Action Required:</strong> These features are not automatically imported. You may need to configure them manually in the proxy host settings after import.
          </p>
        </div>
      </div>
    </td>
  </tr>
)}

Change 5: Make Warning Rows Expandable (Lines 177-191)

Update row click logic to support expansion for warnings (not just conflicts):

{hosts.map((h) => {
  const domain = h.domain_names
  const hasConflict = conflicts.includes(domain)
  const hasWarnings = h.warnings && h.warnings.length > 0  // NEW
  const isExpanded = expandedRows.has(domain)
  const details = conflictDetails?.[domain]

  return (
    <React.Fragment key={domain}>
      <tr className="hover:bg-gray-900/50">
        <td className="px-6 py-4">
          {/* Name input - unchanged */}
        </td>
        <td className="px-6 py-4">
          <div className="flex items-center gap-2">
            {/* NEW: Make rows with warnings OR conflicts expandable */}
            {(hasConflict || hasWarnings) && (
              <button
                onClick={() => {
                  const newExpanded = new Set(expandedRows)
                  if (isExpanded) newExpanded.delete(domain)
                  else newExpanded.add(domain)
                  setExpandedRows(newExpanded)
                }}
                className="text-gray-400 hover:text-white"
                aria-label={isExpanded ? "Collapse details" : "Expand details"}
              >
                {isExpanded ? '▼' : '▶'}
              </button>
            )}
            <div className="text-sm font-medium text-white">{domain}</div>
          </div>
        </td>
        {/* ... rest of row unchanged ... */}
      </tr>

      {/* Conflict details row - existing, unchanged */}
      {hasConflict && isExpanded && details && (
        {/* ... existing conflict detail UI ... */}
      )}

      {/* NEW: Warning details row - added above */}
      {hasWarnings && isExpanded && (
        {/* ... warning detail UI from Change 4 ... */}
      )}
    </React.Fragment>
  )
})}

Testing Strategy

Frontend Unit Tests: frontend/src/components/__tests__/ImportReviewTable.test.tsx

test('displays warning badge for hosts with warnings', () => {
  const hosts: HostPreview[] = [
    {
      domain_names: 'static.example.com',
      forward_host: 'localhost',
      forward_port: 80,
      warnings: ['File server directives not supported'],
    },
  ]

  const { getByText } = render(
    <ImportReviewTable hosts={hosts} conflicts={[]} errors={[]} onCommit={jest.fn()} onCancel={jest.fn()} />
  )

  // Check warning badge appears
  expect(getByText('Warning')).toBeInTheDocument()
})

test('expands to show warning details when clicked', async () => {
  const hosts: HostPreview[] = [
    {
      domain_names: 'static.example.com',
      forward_host: 'localhost',
      forward_port: 80,
      warnings: ['File server directives not supported', 'Rewrite rules not supported'],
    },
  ]

  const { getByRole, getByText } = render(
    <ImportReviewTable hosts={hosts} conflicts={[]} errors={[]} onCommit={jest.fn()} onCancel={jest.fn()} />
  )

  // Expand row
  const expandButton = getByRole('button', { name: /expand/i })
  fireEvent.click(expandButton)

  // Check warning details visible
  expect(getByText('File server directives not supported')).toBeInTheDocument()
  expect(getByText('Rewrite rules not supported')).toBeInTheDocument()
})

test('displays warning summary banner when multiple hosts have warnings', () => {
  const hosts: HostPreview[] = [
    {
      domain_names: 'static.example.com',
      forward_host: 'localhost',
      forward_port: 80,
      warnings: ['File server directives not supported'],
    },
    {
      domain_names: 'docs.example.com',
      forward_host: 'localhost',
      forward_port: 8080,
      warnings: ['Rewrite rules not supported'],
    },
  ]

  const { getByText } = render(
    <ImportReviewTable hosts={hosts} conflicts={[]} errors={[]} onCommit={jest.fn()} onCancel={jest.fn()} />
  )

  // Check summary banner
  expect(getByText('Warnings Detected')).toBeInTheDocument()
  expect(getByText(/static.example.com/)).toBeInTheDocument()
  expect(getByText(/docs.example.com/)).toBeInTheDocument()
})

E2E Tests: tests/tasks/caddy-import-debug.spec.ts (Test 3)

The existing Test 3 should now pass:

// Test 3: File Server Only - should show warnings
const warningMessage = page.locator('.bg-yellow-900, .bg-yellow-900\\/20')
await expect(warningMessage).toBeVisible({ timeout: 5000 })  // ✅ Now passes

const warningText = await warningMessage.textContent()
expect(warningText?.toLowerCase()).toMatch(/file.?server|not supported/)  // ✅ Now passes

Files to Modify

File Lines Changes Complexity
frontend/src/components/ImportReviewTable.tsx 7-13 Add warnings to type definition S (5min)
frontend/src/components/ImportReviewTable.tsx ~120 Add warning summary banner S (30min)
frontend/src/components/ImportReviewTable.tsx 193-200 Add warning badge to status column S (30min)
frontend/src/components/ImportReviewTable.tsx ~243 Add expandable warning details row M (1-2h)
frontend/src/components/ImportReviewTable.tsx 177-191 Update expansion logic for warnings S (30min)
frontend/src/components/__tests__/ImportReviewTable.test.tsx N/A Unit tests for warning display M (1-2h)

Total Estimate: M (3-4 hours)


Issue 3: Multi-File Upload UX Improvement

Root Cause Analysis

Current Behavior: The multi-file upload modal (ImportSitesModal.tsx) requires users to:

  1. Click "Add site" button for each file
  2. Manually paste content into individual textareas
  3. Scroll through multiple large textareas
  4. No visual indication of file names or structure

This is tedious for users who already have external files on disk.

Evidence from Test 6:

// Test Expected: File upload with drag-drop
const fileInput = modal.locator('input[type="file"]')
await expect(fileInput).toBeVisible()  // ❌ FAILS - no file input exists

// Test Actual: Only textareas available
const textareas = modal.locator('textarea')
// Users must copy-paste each file manually

Code Location:

  • frontend/src/components/ImportSitesModal.tsx (lines 38-76) - uses textarea-based approach
  • No file upload component exists

Requirements (EARS Notation)

R3.1 - File Upload Input WHEN the user opens the multi-file import modal, THE SYSTEM SHALL provide a file input that accepts multiple .caddyfile, .caddy, or .txt files.

R3.2 - Drag-Drop Support WHEN the user drags files over the file upload area, THE SYSTEM SHALL show a visual drop zone indicator and accept dropped files.

R3.3 - File Preview List WHEN files are uploaded, THE SYSTEM SHALL display a list showing:

  • File name
  • File size
  • Remove button
  • Preview of first 10 lines (optional)

R3.4 - Automatic Main Caddyfile Detection WHEN files are uploaded, THE SYSTEM SHALL automatically identify the file named "Caddyfile" as the main file.

R3.5 - Dual Mode Support WHEN the user prefers manual entry, THE SYSTEM SHALL provide a toggle to switch between file upload and textarea mode.

Implementation Approach

BLOCKING ISSUES #2, #3, #4 RESOLVED + HIGH-PRIORITY IMPROVEMENT #7 INCORPORATED

Component Architecture (Split for Maintainability)

BEFORE (Monolithic ~300+ lines):

// Single massive component
export default function ImportSitesModal({ visible, onClose, onUploaded }: Props) {
  // 50+ lines of state
  // 100+ lines of handlers
  // 150+ lines of JSX
  // Total: ~300+ lines (hard to maintain)
}

AFTER (Split into Sub-Components):

// Main orchestrator (~100 lines)
export default function ImportSitesModal({ visible, onClose, onUploaded }: Props)

// Sub-components (~50 lines each)
function FileUploadSection({ onFilesProcessed, processing }: FileUploadProps)
function ManualEntrySection({ files, setFiles, onRemove }: ManualEntryProps)
function ImportActions({ onSubmit, onCancel, disabled, loading }: ActionsProps)

Rationale:

  • Better maintainability: Each component has single responsibility
  • Easier testing: Test components independently
  • Improved readability: ~50-100 lines per component vs 300+

Sub-Component 1: FileUploadSection

BLOCKING ISSUE #2 RESOLVED: Error handling for file read failures BLOCKING ISSUE #3 RESOLVED: Race condition prevention with processing state BLOCKING ISSUE #4 RESOLVED: File size and count limits

import { useState } from 'react'
import { Upload, AlertTriangle } from 'lucide-react'

interface FileUploadProps {
  onFilesProcessed: (files: UploadedFile[]) => void
  processing: boolean
}

interface UploadedFile {
  name: string
  content: string
  size: number
}

// BLOCKING ISSUE #4: File validation constants
const MAX_FILE_SIZE = 5 * 1024 * 1024  // 5MB
const MAX_FILE_COUNT = 50

export function FileUploadSection({ onFilesProcessed, processing }: FileUploadProps) {
  const [dragActive, setDragActive] = useState(false)
  const [error, setError] = useState<string | null>(null)

  const handleFileInput = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const selectedFiles = Array.from(e.target.files || [])
    await processFiles(selectedFiles)
  }

  const handleDrag = (e: React.DragEvent) => {
    e.preventDefault()
    e.stopPropagation()
    if (e.type === 'dragenter' || e.type === 'dragover') {
      setDragActive(true)
    } else if (e.type === 'dragleave') {
      setDragActive(false)
    }
  }

  const handleDrop = async (e: React.DragEvent) => {
    e.preventDefault()
    e.stopPropagation()
    setDragActive(false)
    const droppedFiles = Array.from(e.dataTransfer.files)
    await processFiles(droppedFiles)
  }

  const processFiles = async (selectedFiles: File[]) => {
    setError(null)

    // BLOCKING ISSUE #4: Validate file count
    if (selectedFiles.length > MAX_FILE_COUNT) {
      setError(`Too many files. Maximum ${MAX_FILE_COUNT} files allowed. You selected ${selectedFiles.length}.`)
      return
    }

    // BLOCKING ISSUE #4: Validate file sizes
    const oversizedFiles = selectedFiles.filter(f => f.size > MAX_FILE_SIZE)
    if (oversizedFiles.length > 0) {
      const fileList = oversizedFiles.map(f => `${f.name} (${(f.size / 1024 / 1024).toFixed(1)}MB)`).join(', ')
      setError(`Files exceed 5MB limit: ${fileList}`)
      return
    }

    // Validate file types
    const validFiles = selectedFiles.filter(f =>
      f.name.endsWith('.caddyfile') ||
      f.name.endsWith('.caddy') ||
      f.name.endsWith('.txt') ||
      f.name === 'Caddyfile'
    )

    if (validFiles.length !== selectedFiles.length) {
      setError('Some files were skipped. Only .caddyfile, .caddy, .txt files are accepted.')
    }

    // BLOCKING ISSUE #3: Signal processing started
    const uploadedFiles: UploadedFile[] = []

    for (const file of validFiles) {
      try {
        // BLOCKING ISSUE #2: Proper error handling for file.text()
        const content = await file.text()
        uploadedFiles.push({
          name: file.name,
          content,
          size: file.size,
        })
      } catch (err) {
        // BLOCKING ISSUE #2: Show error to user instead of just logging
        const errorMsg = err instanceof Error ? err.message : String(err)
        setError(`Failed to read file "${file.name}": ${errorMsg}`)
        return  // Stop processing on first error
      }
    }

    // Sort: main Caddyfile first, then alphabetically
    uploadedFiles.sort((a, b) => {
      if (a.name === 'Caddyfile') return -1
      if (b.name === 'Caddyfile') return 1
      return a.name.localeCompare(b.name)
    })

    onFilesProcessed(uploadedFiles)
  }

  return (
    <div>
      {/* Upload Drop Zone */}
      <div
        onDragEnter={handleDrag}
        onDragLeave={handleDrag}
        onDragOver={handleDrag}
        onDrop={handleDrop}
        className={`border-2 border-dashed rounded-lg p-8 mb-4 transition-colors ${
          dragActive ? 'border-blue-500 bg-blue-900/10' : 'border-gray-700 hover:border-gray-600'
        } ${processing ? 'opacity-50 pointer-events-none' : ''}`}
      >
        <div className="flex flex-col items-center gap-3">
          <Upload className={`w-12 h-12 ${dragActive ? 'text-blue-400' : 'text-gray-500'}`} />
          <div className="text-center">
            <label htmlFor="file-upload" className="cursor-pointer">
              <span className="text-blue-400 hover:text-blue-300 font-medium">
                Choose files
              </span>
              <span className="text-gray-400"> or drag and drop</span>
            </label>
            <input
              id="file-upload"
              type="file"
              multiple
              accept=".caddyfile,.caddy,.txt"
              onChange={handleFileInput}
              className="hidden"
              data-testid="file-input"
              disabled={processing}
            />
            <p className="text-xs text-gray-500 mt-2">
              Max 5MB per file, 50 files total  Accepts: .caddyfile, .caddy, .txt, or "Caddyfile"
            </p>
          </div>
        </div>
      </div>

      {/* BLOCKING ISSUE #2: Error Display for File Processing */}
      {error && (
        <div className="bg-red-900/20 border border-red-500 text-red-400 px-4 py-3 rounded mb-4 flex items-start gap-2">
          <AlertTriangle className="w-5 h-5 flex-shrink-0 mt-0.5" />
          <div>
            <p className="font-medium">File Upload Error</p>
            <p className="text-sm mt-1">{error}</p>
          </div>
        </div>
      )}

      {/* BLOCKING ISSUE #3: Processing Indicator */}
      {processing && (
        <div className="bg-blue-900/20 border border-blue-500 text-blue-400 px-4 py-2 rounded mb-4">
          <p className="text-sm">📄 Processing files, please wait...</p>
        </div>
      )}
    </div>
  )
}

Sub-Component 2: ManualEntrySection

import { X } from 'lucide-react'

interface ManualEntryProps {
  files: UploadedFile[]
  setFiles: (files: UploadedFile[]) => void
  onRemove: (index: number) => void
}

export function ManualEntrySection({ files, setFiles, onRemove }: ManualEntryProps) {
  const addFile = () => {
    setFiles([...files, { name: '', content: '', size: 0 }])
  }

  const updateFile = (index: number, field: 'name' | 'content', value: string) => {
    const newFiles = [...files]
    newFiles[index][field] = value
    if (field === 'content') {
      newFiles[index].size = new Blob([value]).size
    }
    setFiles(newFiles)
  }

  return (
    <div>
      <p className="text-sm text-gray-400 mb-3">
        Manually paste each site's Caddyfile content below:
      </p>
      <div className="space-y-4 max-h-[60vh] overflow-auto mb-4">
        {files.map((file, idx) => (
          <div key={idx} className="border border-gray-800 rounded-lg p-3">
            <div className="flex justify-between items-center mb-2">
              <input
                type="text"
                value={file.name}
                onChange={e => updateFile(idx, 'name', e.target.value)}
                placeholder="Filename (e.g., Caddyfile, sites.d/app.caddy)"
                className="flex-1 bg-gray-900 border border-gray-700 text-white text-sm px-3 py-1.5 rounded"
              />
              <button
                onClick={() => onRemove(idx)}
                className="ml-2 text-red-400 hover:text-red-300 text-sm flex items-center gap-1"
              >
                <X className="w-4 h-4" />
                Remove
              </button>
            </div>
            <textarea
              value={file.content}
              onChange={e => updateFile(idx, 'content', e.target.value)}
              placeholder={`example.com {\n  reverse_proxy localhost:8080\n}`}
              className="w-full h-48 bg-gray-900 border border-gray-700 rounded-lg p-3 text-white font-mono text-sm"
            />
          </div>
        ))}
      </div>
      <button
        onClick={addFile}
        className="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-white rounded transition-colors"
      >
        + Add File
      </button>
    </div>
  )
}

Sub-Component 3: ImportActions

interface ImportActionsProps {
  onSubmit: () => void
  onCancel: () => void
  disabled: boolean
  loading: boolean
  fileCount: number
}

export function ImportActions({ onSubmit, onCancel, disabled, loading, fileCount }: ImportActionsProps) {
  return (
    <div className="flex gap-3 justify-end mt-4 pt-4 border-t border-gray-800">
      <button
        onClick={onCancel}
        className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded transition-colors"
        disabled={loading}
      >
        Cancel
      </button>
      <button
        onClick={onSubmit}
        disabled={disabled || loading || fileCount === 0}
        className="px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
      >
        {loading ? (
          <span className="flex items-center gap-2">
            <span className="animate-spin"></span>
            Processing...
          </span>
        ) : (
          `Parse and Review (${fileCount} files)`
        )}
      </button>
    </div>
  )
}

Main Component: ImportSitesModal (Orchestrator)

BLOCKING ISSUE #3 RESOLVED: Race condition prevention with isProcessingFiles state

import { useState } from 'react'
import { FileText, Check } from 'lucide-react'
import { uploadCaddyfilesMulti } from '../api/import'
import { FileUploadSection } from './FileUploadSection'
import { ManualEntrySection } from './ManualEntrySection'
import { ImportActions } from './ImportActions'

interface Props {
  visible: boolean
  onClose: () => void
  onUploaded?: () => void
}

interface UploadedFile {
  name: string
  content: string
  size: number
}

export default function ImportSitesModal({ visible, onClose, onUploaded }: Props) {
  const [files, setFiles] = useState<UploadedFile[]>([])
  const [loading, setLoading] = useState(false)
  const [isProcessingFiles, setIsProcessingFiles] = useState(false)  // BLOCKING ISSUE #3
  const [error, setError] = useState<string | null>(null)
  const [mode, setMode] = useState<'upload' | 'manual'>('upload')

  if (!visible) return null

  // BLOCKING ISSUE #3: Handler notifies when processing starts/ends
  const handleFilesProcessed = (newFiles: UploadedFile[]) => {
    setFiles(prev => [...prev, ...newFiles])
    setIsProcessingFiles(false)
  }

  const removeFile = (index: number) => {
    setFiles(prev => prev.filter((_, i) => i !== index))
  }

  const handleSubmit = async () => {
    // BLOCKING ISSUE #3: Prevent submission while files are still processing
    if (isProcessingFiles) {
      setError('Files are still being processed. Please wait.')
      return
    }

    if (files.length === 0) {
      setError('Please upload at least one file')
      return
    }

    // Check for main Caddyfile
    const hasMainCaddyfile = files.some(f => f.name === 'Caddyfile')
    if (!hasMainCaddyfile) {
      setError('Please include a main "Caddyfile" (no extension) in your upload')
      return
    }

    setError(null)
    setLoading(true)

    try {
      // HIGH-PRIORITY IMPROVEMENT #5: Verified endpoint exists (lines 387-446)
      const apiFiles = files.map(f => ({
        filename: f.name,
        content: f.content,
      }))

      await uploadCaddyfilesMulti(apiFiles)
      setLoading(false)
      if (onUploaded) onUploaded()
      onClose()
    } catch (err: unknown) {
      const msg = err instanceof Error ? err.message : String(err)
      setError(msg || 'Upload failed')
      setLoading(false)
    }
  }

  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center" data-testid="multi-site-modal">
      <div className="absolute inset-0 bg-black/60" onClick={onClose} />
      <div className="relative bg-dark-card rounded-lg p-6 w-[900px] max-w-full max-h-[90vh] overflow-auto">
        <h3 className="text-xl font-semibold text-white mb-2">Multi-File Import</h3>
        <p className="text-gray-400 text-sm mb-4">
          Upload your main Caddyfile along with any imported site files. Supports drag-and-drop.
        </p>

        {/* Mode Toggle */}
        <div className="flex gap-2 mb-4">
          <button
            onClick={() => setMode('upload')}
            className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
              mode === 'upload' ? 'bg-blue-500 text-white' : 'bg-gray-800 text-gray-400 hover:bg-gray-700'
            }`}
          >
            📁 File Upload
          </button>
          <button
            onClick={() => setMode('manual')}
            className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
              mode === 'manual' ? 'bg-blue-500 text-white' : 'bg-gray-800 text-gray-400 hover:bg-gray-700'
            }`}
          >
            ✍️ Manual Entry
          </button>
        </div>

        {/* Mode-Specific Content */}
        {mode === 'upload' ? (
          <>
            <FileUploadSection
              onFilesProcessed={handleFilesProcessed}
              processing={isProcessingFiles}
            />

            {/* Uploaded Files List */}
            {files.length > 0 && (
              <div className="space-y-2 mb-4 max-h-80 overflow-y-auto">
                <h4 className="text-sm font-medium text-gray-300">
                  Uploaded Files ({files.length})
                </h4>
                {files.map((file, idx) => (
                  <div key={idx} className="flex items-start gap-3 p-3 bg-gray-900 rounded-lg border border-gray-800">
                    <FileText className="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" />
                    <div className="flex-1 min-w-0">
                      <div className="flex items-center gap-2">
                        <p className="text-sm font-medium text-white truncate">{file.name}</p>
                        {file.name === 'Caddyfile' && (
                          <span className="flex items-center gap-1 px-2 py-0.5 text-xs bg-green-900/30 text-green-400 rounded">
                            <Check className="w-3 h-3" />
                            Main
                          </span>
                        )}
                      </div>
                      <p className="text-xs text-gray-500">{(file.size / 1024).toFixed(1)} KB</p>
                    </div>
                    <button
                      onClick={() => removeFile(idx)}
                      className="text-gray-400 hover:text-red-400 transition-colors"
                      aria-label={`Remove ${file.name}`}
                    >
                      ×
                    </button>
                  </div>
                ))}
              </div>
            )}
          </>
        ) : (
          <ManualEntrySection files={files} setFiles={setFiles} onRemove={removeFile} />
        )}

        {/* Global Error Display */}
        {error && (
          <div className="bg-red-900/20 border border-red-500 text-red-400 px-4 py-2 rounded mb-4">
            {error}
          </div>
        )}

        {/* Action Buttons */}
        <ImportActions
          onSubmit={handleSubmit}
          onCancel={onClose}
          disabled={isProcessingFiles}  // BLOCKING ISSUE #3: Disable while processing
          loading={loading}
          fileCount={files.length}
        />
      </div>
    </div>
  )
}

Summary of Blocking Issue Resolutions:

Issue Before After Impact
#2: File Error Handling catch logged but didn't show user setError() displays clear message Users see why file failed
#3: Race Condition Could submit before files read isProcessingFiles state disables submit Prevents incomplete submissions
#4: File Limits No validation, could crash browser 5MB/50 file limits with clear errors Better UX, prevents crashes
#7: Component Split 300+ line monolith 4 components ~50-100 lines each Easier to maintain/test

Testing Strategy

Frontend Unit Tests: frontend/src/components/__tests__/ImportSitesModal.test.tsx

test('displays file upload input', () => {
  const { getByTestId } = render(<ImportSitesModal visible={true} onClose={jest.fn()} />)
  expect(getByTestId('file-input')).toBeInTheDocument()
})

test('processes uploaded files and displays list', async () => {
  const { getByTestId, getByText } = render(<ImportSitesModal visible={true} onClose={jest.fn()} />)

  const file1 = new File(['content1'], 'Caddyfile', { type: 'text/plain' })
  const file2 = new File(['content2'], 'app.caddy', { type: 'text/plain' })

  const input = getByTestId('file-input') as HTMLInputElement
  fireEvent.change(input, { target: { files: [file1, file2] } })

  await waitFor(() => {
    expect(getByText('Caddyfile')).toBeInTheDocument()
    expect(getByText('app.caddy')).toBeInTheDocument()
  })
})

test('marks main Caddyfile with badge', async () => {
  const { getByTestId, getByText } = render(<ImportSitesModal visible={true} onClose={jest.fn()} />)

  const mainFile = new File(['content'], 'Caddyfile', { type: 'text/plain' })
  const input = getByTestId('file-input') as HTMLInputElement
  fireEvent.change(input, { target: { files: [mainFile] } })

  await waitFor(() => {
    expect(getByText('Main')).toBeInTheDocument()
  })
})

test('allows removing uploaded files', async () => {
  const { getByTestId, getByText, getByLabelText, queryByText } = render(
    <ImportSitesModal visible={true} onClose={jest.fn()} />
  )

  const file = new File(['content'], 'app.caddy', { type: 'text/plain' })
  const input = getByTestId('file-input') as HTMLInputElement
  fireEvent.change(input, { target: { files: [file] } })

  await waitFor(() => expect(getByText('app.caddy')).toBeInTheDocument())

  // Click remove button
  const removeButton = getByLabelText('Remove app.caddy')
  fireEvent.click(removeButton)

  expect(queryByText('app.caddy')).not.toBeInTheDocument()
})

test('switches between upload and manual mode', () => {
  const { getByText, getByTestId, queryByTestId } = render(
    <ImportSitesModal visible={true} onClose={jest.fn()} />
  )

  // Default: upload mode
  expect(getByTestId('file-input')).toBeInTheDocument()

  // Switch to manual
  fireEvent.click(getByText('✍️ Manual Entry'))
  expect(queryByTestId('file-input')).not.toBeInTheDocument()

  // Switch back to upload
  fireEvent.click(getByText('📁 File Upload'))
  expect(getByTestId('file-input')).toBeInTheDocument()
})

E2E Tests: tests/tasks/caddy-import-debug.spec.ts (Test 6)

The existing Test 6 should now pass:

// Test 6: Multi-File Upload - should support file upload
const fileInput = modal.locator('input[type="file"]')
await expect(fileInput).toBeVisible({ timeout: 5000 })  // ✅ Now passes

// Upload files using setInputFiles
await fileInput.setInputFiles([
  { name: 'Caddyfile', mimeType: 'text/plain', buffer: Buffer.from(mainCaddyfile) },
  { name: 'app.caddy', mimeType: 'text/plain', buffer: Buffer.from(siteCaddyfile) },
])

// Verify files listed
await expect(page.getByText('Caddyfile')).toBeVisible()  // ✅ Now passes
await expect(page.getByText('app.caddy')).toBeVisible()  // ✅ Now passes

Files to Modify

File Lines Changes Complexity
frontend/src/components/FileUploadSection.tsx NEW File upload UI with BLOCKING #2, #3, #4 fixes M (3-4h)
frontend/src/components/ManualEntrySection.tsx NEW Manual textarea entry (IMPROVEMENT #7 split) S (1h)
frontend/src/components/ImportActions.tsx NEW Submit/Cancel buttons (IMPROVEMENT #7 split) S (30min)
frontend/src/components/ImportSitesModal.tsx 1-92 Refactored orchestrator using sub-components M (2-3h)
frontend/src/api/import.ts ~20 Update uploadCaddyfilesMulti signature S (15min)
frontend/src/components/__tests__/FileUploadSection.test.tsx NEW Unit tests for file upload + limits (BLOCKING #4) M (2h)
frontend/src/components/__tests__/ImportSitesModal.test.tsx NEW Integration tests for modal orchestration M (2h)

Total Estimate: L (11-13 hours) (increased due to component split and blocking fixes)


Implementation Phases

Phase 1: Critical Fixes (Priority: Immediate)

Day 1-2: Import Detection + Warning Display (Updated estimates per Supervisor feedback)

Task Issue Files Estimate
Backend: Refactor import detection (BLOCKING #1) Issue 1 import_handler.go 2-3h
Frontend: React Query error handling (IMPROVEMENT #6) Issue 1 ImportCaddy.tsx 1-2h
Frontend: Warning display Issue 2 ImportReviewTable.tsx 3-4h
Unit Tests Issues 1, 2 Test files 2-3h

Total: 8-12 hours (2 days)

Phase 2: UX Improvement (Priority: High)

Day 3-4: Multi-File Upload with Blocking Fixes (Increased due to component split + 4 blocking fixes)

Task Issue Files Estimate
Component split: Sub-components (IMPROVEMENT #7) Issue 3 FileUploadSection, ManualEntrySection, ImportActions 4-5h
Main orchestrator: ImportSitesModal Issue 3 ImportSitesModal.tsx 2-3h
File upload + drag-drop + limits (BLOCKING #2-4) Issue 3 FileUploadSection.tsx 3-4h
Unit Tests (including limit validation) Issue 3 Test files 2-3h

Total: 11-15 hours (2-3 days)

Phase 3: Testing & QA (Priority: Final)

Day 5-6: E2E Validation & Documentation (Extended for additional test coverage)

Task Scope Estimate
Run E2E test suite Tests 1-6 1h
Fix regressions Any 2-4h
Update docs All 1-2h
Code review polish All 1-2h

Total: 5-9 hours (1-2 days)


Overall Timeline

Original Estimate: 13-18 hours Updated Estimate (Supervisor Approved): 24-36 hours

Rationale for Increase:

  • Component Split (+3-4h): Breaking monolithic modal into testable components
  • Blocking Fixes (+2-3h): Error handling, race conditions, file limits
  • React Query Migration (+1h): Consistent error handling patterns
  • Additional Testing (+2-3h): More edge cases, limit validation, race condition tests

Success Criteria

Issue 1: Import Directive Detection

  • BLOCKING #1: Single import detection point (no duplication)
  • IMPROVEMENT #6: Using React Query error handling (no custom errorDetails state)
  • Backend detects import directives after parse when no hosts found
  • API returns 400 with imports array and hint field
  • Frontend displays detected imports in red error box using React Query error object
  • "Switch to Multi-File Import" button appears and works
  • Test 2 passes without failures
  • No regression in Test 1 (backward compatibility)

Issue 2: Warning Display

  • Warning badge appears in status column for hosts with warnings
  • Warning details expand when row is clicked
  • Yellow theme (bg-yellow-900/20) used for accessibility
  • Warning summary banner displays when multiple hosts have warnings
  • Test 3 passes without failures
  • All warning types (file_server, rewrite) display correctly

Issue 3: Multi-File Upload UX

  • BLOCKING #2: File read errors display to user (setError in catch block)
  • BLOCKING #3: Race condition prevented (isProcessingFiles state, disabled submit)
  • BLOCKING #4: File limits enforced (5MB per file, 50 files max with clear errors)
  • IMPROVEMENT #7: Component split (4 components instead of 1 monolith)
  • IMPROVEMENT #5: Backend endpoint verified (exists at lines 387-446)
  • File upload input accepts multiple files
  • Drag-drop area highlights on dragover
  • Uploaded files display in list with name, size, remove button
  • Main "Caddyfile" detected and marked with badge
  • Manual entry mode still available via toggle
  • Test 6 passes without failures
  • Multi-file API successfully parses imported hosts

Additional Quality Gates

  • All unit tests pass with 100% patch coverage
  • No regressions in existing tests
  • E2E test suite passes completely (6/6 tests)
  • Code review approved
  • Documentation updated (import-guide.md)

File Structure for New Components

To maintain consistency and aid in implementation, new files should be created in the following locations:

frontend/src/
├── components/
│   ├── FileUploadSection.tsx          # NEW: File upload UI with drag-drop
│   ├── ManualEntrySection.tsx         # NEW: Textarea-based manual entry
│   ├── ImportActions.tsx              # NEW: Submit/Cancel button group
│   ├── ImportSitesModal.tsx           # REFACTOR: Orchestrator using above
│   ├── ImportReviewTable.tsx          # UPDATE: Add warning display
│   └── __tests__/
│       ├── FileUploadSection.test.tsx     # NEW: Test file limits, error handling
│       ├── ManualEntrySection.test.tsx    # NEW: Test manual mode
│       ├── ImportActions.test.tsx         # NEW: Test button states
│       └── ImportSitesModal.test.tsx      # UPDATE: Test orchestration
├── pages/
│   └── ImportCaddy.tsx                # UPDATE: Use React Query errors
└── api/
    └── import.ts                      # UPDATE: uploadCaddyfilesMulti signature

backend/internal/api/handlers/
├── import_handler.go                  # UPDATE: Refactor import detection
└── import_handler_test.go             # UPDATE: Add new test cases

Creation Order (RECOMMENDED):

  1. Create sub-components first: FileUploadSection, ManualEntrySection, ImportActions
  2. Write unit tests for each sub-component
  3. Refactor ImportSitesModal to use sub-components
  4. Update ImportCaddy.tsx for React Query errors
  5. Update import_handler.go with single detection point
  6. Run E2E test suite to validate integration

Testing Requirements

Unit Test Coverage

All modified files must have corresponding unit tests:

  • backend/internal/api/handlers/import_handler_test.go - Early import detection
  • frontend/src/components/__tests__/ImportReviewTable.test.tsx - Warning display
  • frontend/src/components/__tests__/ImportSitesModal.test.tsx - File upload

E2E Test Coverage

All 6 tests in tests/tasks/caddy-import-debug.spec.ts must pass:

  • Test 1: Baseline (should continue to pass, no changes)
  • Test 2: Import Directives (should pass after Issue 1 fix)
  • Test 3: File Servers (should pass after Issue 2 fix)
  • Test 4: Invalid Syntax (should pass, no changes)
  • Test 5: Mixed Content (should pass after Issue 2 fix)
  • Test 6: Multi-File (should pass after Issue 3 fix)

Patch Coverage Requirement

Codecov patch coverage must be 100% for all modified lines per project requirements.

Backward Compatibility

CRITICAL: Test 1 (baseline) must continue to pass without any behavioral changes for users who upload standard Caddyfiles without imports.


Risk Assessment

Risk Likelihood Impact Mitigation
Breaking existing import flow Low High Extensive backward compatibility tests (Test 1)
Warning display regression Low Medium Reuse existing conflict expansion pattern
File upload MIME type issues Medium Low Accept multiple extensions (.caddyfile, .caddy, .txt)
Multi-file modal too complex Medium Medium Keep toggle for manual entry fallback
Import detection false positives Low Medium Only trigger on exact "import " prefix, not comments

Dependencies

  • No external library dependencies required
  • All changes use existing UI patterns (warning badges, expansion rows)
  • Backend uses existing detectImportDirectives() function
  • Frontend uses existing fetch/axios for API calls

Rollback Strategy

If any phase causes regressions:

  1. Issue 1 (Import Detection): Remove early detection logic, keep fallback (lines 297-305)
  2. Issue 2 (Warning Display): Hide warning UI via feature flag
  3. Issue 3 (Multi-File Upload): Revert to textarea modal, keep new modal in feature branch

All changes are additive and can be disabled without breaking existing functionality.



Verification Checklist for Blocking Issues

Before merging, developers must verify the following:

BLOCKING #1: Duplicate Import Detection (Backend)

  • Search codebase for detectImportDirectives() calls - should only exist ONCE in Upload handler
  • Check lines 258-275 and 297-305 in import_handler.go - no duplicate logic
  • Verify single detection point handles both scenarios correctly
  • Run backend unit test: TestUpload_ImportDetection_SinglePoint

How to verify:

cd backend
grep -n "detectImportDirectives" internal/api/handlers/import_handler.go
# Should show only 1 call in the "no hosts found" branch

BLOCKING #2: File Upload Error Handling (Frontend)

  • processFiles() in FileUploadSection.tsx has try-catch around file.text()
  • Catch block calls setError() with user-friendly message
  • Error message includes filename that failed
  • Error state displays in UI (red border, AlertTriangle icon)
  • Run unit test: test('displays error when file.text() fails')

How to verify:

cd frontend
# Inspect FileUploadSection.tsx for error handling
grep -A 5 "file.text()" src/components/FileUploadSection.tsx
# Should see try-catch with setError() in catch block

BLOCKING #3: Race Condition Prevention (Frontend)

  • isProcessingFiles state initialized to false
  • State set to true when processFiles() starts
  • State set to false when handleFilesProcessed() completes
  • Submit button has disabled={isProcessingFiles || loading}
  • handleSubmit() checks if (isProcessingFiles) and shows error
  • Run unit test: test('disables submit while processing files')

How to verify:

cd frontend
# Check state management
grep -n "isProcessingFiles" src/components/ImportSitesModal.tsx
# Should show: initialization, setter calls, disable logic

BLOCKING #4: File Size/Count Limits (Frontend)

  • Constants defined: MAX_FILE_SIZE = 5 * 1024 * 1024 and MAX_FILE_COUNT = 50
  • processFiles() validates count: if (selectedFiles.length > MAX_FILE_COUNT)
  • processFiles() validates size: oversizedFiles.filter(f => f.size > MAX_FILE_SIZE)
  • Both validations call setError() with clear message showing limits
  • Error messages include actual values (e.g., "You selected 75 files")
  • Run unit test: test('rejects files over 5MB limit')
  • Run unit test: test('rejects more than 50 files')

How to verify:

cd frontend
# Check validation logic
grep -A 10 "MAX_FILE" src/components/FileUploadSection.tsx
# Should show constant definitions and validation checks

HIGH-PRIORITY #5: Backend Endpoint Exists

  • Confirmed: /api/v1/import/upload-multi exists at import_handler.go:387-446
  • Endpoint accepts files array with filename and content fields
  • No additional backend implementation needed for Issue 3
  • Test endpoint with Postman/curl to verify functionality

How to verify:

cd backend
grep -n "UploadMulti" internal/api/handlers/import_handler.go
# Should show function definition around line 387

HIGH-PRIORITY #6: React Query Error Handling

  • ImportCaddy.tsx uses useMutation from React Query
  • Error accessed via uploadMutation.error?.response?.data
  • No custom errorDetails state in component
  • No separate setError() calls in mutation handlers
  • Error display uses uploadMutation.isError condition

How to verify:

cd frontend
# Check for React Query usage
grep "useMutation\|uploadMutation" src/pages/ImportCaddy.tsx
# Search for removed custom state
grep "errorDetails" src/pages/ImportCaddy.tsx
# Should NOT find custom errorDetails state

HIGH-PRIORITY #7: Component Split

  • ImportSitesModal.tsx is ≤150 lines (orchestrator only)
  • FileUploadSection.tsx exists and is ≤100 lines
  • ManualEntrySection.tsx exists and is ≤100 lines
  • ImportActions.tsx exists and is ≤60 lines
  • Each component has corresponding test file
  • All 4 components have clear, single responsibilities

How to verify:

cd frontend/src/components
wc -l ImportSitesModal.tsx FileUploadSection.tsx ManualEntrySection.tsx ImportActions.tsx
# All should be under target line counts

END OF SPECIFICATION