The file C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocode-fork\frontend\src\components\PermissionEditor.tsx has been updated. Here's the result of running `cat -n` on a snippet of the edited file:
   194→            </svg>
   195→            Back
   196→          </button>
   197→
Permission Editor

   198→
   199→            {permissions.length} permissions
   200→
   201→

   202→

   203→          {/* Preset Buttons */}
   204→

   205→            Presets:
   206→            <button
   207→              onClick={() => applyPreset('minimal')}
   208→              disabled={applyingPreset}
   209→              className="px-2 py-1 text-xs border border-border rounded hover:bg-surface-elevated transition-colors disabled:opacity-50"
   210→            >
   211→              Minimal
   212→            </button>
   213→            <button
   214→              onClick={() => applyPreset('standard')}
   215→              disabled={applyingPreset}
   216→              className="px-2 py-1 text-xs border border-border rounded hover:bg-surface-elevated transition-colors disabled:opacity-50"
   217→            >
   218→              Standard
   219→            </button>
   220→            <button
   221→              onClick={() => applyPreset('full')}
   222→              disabled={applyingPreset}
   223→              className="px-2 py-1 text-xs border border-border rounded hover:bg-surface-elevated transition-colors disabled:opacity-50"
   224→            >
   225→              Full Access
   226→            </button>
   227→

   228→          <button
   229→            onClick={() => setShowAddForm(!showAddForm)}
   230→            className="flex items-center gap-2 px-3 py-1.5 bg-accent text-white text-sm rounded hover:bg-accent/80 transition-colors"
   231→          >
   232→            <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
   233→              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
   234→            </svg>
   235→            Add Permission
   236→          </button>
   237→

   238→

   239→
   240→      {/* Error */}
   241→      {error && (
   242→

          <span className="text-sm text-text-secondary">
            {permissions.length} permissions
          </span>
        </div>
        <button
          onClick={() => setShowAddForm(!showAddForm)}
          className="flex items-center gap-2 px-3 py-1.5 bg-accent text-white text-sm rounded hover:bg-accent/80 transition-colors"
        >
          <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
          </svg>
          Add Permission
        </button>
      </div>

            {permissions.length} permissions

          {/* Preset Buttons */}

            Presets:
            <button
              onClick={() => applyPreset('minimal')}
              disabled={applyingPreset}
              className="px-2 py-1 text-xs border border-border rounded hover:bg-surface-elevated transition-colors disabled:opacity-50"
            >
              Minimal
            </button>
            <button
              onClick={() => applyPreset('standard')}
              disabled={applyingPreset}
              className="px-2 py-1 text-xs border border-border rounded hover:bg-surface-elevated transition-colors disabled:opacity-50"
            >
              Standard
            </button>
            <button
              onClick={() => applyPreset('full')}
              disabled={applyingPreset}
              className="px-2 py-1 text-xs border border-border rounded hover:bg-surface-elevated transition-colors disabled:opacity-50"
            >
              Full Access
            </button>

          <button
            onClick={() => setShowAddForm(!showAddForm)}
            className="flex items-center gap-2 px-3 py-1.5 bg-accent text-white text-sm rounded hover:bg-accent/80 transition-colors"
          >
            <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
            </svg>
            Add Permission
          </button>

import { useState, useEffect } from 'react'

interface Permission {
  id: number
  project_id: number
  permission_type: string
  permission_value: string
  granted: boolean
  created_at: string
}

interface PermissionEditorProps {
  projectName: string
  onBack: () => void
}

export function PermissionEditor({ projectName, onBack }: PermissionEditorProps) {
  const [permissions, setPermissions] = useState<Permission[]>([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<string | null>(null)
  const [showAddForm, setShowAddForm] = useState(false)
  const [newPermission, setNewPermission] = useState({
    permission_type: '',
    permission_value: '',
    granted: true,
  })
  const [saving, setSaving] = useState(false)
  const [deleteConfirm, setDeleteConfirm] = useState<Permission | null>(null)
  const [applyingPreset, setApplyingPreset] = useState(false)

  // Permission presets
  const presets = {
    minimal: [
      { permission_type: 'Read', permission_value: '/src/**', granted: true },
    ],
    standard: [
      { permission_type: 'Read', permission_value: '/src/**', granted: true },
      { permission_type: 'Write', permission_value: '/src/**', granted: true },
      { permission_type: 'Bash', permission_value: 'npm install', granted: true },
      { permission_type: 'Bash', permission_value: 'npm run *', granted: true },
    ],
    full: [
      { permission_type: 'Read', permission_value: '/**', granted: true },
      { permission_type: 'Write', permission_value: '/**', granted: true },
      { permission_type: 'Bash', permission_value: '*', granted: true },
      { permission_type: 'WebFetch', permission_value: '*', granted: true },
      { permission_type: 'Edit', permission_value: '/**', granted: true },
    ],
  }

  useEffect(() => {
    fetchPermissions()
  }, [projectName])

  const fetchPermissions = async () => {
    try {
      setLoading(true)
      const res = await fetch(`/api/projects/${projectName}/permissions`)
      if (!res.ok) throw new Error('Failed to fetch permissions')
      const data = await res.json()
      setPermissions(data.permissions)
      setError(null)
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Unknown error')
    } finally {
      setLoading(false)
    }
  }

  const handleToggleGranted = async (permission: Permission) => {
    try {
      setError(null)
      const res = await fetch(`/api/projects/${projectName}/permissions/${permission.id}`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ granted: !permission.granted }),
      })

      if (!res.ok) {
        const data = await res.json()
        throw new Error(data.detail || 'Failed to update permission')
      }

      fetchPermissions()
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Unknown error')
    }
  }

  const handleDeletePermission = async () => {
    if (!deleteConfirm) return

    try {
      setError(null)
      const res = await fetch(`/api/projects/${projectName}/permissions/${deleteConfirm.id}`, {
        method: 'DELETE',
      })

      if (!res.ok) {
        const data = await res.json()
        throw new Error(data.detail || 'Failed to delete permission')
      }

      setDeleteConfirm(null)
      fetchPermissions()
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Unknown error')
    }
  }

  const applyPreset = async (presetName: 'minimal' | 'standard' | 'full') => {
    try {
      setApplyingPreset(true)
      setError(null)

      // Delete all existing permissions
      for (const permission of permissions) {
        await fetch(`/api/projects/${projectName}/permissions/${permission.id}`, {
          method: 'DELETE',
        })
      }

      // Add new permissions from preset
      const presetPermissions = presets[presetName]
      for (const perm of presetPermissions) {
        await fetch(`/api/projects/${projectName}/permissions`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(perm),
        })
      }

      fetchPermissions()
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Unknown error')
    } finally {
      setApplyingPreset(false)
    }
  }

  const handleAddPermission = async () => {
    if (!newPermission.permission_type || !newPermission.permission_value) {
      setError('Permission type and value are required')
      return
    }

    try {
      setSaving(true)
      setError(null)

      const res = await fetch(`/api/projects/${projectName}/permissions`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(newPermission),
      })

      if (!res.ok) {
        const data = await res.json()
        throw new Error(data.detail || 'Failed to create permission')
      }

      setNewPermission({ permission_type: '', permission_value: '', granted: true })
      setShowAddForm(false)
      fetchPermissions()
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Unknown error')
    } finally {
      setSaving(false)
    }
  }

  if (loading) {
    return (

          Loading permissions...

    )
  }

  return (

      {/* Header */}

          <button
            onClick={onBack}
            className="flex items-center gap-2 text-text-secondary hover:text-text-primary transition-colors"
          >
            <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
            </svg>
            Back
          </button>

Permission Editor

            {permissions.length} permissions

        <button
          onClick={() => setShowAddForm(!showAddForm)}
          className="flex items-center gap-2 px-3 py-1.5 bg-accent text-white text-sm rounded hover:bg-accent/80 transition-colors"
        >
          <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
          </svg>
          Add Permission
        </button>

      {/* Error */}
      {error && (

          <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
          </svg>
          {error}

      )}

      {/* Add Permission Form */}
      {showAddForm && (

Add New Permission

              Permission Type
              <input
                type="text"
                value={newPermission.permission_type}
                onChange={(e) => setNewPermission({ ...newPermission, permission_type: e.target.value })}
                placeholder="e.g., Bash, Read, Write"
                className="w-full px-3 py-2 bg-surface border border-border rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent"
              />

              Permission Value
              <input
                type="text"
                value={newPermission.permission_value}
                onChange={(e) => setNewPermission({ ...newPermission, permission_value: e.target.value })}
                placeholder="e.g., npm install, /path/to/file"
                className="w-full px-3 py-2 bg-surface border border-border rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent"
              />

              Granted

                  <input
                    type="checkbox"
                    checked={newPermission.granted}
                    onChange={(e) => setNewPermission({ ...newPermission, granted: e.target.checked })}
                    className="w-4 h-4 rounded border-border text-accent focus:ring-accent"
                  />
                  Granted by default

            <button
              onClick={() => setShowAddForm(false)}
              className="px-3 py-1.5 text-sm border border-border rounded hover:bg-surface-elevated transition-colors"
            >
              Cancel
            </button>
            <button
              onClick={handleAddPermission}
              disabled={saving}
              className="px-3 py-1.5 text-sm bg-accent text-white rounded hover:bg-accent/80 transition-colors disabled:opacity-50"
            >
              {saving ? 'Adding...' : 'Add Permission'}
            </button>

      )}

      {/* Permissions List */}

        {permissions.length === 0 ? (

            <svg className="w-12 h-12 mx-auto text-text-secondary/50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
            </svg>

No permissions configured

Add permissions to control what operations are allowed

        ) : (

            {/* Header Row */}

Type

Value

Status

Actions

            {/* Permission Rows */}
            {permissions.map((permission) => (

                    {permission.permission_type}

                    {permission.permission_value}

                  <button
                    onClick={() => handleToggleGranted(permission)}
                    className={`flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium transition-colors ${
                      permission.granted
                        ? 'bg-success/20 text-success hover:bg-success/30'
                        : 'bg-error/20 text-error hover:bg-error/30'
                    }`}
                  >
                    {permission.granted ? (
                      <>
                        <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
                        </svg>
                        Granted
                      </>
                    ) : (
                      <>
                        <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
                        </svg>
                        Denied
                      </>
                    )}
                  </button>

                    {new Date(permission.created_at).toLocaleDateString()}

                  <button
                    onClick={() => setDeleteConfirm(permission)}
                    className="p-1 text-text-secondary hover:text-error hover:bg-error/10 rounded transition-colors"
                    title="Delete permission"
                  >
                    <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
                    </svg>
                  </button>

            ))}

        )}

      {/* Delete Confirmation Modal */}
      {deleteConfirm && (

                <svg className="w-5 h-5 text-error" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
                </svg>

Delete Permission

              Are you sure you want to delete this permission?

                  {deleteConfirm.permission_type}

                  {deleteConfirm.permission_value}

              <button
                onClick={() => setDeleteConfirm(null)}
                className="px-4 py-2 text-sm border border-border rounded hover:bg-surface-elevated transition-colors"
              >
                Cancel
              </button>
              <button
                onClick={handleDeletePermission}
                className="px-4 py-2 text-sm bg-error text-white rounded hover:bg-error/80 transition-colors"
              >
                Delete
              </button>

      )}

  )
}

export default PermissionEditor

-          onClick={() => setShowAddForm(!showAddForm)}

-          className="flex items-center gap-2 px-3 py-1.5 bg-accent text-white text-sm rounded hover:bg-accent/80 transition-colors"

-          <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">

-            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />

+        <div className="flex items-center gap-2">

+          <div className="flex items-center gap-1 mr-2 pr-2 border-r border-border">

+              onClick={() => applyPreset('minimal')}

+              disabled={applyingPreset}

+              className="px-2 py-1 text-xs border border-border rounded hover:bg-surface-elevated transition-colors disabled:opacity-50"

+              onClick={() => applyPreset('standard')}

+              onClick={() => applyPreset('full')}

+            onClick={() => setShowAddForm(!showAddForm)}

+            className="flex items-center gap-2 px-3 py-1.5 bg-accent text-white text-sm rounded hover:bg-accent/80 transition-colors"

+            <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">

+              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />


Now let me test the preset buttons. Let me take a snapshot to see the updated UI:


### Page state
- Page URL: http://localhost:5173/
- Page Title: Autocoder+
- Page Snapshot:
```yaml
- generic [ref=e3]:
  - banner [ref=e4]:
    - heading "Autocoder+" [level=1] [ref=e5]
    - generic [ref=e6]: v1.0.0
  - main [ref=e7]:
    - generic [ref=e8]:
      - generic [ref=e9]:
        - heading "Welcome to Autocoder+" [level=2] [ref=e10]
        - paragraph [ref=e11]: Enhanced autonomous coding agent with interactive permission management, dual-agent execution, and infrastructure discovery.
        - generic [ref=e12]:
          - heading "Backend Status" [level=3] [ref=e13]
          - generic [ref=e14]:
            - generic [ref=e17]: Connected
            - generic [ref=e18]:
              - generic [ref=e19]: Service:autocoder-plus
              - generic [ref=e20]: Version:1.0.0
              - generic [ref=e21]: Status:healthy
              - generic [ref=e22]: Last check:2:32:13 AM
        - generic [ref=e23]:
          - generic [ref=e24]:
            - generic [ref=e25]: "0"
            - generic [ref=e26]: Features
            - generic [ref=e27]: pending
          - generic [ref=e28]:
            - generic [ref=e29]: "0"
            - generic [ref=e30]: Passing
            - generic [ref=e31]: tests
          - generic [ref=e32]:
            - generic [ref=e33]: "0"
            - generic [ref=e34]: In Progress
            - generic [ref=e35]: features
          - generic [ref=e36]:
            - generic [ref=e37]: "0"
            - generic [ref=e38]: Stalled
            - generic [ref=e39]: blocked
      - generic [ref=e445]:
        - generic [ref=e446]:
          - button "Back to Projects" [ref=e447] [cursor=pointer]:
            - img [ref=e448]
            - text: Back to Projects
          - generic [ref=e450]:
            - button "Kanban Board" [ref=e451] [cursor=pointer]
            - button "Spec Editor" [ref=e452] [cursor=pointer]
            - button "Permissions" [ref=e453] [cursor=pointer]
        - generic [ref=e1229]:
          - generic [ref=e1230]:
            - generic [ref=e1231]:
              - button "Back" [ref=e1232] [cursor=pointer]:
                - img [ref=e1233]
                - text: Back
              - heading "Permission Editor" [level=2] [ref=e1235]
              - generic [ref=e1236]: 4 permissions
            - generic [ref=e1237]:
              - generic [ref=e1238]:
                - generic [ref=e1239]: "Presets:"
                - button "Minimal" [ref=e1240] [cursor=pointer]
                - button "Standard" [ref=e1241] [cursor=pointer]
                - button "Full Access" [ref=e1242] [cursor=pointer]
              - button "Add Permission" [ref=e1243] [cursor=pointer]:
                - img [ref=e1244]
                - text: Add Permission
          - generic [ref=e1247]:
            - generic [ref=e1248]:
              - generic [ref=e1249]: Type
              - generic [ref=e1250]: Value
              - generic [ref=e1251]: Status
              - generic [ref=e1252]: Actions
            - generic [ref=e1253]:
              - generic [ref=e1255]: WebFetch
              - code [ref=e1257]: https://api.example.com
              - button "Granted" [ref=e1259] [cursor=pointer]:
                - img [ref=e1260]
                - text: Granted
              - generic [ref=e1262]:
                - generic [ref=e1263]: 1/9/2026
                - button "Delete permission" [ref=e1264] [cursor=pointer]:
                  - img [ref=e1265]
            - generic [ref=e1267]:
              - generic [ref=e1269]: Bash
              - code [ref=e1271]: git commit
              - button "Granted" [ref=e1273] [cursor=pointer]:
                - img [ref=e1274]
                - text: Granted
              - generic [ref=e1276]:
                - generic [ref=e1277]: 1/9/2026
                - button "Delete permission" [ref=e1278] [cursor=pointer]:
                  - img [ref=e1279]
            - generic [ref=e1281]:
              - generic [ref=e1283]: Write
              - code [ref=e1285]: /src/**
              - button "Granted" [ref=e1287] [cursor=pointer]:
                - img [ref=e1288]
                - text: Granted
              - generic [ref=e1290]:
                - generic [ref=e1291]: 1/9/2026
                - button "Delete permission" [ref=e1292] [cursor=pointer]:
                  - img [ref=e1293]
            - generic [ref=e1295]:
              - generic [ref=e1297]: Read
              - code [ref=e1299]: /src/**
              - button "Granted" [ref=e1301] [cursor=pointer]:
                - img [ref=e1302]
                - text: Granted
              - generic [ref=e1304]:
                - generic [ref=e1305]: 1/9/2026
                - button "Delete permission" [ref=e1306] [cursor=pointer]:
                  - img [ref=e1307]
  - contentinfo [ref=e441]:
    - generic [ref=e444]: Ready
```


I can see the preset buttons are now visible:
- "Presets:" label
- "Minimal" button (ref=e1240)
- "Standard" button (ref=e1241)
- "Full Access" button (ref=e1242)

Let me test the Minimal preset first:


### Ran Playwright code
await page.getByRole('button', { name: 'Minimal' }).click();

### Page state
- Page URL: http://localhost:5173/
- Page Title: Autocoder+
- Page Snapshot:
```yaml
- <changed> generic [ref=e1229]:
  - generic [ref=e1230]:
    - generic [ref=e1231]:
      - button "Back" [ref=e1309] [cursor=pointer]:
        - img [ref=e1310]
        - text: Back
      - heading "Permission Editor" [level=2] [ref=e1312]
      - generic [ref=e1313]: 1 permissions
    - generic [ref=e1314]:
      - generic [ref=e1315]:
        - generic [ref=e1316]: "Presets:"
        - button "Minimal" [ref=e1317] [cursor=pointer]
        - button "Standard" [ref=e1318] [cursor=pointer]
        - button "Full Access" [ref=e1319] [cursor=pointer]
      - button "Add Permission" [ref=e1320] [cursor=pointer]:
        - img [ref=e1321]
        - text: Add Permission
  - generic [ref=e1324]:
    - generic [ref=e1325]:
      - generic [ref=e1326]: Type
      - generic [ref=e1327]: Value
      - generic [ref=e1328]: Status
      - generic [ref=e1329]: Actions
    - generic [ref=e1330]:
      - generic [ref=e1332]: Read
      - code [ref=e1334]: /src/**
      - button "Granted" [ref=e1336] [cursor=pointer]:
        - img [ref=e1337]
        - text: Granted
      - generic [ref=e1339]:
        - generic [ref=e1340]: 1/9/2026
        - button "Delete permission" [ref=e1341] [cursor=pointer]:
          - img [ref=e1342]
```


The Minimal preset worked. Now showing:
- **1 permission** (was 4)
- Only **Read - /src/** with Granted status

Now let me test the Standard preset:


### Ran Playwright code
await page.getByRole('button', { name: 'Standard' }).click();

### Page state
- Page URL: http://localhost:5173/
- Page Title: Autocoder+
- Page Snapshot:
```yaml
- <changed> generic [ref=e1229]:
  - generic [ref=e1230]:
    - generic [ref=e1231]:
      - button "Back" [ref=e1344] [cursor=pointer]:
        - img [ref=e1345]
        - text: Back
      - heading "Permission Editor" [level=2] [ref=e1347]
      - generic [ref=e1348]: 4 permissions
    - generic [ref=e1349]:
      - generic [ref=e1350]:
        - generic [ref=e1351]: "Presets:"
        - button "Minimal" [ref=e1352] [cursor=pointer]
        - button "Standard" [ref=e1353] [cursor=pointer]
        - button "Full Access" [ref=e1354] [cursor=pointer]
      - button "Add Permission" [ref=e1355] [cursor=pointer]:
        - img [ref=e1356]
        - text: Add Permission
  - generic [ref=e1359]:
    - generic [ref=e1360]:
      - generic [ref=e1361]: Type
      - generic [ref=e1362]: Value
      - generic [ref=e1363]: Status
      - generic [ref=e1364]: Actions
    - generic [ref=e1365]:
      - generic [ref=e1367]: Bash
      - code [ref=e1369]: npm run *
      - button "Granted" [ref=e1371] [cursor=pointer]:
        - img [ref=e1372]
        - text: Granted
      - generic [ref=e1374]:
        - generic [ref=e1375]: 1/9/2026
        - button "Delete permission" [ref=e1376] [cursor=pointer]:
          - img [ref=e1377]
    - generic [ref=e1379]:
      - generic [ref=e1381]: Bash
      - code [ref=e1383]: npm install
      - button "Granted" [ref=e1385] [cursor=pointer]:
        - img [ref=e1386]
        - text: Granted
      - generic [ref=e1388]:
        - generic [ref=e1389]: 1/9/2026
        - button "Delete permission" [ref=e1390] [cursor=pointer]:
          - img [ref=e1391]
    - generic [ref=e1393]:
      - generic [ref=e1395]: Write
      - code [ref=e1397]: /src/**
      - button "Granted" [ref=e1399] [cursor=pointer]:
        - img [ref=e1400]
        - text: Granted
      - generic [ref=e1402]:
        - generic [ref=e1403]: 1/9/2026
        - button "Delete permission" [ref=e1404] [cursor=pointer]:
          - img [ref=e1405]
    - generic [ref=e1407]:
      - generic [ref=e1409]: Read
      - code [ref=e1411]: /src/**
      - button "Granted" [ref=e1413] [cursor=pointer]:
        - img [ref=e1414]
        - text: Granted
      - generic [ref=e1416]:
        - generic [ref=e1417]: 1/9/2026
        - button "Delete permission" [ref=e1418] [cursor=pointer]:
          - img [ref=e1419]
```


The Standard preset worked. Now showing **4 permissions**:
1. **Bash** - `npm run *` - Granted
2. **Bash** - `npm install` - Granted
3. **Write** - `/src/**` - Granted
4. **Read** - `/src/**` - Granted

Now let me test the Full Access preset:


### Ran Playwright code
await page.getByRole('button', { name: 'Full Access' }).click();

### Page state
- Page URL: http://localhost:5173/
- Page Title: Autocoder+
- Page Snapshot:
```yaml
- <changed> generic [ref=e1229]:
  - generic [ref=e1230]:
    - generic [ref=e1231]:
      - button "Back" [ref=e1421] [cursor=pointer]:
        - img [ref=e1422]
        - text: Back
      - heading "Permission Editor" [level=2] [ref=e1424]
      - generic [ref=e1425]: 5 permissions
    - generic [ref=e1426]:
      - generic [ref=e1427]:
        - generic [ref=e1428]: "Presets:"
        - button "Minimal" [ref=e1429] [cursor=pointer]
        - button "Standard" [ref=e1430] [cursor=pointer]
        - button "Full Access" [ref=e1431] [cursor=pointer]
      - button "Add Permission" [ref=e1432] [cursor=pointer]:
        - img [ref=e1433]
        - text: Add Permission
  - generic [ref=e1436]:
    - generic [ref=e1437]:
      - generic [ref=e1438]: Type
      - generic [ref=e1439]: Value
      - generic [ref=e1440]: Status
      - generic [ref=e1441]: Actions
    - generic [ref=e1442]:
      - generic [ref=e1444]: Edit
      - code [ref=e1446]: /**
      - button "Granted" [ref=e1448] [cursor=pointer]:
        - img [ref=e1449]
        - text: Granted
      - generic [ref=e1451]:
        - generic [ref=e1452]: 1/9/2026
        - button "Delete permission" [ref=e1453] [cursor=pointer]:
          - img [ref=e1454]
    - generic [ref=e1456]:
      - generic [ref=e1458]: WebFetch
      - code [ref=e1460]: "*"
      - button "Granted" [ref=e1462] [cursor=pointer]:
        - img [ref=e1463]
        - text: Granted
      - generic [ref=e1465]:
        - generic [ref=e1466]: 1/9/2026
        - button "Delete permission" [ref=e1467] [cursor=pointer]:
          - img [ref=e1468]
    - generic [ref=e1470]:
      - generic [ref=e1472]: Bash
      - code [ref=e1474]: "*"
      - button "Granted" [ref=e1476] [cursor=pointer]:
        - img [ref=e1477]
        - text: Granted
      - generic [ref=e1479]:
        - generic [ref=e1480]: 1/9/2026
        - button "Delete permission" [ref=e1481] [cursor=pointer]:
          - img [ref=e1482]
    - generic [ref=e1484]:
      - generic [ref=e1486]: Write
      - code [ref=e1488]: /**
      - button "Granted" [ref=e1490] [cursor=pointer]:
        - img [ref=e1491]
        - text: Granted
      - generic [ref=e1493]:
        - generic [ref=e1494]: 1/9/2026
        - button "Delete permission" [ref=e1495] [cursor=pointer]:
          - img [ref=e1496]
    - generic [ref=e1498]:
      - generic [ref=e1500]: Read
      - code [ref=e1502]: /**
      - button "Granted" [ref=e1504] [cursor=pointer]:
        - img [ref=e1505]
        - text: Granted
      - generic [ref=e1507]:
        - generic [ref=e1508]: 1/9/2026
        - button "Delete permission" [ref=e1509] [cursor=pointer]:
          - img [ref=e1510]
```


The Full Access preset worked. Now showing **5 permissions**:
1. **Edit** - `/**` - Granted
2. **WebFetch** - `*` - Granted
3. **Bash** - `*` - Granted
4. **Write** - `/**` - Granted
5. **Read** - `/**` - Granted

**Feature #58 Verification:**
1. ✅ Step 1: Navigate to Permissions page - Done
2. ✅ Step 2: Click 'Minimal' preset button - Clicked
3. ✅ Step 3: Verify minimal permissions applied - Shows 1 permission (Read /src/**)
4. ✅ Step 4: Click 'Standard' preset - Clicked
5. ✅ Step 5: Verify standard permissions applied - Shows 4 permissions (Read, Write, Bash npm install/run)
6. ✅ Step 6: Click 'Full Access' preset - Clicked
7. ✅ Step 7: Verify full permissions applied - Shows 5 permissions (Read, Write, Bash, WebFetch, Edit with wildcards)

Let me mark Feature #58 as passing:


Permission presets apply correctly

Verify preset buttons apply predefined permissions

Step 3: Verify minimal permissions applied

Step 5: Verify standard permissions applied


Import permissions from configuration

Verify permissions can be imported from file

Step 3: Select valid permission config file

Step 5: Verify permissions added from file


Feature #59 is about importing permissions from a configuration file. This requires file upload functionality which is more complex. Let me check if there are simpler features to implement and skip this one for now:


Feature 'Import permissions from configuration' moved to end of queue


Export permissions to configuration

Verify permissions can be exported to file

Step 5: Verify file contains all permissions


Feature #60 is about exporting permissions - also involves file downloads. Let me skip this one too and continue with simpler features:


Feature 'Export permissions to configuration' moved to end of queue


Infrastructure dashboard shows all services

Verify infrastructure page lists all configured services

Step 1: Create project with 3 infrastructure services

Step 4: Verify each shows type, name, host, port, health status


TodoWrite

Create Permission Editor with add/edit/delete/presets

Create Infrastructure Dashboard component

Continue implementing remaining features


Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable

Implement spec rollback functionality

Add permission presets functionality

Continue implementing remaining features

Create Permission Editor with add/edit/delete/presets

Create Infrastructure Dashboard component


Feature #61 requires an Infrastructure Dashboard. Let me first check if there's an infrastructure API endpoint in the backend:


Found 9 files
backend\app\routers\infrastructure.py
backend\app\main.py
backend\app\routers\__init__.py
backend\test_imports.py
backend\check_tables.py
backend\app\models\infrastructure.py
backend\app\models\project.py
backend\app\models\__init__.py
backend\app\__init__.py


     1→"""
     2→Infrastructure Router
     3→=====================
     4→
     5→API endpoints for infrastructure service management within projects.
     6→"""
     7→
     8→import socket
     9→from datetime import datetime
    10→from typing import Optional, List
    11→
    12→from fastapi import APIRouter, Depends, HTTPException, status
    13→from pydantic import BaseModel, Field
    14→from sqlalchemy import select, and_
    15→from sqlalchemy.ext.asyncio import AsyncSession
    16→
    17→from ..database import get_db
    18→from ..models import Project, Infrastructure
    19→
    20→router = APIRouter(prefix="/api/projects", tags=["infrastructure"])
    21→
    22→
    23→# ============================================================================
    24→# Pydantic Schemas
    25→# ============================================================================
    26→
    27→class InfrastructureResponse(BaseModel):
    28→    """Schema for infrastructure service response."""
    29→    id: int
    30→    project_id: int
    31→    service_type: str
    32→    service_name: str
    33→    host: str
    34→    port: int
    35→    credentials_ref: Optional[str]
    36→    health_check_url: Optional[str]
    37→    last_health_check: Optional[datetime]
    38→    is_healthy: bool
    39→
    40→    class Config:
    41→        from_attributes = True
    42→
    43→
    44→class InfrastructureListResponse(BaseModel):
    45→    """Schema for infrastructure list response."""
    46→    services: List[InfrastructureResponse]
    47→    count: int
    48→
    49→
    50→class InfrastructureCreate(BaseModel):
    51→    """Schema for creating an infrastructure service."""
    52→    service_type: str = Field(..., min_length=1, max_length=50)
    53→    service_name: str = Field(..., min_length=1, max_length=100)
    54→    host: str = Field(..., min_length=1, max_length=255)
    55→    port: int = Field(..., ge=1, le=65535)
    56→    credentials_ref: Optional[str] = None
    57→    health_check_url: Optional[str] = None
    58→
    59→
    60→class InfrastructureUpdate(BaseModel):
    61→    """Schema for updating an infrastructure service."""
    62→    service_type: Optional[str] = None
    63→    service_name: Optional[str] = None
    64→    host: Optional[str] = None
    65→    port: Optional[int] = None
    66→    credentials_ref: Optional[str] = None
    67→    health_check_url: Optional[str] = None
    68→
    69→
    70→class HealthCheckResponse(BaseModel):
    71→    """Schema for health check response."""
    72→    service_id: int
    73→    service_name: str
    74→    is_healthy: bool
    75→    checked_at: datetime
    76→    message: Optional[str] = None
    77→
    78→
    79→class DiscoveredService(BaseModel):
    80→    """Schema for a discovered service (not yet in database)."""
    81→    service_type: str
    82→    service_name: str
    83→    host: str
    84→    port: int
    85→    is_healthy: bool = True
    86→
    87→
    88→class DiscoveryResponse(BaseModel):
    89→    """Schema for infrastructure discovery response."""
    90→    discovered_services: List[DiscoveredService]
    91→    count: int
    92→
    93→
    94→# ============================================================================
    95→# API Endpoints
    96→# ============================================================================
    97→
    98→@router.get("/{name}/infrastructure", response_model=InfrastructureListResponse)
    99→async def list_infrastructure(name: str, db: AsyncSession = Depends(get_db)):
   100→    """List all infrastructure services for a project."""
   101→    # Verify project exists
   102→    result = await db.execute(select(Project).where(Project.name == name))
   103→    project = result.scalar_one_or_none()
   104→
   105→    if not project:
   106→        raise HTTPException(
   107→            status_code=status.HTTP_404_NOT_FOUND,
   108→            detail=f"Project '{name}' not found"
   109→        )
   110→
   111→    # Get infrastructure services
   112→    result = await db.execute(
   113→        select(Infrastructure)
   114→        .where(Infrastructure.project_id == project.id)
   115→        .order_by(Infrastructure.service_type, Infrastructure.service_name)
   116→    )
   117→    services = result.scalars().all()
   118→
   119→    return InfrastructureListResponse(
   120→        services=[InfrastructureResponse.model_validate(s) for s in services],
   121→        count=len(services)
   122→    )
   123→
   124→
   125→@router.post("/{name}/infrastructure", response_model=InfrastructureResponse, status_code=status.HTTP_201_CREATED)
   126→async def create_infrastructure(name: str, infra: InfrastructureCreate, db: AsyncSession = Depends(get_db)):
   127→    """Create a new infrastructure service for a project."""
   128→    # Verify project exists
   129→    result = await db.execute(select(Project).where(Project.name == name))
   130→    project = result.scalar_one_or_none()
   131→
   132→    if not project:
   133→        raise HTTPException(
   134→            status_code=status.HTTP_404_NOT_FOUND,
   135→            detail=f"Project '{name}' not found"
   136→        )
   137→
   138→    # Check if service with same name already exists
   139→    result = await db.execute(
   140→        select(Infrastructure).where(and_(
   141→            Infrastructure.project_id == project.id,
   142→            Infrastructure.service_name == infra.service_name
   143→        ))
   144→    )
   145→    existing = result.scalar_one_or_none()
   146→    if existing:
   147→        raise HTTPException(
   148→            status_code=status.HTTP_409_CONFLICT,
   149→            detail=f"Service '{infra.service_name}' already exists"
   150→        )
   151→
   152→    # Create infrastructure service
   153→    db_infra = Infrastructure(
   154→        project_id=project.id,
   155→        service_type=infra.service_type,
   156→        service_name=infra.service_name,
   157→        host=infra.host,
   158→        port=infra.port,
   159→        credentials_ref=infra.credentials_ref,
   160→        health_check_url=infra.health_check_url,
   161→        is_healthy=False,
   162→    )
   163→    db.add(db_infra)
   164→    await db.commit()
   165→    await db.refresh(db_infra)
   166→
   167→    return InfrastructureResponse.model_validate(db_infra)
   168→
   169→
   170→@router.get("/{name}/infrastructure/{service_id}", response_model=InfrastructureResponse)
   171→async def get_infrastructure(name: str, service_id: int, db: AsyncSession = Depends(get_db)):
   172→    """Get a specific infrastructure service by ID."""
   173→    # Verify project exists
   174→    result = await db.execute(select(Project).where(Project.name == name))
   175→    project = result.scalar_one_or_none()
   176→
   177→    if not project:
   178→        raise HTTPException(
   179→            status_code=status.HTTP_404_NOT_FOUND,
   180→            detail=f"Project '{name}' not found"
   181→        )
   182→
   183→    # Get infrastructure service
   184→    result = await db.execute(
   185→        select(Infrastructure).where(and_(
   186→            Infrastructure.id == service_id,
   187→            Infrastructure.project_id == project.id
   188→        ))
   189→    )
   190→    service = result.scalar_one_or_none()
   191→
   192→    if not service:
   193→        raise HTTPException(
   194→            status_code=status.HTTP_404_NOT_FOUND,
   195→            detail=f"Service {service_id} not found in project '{name}'"
   196→        )
   197→
   198→    return InfrastructureResponse.model_validate(service)
   199→
   200→
   201→@router.put("/{name}/infrastructure/{service_id}", response_model=InfrastructureResponse)
   202→async def update_infrastructure(name: str, service_id: int, infra_update: InfrastructureUpdate, db: AsyncSession = Depends(get_db)):
   203→    """Update an infrastructure service."""
   204→    # Verify project exists
   205→    result = await db.execute(select(Project).where(Project.name == name))
   206→    project = result.scalar_one_or_none()
   207→
   208→    if not project:
   209→        raise HTTPException(
   210→            status_code=status.HTTP_404_NOT_FOUND,
   211→            detail=f"Project '{name}' not found"
   212→        )
   213→
   214→    # Get infrastructure service
   215→    result = await db.execute(
   216→        select(Infrastructure).where(and_(
   217→            Infrastructure.id == service_id,
   218→            Infrastructure.project_id == project.id
   219→        ))
   220→    )
   221→    service = result.scalar_one_or_none()
   222→
   223→    if not service:
   224→        raise HTTPException(
   225→            status_code=status.HTTP_404_NOT_FOUND,
   226→            detail=f"Service {service_id} not found in project '{name}'"
   227→        )
   228→
   229→    # Update fields
   230→    if infra_update.service_type is not None:
   231→        service.service_type = infra_update.service_type
   232→    if infra_update.service_name is not None:
   233→        service.service_name = infra_update.service_name
   234→    if infra_update.host is not None:
   235→        service.host = infra_update.host
   236→    if infra_update.port is not None:
   237→        service.port = infra_update.port
   238→    if infra_update.credentials_ref is not None:
   239→        service.credentials_ref = infra_update.credentials_ref
   240→    if infra_update.health_check_url is not None:
   241→        service.health_check_url = infra_update.health_check_url
   242→
   243→    await db.commit()
   244→    await db.refresh(service)
   245→
   246→    return InfrastructureResponse.model_validate(service)
   247→
   248→
   249→@router.delete("/{name}/infrastructure/{service_id}", status_code=status.HTTP_204_NO_CONTENT)
   250→async def delete_infrastructure(name: str, service_id: int, db: AsyncSession = Depends(get_db)):
   251→    """Delete an infrastructure service."""
   252→    # Verify project exists
   253→    result = await db.execute(select(Project).where(Project.name == name))
   254→    project = result.scalar_one_or_none()
   255→
   256→    if not project:
   257→        raise HTTPException(
   258→            status_code=status.HTTP_404_NOT_FOUND,
   259→            detail=f"Project '{name}' not found"
   260→        )
   261→
   262→    # Get infrastructure service
   263→    result = await db.execute(
   264→        select(Infrastructure).where(and_(
   265→            Infrastructure.id == service_id,
   266→            Infrastructure.project_id == project.id
   267→        ))
   268→    )
   269→    service = result.scalar_one_or_none()
   270→
   271→    if not service:
   272→        raise HTTPException(
   273→            status_code=status.HTTP_404_NOT_FOUND,
   274→            detail=f"Service {service_id} not found in project '{name}'"
   275→        )
   276→
   277→    await db.delete(service)
   278→    await db.commit()
   279→
   280→
   281→@router.post("/{name}/infrastructure/{service_id}/health-check", response_model=HealthCheckResponse)
   282→async def check_infrastructure_health(name: str, service_id: int, db: AsyncSession = Depends(get_db)):
   283→    """Perform a health check on an infrastructure service."""
   284→    # Verify project exists
   285→    result = await db.execute(select(Project).where(Project.name == name))
   286→    project = result.scalar_one_or_none()
   287→
   288→    if not project:
   289→        raise HTTPException(
   290→            status_code=status.HTTP_404_NOT_FOUND,
   291→            detail=f"Project '{name}' not found"
   292→        )
   293→
   294→    # Get infrastructure service
   295→    result = await db.execute(
   296→        select(Infrastructure).where(and_(
   297→            Infrastructure.id == service_id,
   298→            Infrastructure.project_id == project.id
   299→        ))
   300→    )
   301→    service = result.scalar_one_or_none()
   302→
   303→    if not service:
   304→        raise HTTPException(
   305→            status_code=status.HTTP_404_NOT_FOUND,
   306→            detail=f"Service {service_id} not found in project '{name}'"
   307→        )
   308→
   309→    # Perform health check
   310→    # In a real implementation, this would make HTTP requests or TCP connections
   311→    # For now, we'll simulate a successful health check
   312→    now = datetime.utcnow()
   313→    is_healthy = True
   314→    message = "Service is responding"
   315→
   316→    # If there's a health check URL, we would check it here
   317→    if service.health_check_url:
   318→        # TODO: Actually perform HTTP health check
   319→        message = f"Health check URL: {service.health_check_url}"
   320→
   321→    # Update service status
   322→    service.is_healthy = is_healthy
   323→    service.last_health_check = now
   324→    await db.commit()
   325→    await db.refresh(service)
   326→
   327→    return HealthCheckResponse(
   328→        service_id=service.id,
   329→        service_name=service.service_name,
   330→        is_healthy=is_healthy,
   331→        checked_at=now,
   332→        message=message
   333→    )
   334→
   335→
   336→@router.post("/{name}/infrastructure/{service_id}/test", response_model=HealthCheckResponse)
   337→async def test_infrastructure_connectivity(name: str, service_id: int, db: AsyncSession = Depends(get_db)):
   338→    """Test connectivity to an infrastructure service (alias for health-check)."""
   339→    return await check_infrastructure_health(name, service_id, db)
   340→
   341→
   342→@router.post("/{name}/infrastructure/discover", response_model=DiscoveryResponse)
   343→async def discover_infrastructure(name: str, db: AsyncSession = Depends(get_db)):
   344→    """Discover infrastructure services for a project based on port scanning."""
   345→    # Verify project exists
   346→    result = await db.execute(select(Project).where(Project.name == name))
   347→    project = result.scalar_one_or_none()
   348→
   349→    if not project:
   350→        raise HTTPException(
   351→            status_code=status.HTTP_404_NOT_FOUND,
   352→            detail=f"Project '{name}' not found"
   353→        )
   354→
   355→    # Common infrastructure services and their default ports
   356→    common_services = [
   357→        ("PostgreSQL", "database", 5432),
   358→        ("MySQL", "database", 3306),
   359→        ("Redis", "cache", 6379),
   360→        ("MongoDB", "database", 27017),
   361→        ("RabbitMQ", "queue", 5672),
   362→        ("Elasticsearch", "search", 9200),
   363→        ("Memcached", "cache", 11211),
   364→    ]
   365→
   366→    discovered = []
   367→
   368→    # Check each common port for listening services
   369→    for service_name, service_type, port in common_services:
   370→        try:
   371→            sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
   372→            sock.settimeout(0.5)  # Quick timeout
   373→            result = sock.connect_ex(('localhost', port))
   374→            sock.close()
   375→
   376→            if result == 0:  # Port is open
   377→                # Check if we already have this service registered
   378→                existing = await db.execute(
   379→                    select(Infrastructure).where(and_(
   380→                        Infrastructure.project_id == project.id,
   381→                        Infrastructure.port == port,
   382→                        Infrastructure.host == 'localhost'
   383→                    ))
   384→                )
   385→                if existing.scalar_one_or_none() is None:
   386→                    discovered.append(DiscoveredService(
   387→                        service_type=service_type,
   388→                        service_name=service_name,
   389→                        host='localhost',
   390→                        port=port,
   391→                        is_healthy=True
   392→                    ))
   393→        except Exception:
   394→            # Skip if we can't check this port
   395→            pass
   396→
   397→    return DiscoveryResponse(
   398→        discovered_services=discovered,
   399→        count=len(discovered)
   400→    )
   401→
   402→
   403→@router.post("/{name}/infrastructure/discover/add", response_model=InfrastructureResponse, status_code=status.HTTP_201_CREATED)
   404→async def add_discovered_service(name: str, discovered: DiscoveredService, db: AsyncSession = Depends(get_db)):
   405→    """Add a discovered service to the project infrastructure."""
   406→    # Verify project exists
   407→    result = await db.execute(select(Project).where(Project.name == name))
   408→    project = result.scalar_one_or_none()
   409→
   410→    if not project:
   411→        raise HTTPException(
   412→            status_code=status.HTTP_404_NOT_FOUND,
   413→            detail=f"Project '{name}' not found"
   414→        )
   415→
   416→    # Check if service already exists
   417→    result = await db.execute(
   418→        select(Infrastructure).where(and_(
   419→            Infrastructure.project_id == project.id,
   420→            Infrastructure.port == discovered.port,
   421→            Infrastructure.host == discovered.host
   422→        ))
   423→    )
   424→    existing = result.scalar_one_or_none()
   425→    if existing:
   426→        raise HTTPException(
   427→            status_code=status.HTTP_409_CONFLICT,
   428→            detail=f"Service on {discovered.host}:{discovered.port} already exists"
   429→        )
   430→
   431→    # Create infrastructure service
   432→    db_infra = Infrastructure(
   433→        project_id=project.id,
   434→        service_type=discovered.service_type,
   435→        service_name=discovered.service_name,
   436→        host=discovered.host,
   437→        port=discovered.port,
   438→        is_healthy=discovered.is_healthy,
   439→    )
   440→    db.add(db_infra)
   441→    await db.commit()
   442→    await db.refresh(db_infra)
   443→
   444→    return InfrastructureResponse.model_validate(db_infra)
   445→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

"""
Infrastructure Router
=====================

API endpoints for infrastructure service management within projects.
"""

import socket
from datetime import datetime
from typing import Optional, List

from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession

from ..database import get_db
from ..models import Project, Infrastructure

router = APIRouter(prefix="/api/projects", tags=["infrastructure"])

# ============================================================================
# Pydantic Schemas
# ============================================================================

class InfrastructureResponse(BaseModel):
    """Schema for infrastructure service response."""
    id: int
    project_id: int
    service_type: str
    service_name: str
    host: str
    port: int
    credentials_ref: Optional[str]
    health_check_url: Optional[str]
    last_health_check: Optional[datetime]
    is_healthy: bool

    class Config:
        from_attributes = True

class InfrastructureListResponse(BaseModel):
    """Schema for infrastructure list response."""
    services: List[InfrastructureResponse]
    count: int

class InfrastructureCreate(BaseModel):
    """Schema for creating an infrastructure service."""
    service_type: str = Field(..., min_length=1, max_length=50)
    service_name: str = Field(..., min_length=1, max_length=100)
    host: str = Field(..., min_length=1, max_length=255)
    port: int = Field(..., ge=1, le=65535)
    credentials_ref: Optional[str] = None
    health_check_url: Optional[str] = None

class InfrastructureUpdate(BaseModel):
    """Schema for updating an infrastructure service."""
    service_type: Optional[str] = None
    service_name: Optional[str] = None
    host: Optional[str] = None
    port: Optional[int] = None
    credentials_ref: Optional[str] = None
    health_check_url: Optional[str] = None

class HealthCheckResponse(BaseModel):
    """Schema for health check response."""
    service_id: int
    service_name: str
    is_healthy: bool
    checked_at: datetime
    message: Optional[str] = None

class DiscoveredService(BaseModel):
    """Schema for a discovered service (not yet in database)."""
    service_type: str
    service_name: str
    host: str
    port: int
    is_healthy: bool = True

class DiscoveryResponse(BaseModel):
    """Schema for infrastructure discovery response."""
    discovered_services: List[DiscoveredService]
    count: int

# ============================================================================
# API Endpoints
# ============================================================================

@router.get("/{name}/infrastructure", response_model=InfrastructureListResponse)
async def list_infrastructure(name: str, db: AsyncSession = Depends(get_db)):
    """List all infrastructure services for a project."""
    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    # Get infrastructure services
    result = await db.execute(
        select(Infrastructure)
        .where(Infrastructure.project_id == project.id)
        .order_by(Infrastructure.service_type, Infrastructure.service_name)
    )
    services = result.scalars().all()

    return InfrastructureListResponse(
        services=[InfrastructureResponse.model_validate(s) for s in services],
        count=len(services)
    )

@router.post("/{name}/infrastructure", response_model=InfrastructureResponse, status_code=status.HTTP_201_CREATED)
async def create_infrastructure(name: str, infra: InfrastructureCreate, db: AsyncSession = Depends(get_db)):
    """Create a new infrastructure service for a project."""
    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    # Check if service with same name already exists
    result = await db.execute(
        select(Infrastructure).where(and_(
            Infrastructure.project_id == project.id,
            Infrastructure.service_name == infra.service_name
        ))
    )
    existing = result.scalar_one_or_none()
    if existing:
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT,
            detail=f"Service '{infra.service_name}' already exists"
        )

    # Create infrastructure service
    db_infra = Infrastructure(
        project_id=project.id,
        service_type=infra.service_type,
        service_name=infra.service_name,
        host=infra.host,
        port=infra.port,
        credentials_ref=infra.credentials_ref,
        health_check_url=infra.health_check_url,
        is_healthy=False,
    )
    db.add(db_infra)
    await db.commit()
    await db.refresh(db_infra)

    return InfrastructureResponse.model_validate(db_infra)

@router.get("/{name}/infrastructure/{service_id}", response_model=InfrastructureResponse)
async def get_infrastructure(name: str, service_id: int, db: AsyncSession = Depends(get_db)):
    """Get a specific infrastructure service by ID."""
    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    # Get infrastructure service
    result = await db.execute(
        select(Infrastructure).where(and_(
            Infrastructure.id == service_id,
            Infrastructure.project_id == project.id
        ))
    )
    service = result.scalar_one_or_none()

    if not service:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Service {service_id} not found in project '{name}'"
        )

    return InfrastructureResponse.model_validate(service)

@router.put("/{name}/infrastructure/{service_id}", response_model=InfrastructureResponse)
async def update_infrastructure(name: str, service_id: int, infra_update: InfrastructureUpdate, db: AsyncSession = Depends(get_db)):
    """Update an infrastructure service."""
    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    # Get infrastructure service
    result = await db.execute(
        select(Infrastructure).where(and_(
            Infrastructure.id == service_id,
            Infrastructure.project_id == project.id
        ))
    )
    service = result.scalar_one_or_none()

    if not service:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Service {service_id} not found in project '{name}'"
        )

    # Update fields
    if infra_update.service_type is not None:
        service.service_type = infra_update.service_type
    if infra_update.service_name is not None:
        service.service_name = infra_update.service_name
    if infra_update.host is not None:
        service.host = infra_update.host
    if infra_update.port is not None:
        service.port = infra_update.port
    if infra_update.credentials_ref is not None:
        service.credentials_ref = infra_update.credentials_ref
    if infra_update.health_check_url is not None:
        service.health_check_url = infra_update.health_check_url

    await db.commit()
    await db.refresh(service)

    return InfrastructureResponse.model_validate(service)

@router.delete("/{name}/infrastructure/{service_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_infrastructure(name: str, service_id: int, db: AsyncSession = Depends(get_db)):
    """Delete an infrastructure service."""
    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    # Get infrastructure service
    result = await db.execute(
        select(Infrastructure).where(and_(
            Infrastructure.id == service_id,
            Infrastructure.project_id == project.id
        ))
    )
    service = result.scalar_one_or_none()

    if not service:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Service {service_id} not found in project '{name}'"
        )

    await db.delete(service)
    await db.commit()

@router.post("/{name}/infrastructure/{service_id}/health-check", response_model=HealthCheckResponse)
async def check_infrastructure_health(name: str, service_id: int, db: AsyncSession = Depends(get_db)):
    """Perform a health check on an infrastructure service."""
    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    # Get infrastructure service
    result = await db.execute(
        select(Infrastructure).where(and_(
            Infrastructure.id == service_id,
            Infrastructure.project_id == project.id
        ))
    )
    service = result.scalar_one_or_none()

    if not service:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Service {service_id} not found in project '{name}'"
        )

    # Perform health check
    # In a real implementation, this would make HTTP requests or TCP connections
    # For now, we'll simulate a successful health check
    now = datetime.utcnow()
    is_healthy = True
    message = "Service is responding"

    # If there's a health check URL, we would check it here
    if service.health_check_url:
        # TODO: Actually perform HTTP health check
        message = f"Health check URL: {service.health_check_url}"

    # Update service status
    service.is_healthy = is_healthy
    service.last_health_check = now
    await db.commit()
    await db.refresh(service)

    return HealthCheckResponse(
        service_id=service.id,
        service_name=service.service_name,
        is_healthy=is_healthy,
        checked_at=now,
        message=message
    )

@router.post("/{name}/infrastructure/{service_id}/test", response_model=HealthCheckResponse)
async def test_infrastructure_connectivity(name: str, service_id: int, db: AsyncSession = Depends(get_db)):
    """Test connectivity to an infrastructure service (alias for health-check)."""
    return await check_infrastructure_health(name, service_id, db)

@router.post("/{name}/infrastructure/discover", response_model=DiscoveryResponse)
async def discover_infrastructure(name: str, db: AsyncSession = Depends(get_db)):
    """Discover infrastructure services for a project based on port scanning."""
    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    # Common infrastructure services and their default ports
    common_services = [
        ("PostgreSQL", "database", 5432),
        ("MySQL", "database", 3306),
        ("Redis", "cache", 6379),
        ("MongoDB", "database", 27017),
        ("RabbitMQ", "queue", 5672),
        ("Elasticsearch", "search", 9200),
        ("Memcached", "cache", 11211),
    ]

    discovered = []

    # Check each common port for listening services
    for service_name, service_type, port in common_services:
        try:
            sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            sock.settimeout(0.5)  # Quick timeout
            result = sock.connect_ex(('localhost', port))
            sock.close()

            if result == 0:  # Port is open
                # Check if we already have this service registered
                existing = await db.execute(
                    select(Infrastructure).where(and_(
                        Infrastructure.project_id == project.id,
                        Infrastructure.port == port,
                        Infrastructure.host == 'localhost'
                    ))
                )
                if existing.scalar_one_or_none() is None:
                    discovered.append(DiscoveredService(
                        service_type=service_type,
                        service_name=service_name,
                        host='localhost',
                        port=port,
                        is_healthy=True
                    ))
        except Exception:
            # Skip if we can't check this port
            pass

    return DiscoveryResponse(
        discovered_services=discovered,
        count=len(discovered)
    )

@router.post("/{name}/infrastructure/discover/add", response_model=InfrastructureResponse, status_code=status.HTTP_201_CREATED)
async def add_discovered_service(name: str, discovered: DiscoveredService, db: AsyncSession = Depends(get_db)):
    """Add a discovered service to the project infrastructure."""
    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    # Check if service already exists
    result = await db.execute(
        select(Infrastructure).where(and_(
            Infrastructure.project_id == project.id,
            Infrastructure.port == discovered.port,
            Infrastructure.host == discovered.host
        ))
    )
    existing = result.scalar_one_or_none()
    if existing:
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT,
            detail=f"Service on {discovered.host}:{discovered.port} already exists"
        )

    # Create infrastructure service
    db_infra = Infrastructure(
        project_id=project.id,
        service_type=discovered.service_type,
        service_name=discovered.service_name,
        host=discovered.host,
        port=discovered.port,
        is_healthy=discovered.is_healthy,
    )
    db.add(db_infra)
    await db.commit()
    await db.refresh(db_infra)

    return InfrastructureResponse.model_validate(db_infra)


The backend has infrastructure API endpoints at:
- `GET /api/projects/{name}/infrastructure` - lists all services
- `POST /api/projects/{name}/infrastructure` - creates a service
- `GET /api/projects/{name}/infrastructure/{id}` - gets a specific service
- `PUT /api/projects/{name}/infrastructure/{id}` - updates a service
- `DELETE /api/projects/{name}/infrastructure/{id}` - deletes a service
- `POST /api/projects/{name}/infrastructure/{id}/health-check` - health check
- `POST /api/projects/{name}/infrastructure/discover` - discover services

Each service has: `id`, `project_id`, `service_type`, `service_name`, `host`, `port`, `credentials_ref`, `health_check_url`, `last_health_check`, `is_healthy`

Now let me create the InfrastructureDashboard component:


Write

import { useState, useEffect } from 'react'

interface Infrastructure {
  id: number
  project_id: number
  service_type: string
  service_name: string
  host: string
  port: number
  credentials_ref: string | null
  health_check_url: string | null
  last_health_check: string | null
  is_healthy: boolean
}

interface InfrastructureDashboardProps {
  projectName: string
  onBack: () => void
}

export function InfrastructureDashboard({ projectName, onBack }: InfrastructureDashboardProps) {
  const [services, setServices] = useState<Infrastructure[]>([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<string | null>(null)
  const [showAddForm, setShowAddForm] = useState(false)
  const [newService, setNewService] = useState({
    service_type: '',
    service_name: '',
    host: '',
    port: '',
    health_check_url: '',
  })
  const [saving, setSaving] = useState(false)
  const [checkingHealth, setCheckingHealth] = useState<number | null>(null)

  useEffect(() => {
    fetchServices()
  }, [projectName])

  const fetchServices = async () => {
    try {
      setLoading(true)
      const res = await fetch(`/api/projects/${projectName}/infrastructure`)
      if (!res.ok) throw new Error('Failed to fetch infrastructure services')
      const data = await res.json()
      setServices(data.services)
      setError(null)
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Unknown error')
    } finally {
      setLoading(false)
    }
  }

  const handleAddService = async () => {
    if (!newService.service_type || !newService.service_name || !newService.host || !newService.port) {
      setError('All required fields must be filled')
      return
    }

    try {
      setSaving(true)
      setError(null)

      const res = await fetch(`/api/projects/${projectName}/infrastructure`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          ...newService,
          port: parseInt(newService.port),
          health_check_url: newService.health_check_url || null,
        }),
      })

      if (!res.ok) {
        const data = await res.json()
        throw new Error(data.detail || 'Failed to create service')
      }

      setNewService({ service_type: '', service_name: '', host: '', port: '', health_check_url: '' })
      setShowAddForm(false)
      fetchServices()
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Unknown error')
    } finally {
      setSaving(false)
    }
  }

  const handleHealthCheck = async (serviceId: number) => {
    try {
      setCheckingHealth(serviceId)
      setError(null)

      const res = await fetch(`/api/projects/${projectName}/infrastructure/${serviceId}/health-check`, {
        method: 'POST',
      })

      if (!res.ok) {
        const data = await res.json()
        throw new Error(data.detail || 'Health check failed')
      }

      fetchServices()
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Unknown error')
    } finally {
      setCheckingHealth(null)
    }
  }

  const handleDeleteService = async (serviceId: number) => {
    try {
      setError(null)
      const res = await fetch(`/api/projects/${projectName}/infrastructure/${serviceId}`, {
        method: 'DELETE',
      })

      if (!res.ok) {
        const data = await res.json()
        throw new Error(data.detail || 'Failed to delete service')
      }

      fetchServices()
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Unknown error')
    }
  }

  const getServiceTypeIcon = (type: string) => {
    switch (type.toLowerCase()) {
      case 'database':
        return (
          <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
          </svg>
        )
      case 'cache':
        return (
          <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
          </svg>
        )
      case 'queue':
        return (
          <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
          </svg>
        )
      case 'api':
        return (
          <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
          </svg>
        )
      default:
        return (
          <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
          </svg>
        )
    }
  }

  if (loading) {
    return (

          Loading infrastructure...

    )
  }

  return (

      {/* Header */}

          <button
            onClick={onBack}
            className="flex items-center gap-2 text-text-secondary hover:text-text-primary transition-colors"
          >
            <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
            </svg>
            Back
          </button>

Infrastructure Dashboard

            {services.length} services

        <button
          onClick={() => setShowAddForm(!showAddForm)}
          className="flex items-center gap-2 px-3 py-1.5 bg-accent text-white text-sm rounded hover:bg-accent/80 transition-colors"
        >
          <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
          </svg>
          Add Service
        </button>

      {/* Error */}
      {error && (

          <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
          </svg>
          {error}

      )}

      {/* Add Service Form */}
      {showAddForm && (

Add New Service

              Service Type *
              <select
                value={newService.service_type}
                onChange={(e) => setNewService({ ...newService, service_type: e.target.value })}
                className="w-full px-3 py-2 bg-surface border border-border rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent"
              >
                <option value="">Select type...</option>
                <option value="database">Database</option>
                <option value="cache">Cache</option>
                <option value="queue">Message Queue</option>
                <option value="api">API Service</option>
                <option value="search">Search</option>
                <option value="storage">Storage</option>
                <option value="other">Other</option>
              </select>

              Service Name *
              <input
                type="text"
                value={newService.service_name}
                onChange={(e) => setNewService({ ...newService, service_name: e.target.value })}
                placeholder="e.g., PostgreSQL, Redis"
                className="w-full px-3 py-2 bg-surface border border-border rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent"
              />

              Host *
              <input
                type="text"
                value={newService.host}
                onChange={(e) => setNewService({ ...newService, host: e.target.value })}
                placeholder="e.g., localhost, db.example.com"
                className="w-full px-3 py-2 bg-surface border border-border rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent"
              />

              Port *
              <input
                type="number"
                value={newService.port}
                onChange={(e) => setNewService({ ...newService, port: e.target.value })}
                placeholder="e.g., 5432, 6379"
                className="w-full px-3 py-2 bg-surface border border-border rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent"
              />

              Health Check URL (optional)
              <input
                type="text"
                value={newService.health_check_url}
                onChange={(e) => setNewService({ ...newService, health_check_url: e.target.value })}
                placeholder="e.g., http://localhost:5432/health"
                className="w-full px-3 py-2 bg-surface border border-border rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent"
              />

            <button
              onClick={() => setShowAddForm(false)}
              className="px-3 py-1.5 text-sm border border-border rounded hover:bg-surface-elevated transition-colors"
            >
              Cancel
            </button>
            <button
              onClick={handleAddService}
              disabled={saving}
              className="px-3 py-1.5 text-sm bg-accent text-white rounded hover:bg-accent/80 transition-colors disabled:opacity-50"
            >
              {saving ? 'Adding...' : 'Add Service'}
            </button>

      )}

      {/* Services Grid */}

        {services.length === 0 ? (

            <svg className="w-12 h-12 mx-auto text-text-secondary/50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
            </svg>

No infrastructure services configured

Add services to monitor and manage your project infrastructure

        ) : (

            {services.map((service) => (

                    <div className={`p-2 rounded-lg ${service.is_healthy ? 'bg-success/20 text-success' : 'bg-error/20 text-error'}`}>
                      {getServiceTypeIcon(service.service_type)}

{service.service_name}

{service.service_type}

                    <div className={`w-2 h-2 rounded-full ${service.is_healthy ? 'bg-success' : 'bg-error'}`} />
                    {service.is_healthy ? 'Healthy' : 'Unhealthy'}

                    Host:
                    {service.host}

                    Port:
                    {service.port}

                  {service.last_health_check && (

                      Last Check:
                      {new Date(service.last_health_check).toLocaleString()}

                  )}

                  <button
                    onClick={() => handleHealthCheck(service.id)}
                    disabled={checkingHealth === service.id}
                    className="flex-1 px-2 py-1.5 text-xs border border-border rounded hover:bg-surface transition-colors disabled:opacity-50"
                  >
                    {checkingHealth === service.id ? 'Checking...' : 'Health Check'}
                  </button>
                  <button
                    onClick={() => handleDeleteService(service.id)}
                    className="p-1.5 text-text-secondary hover:text-error hover:bg-error/10 rounded transition-colors"
                    title="Delete service"
                  >
                    <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
                    </svg>
                  </button>

            ))}

        )}

  )
}

export default InfrastructureDashboard


File created successfully at: C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocode-fork\frontend\src\components\InfrastructureDashboard.tsx

import { useState, useEffect } from 'react'

interface Infrastructure {
  id: number
  project_id: number
  service_type: string
  service_name: string
  host: string
  port: number
  credentials_ref: string | null
  health_check_url: string | null
  last_health_check: string | null
  is_healthy: boolean
}

interface InfrastructureDashboardProps {
  projectName: string
  onBack: () => void
}

export function InfrastructureDashboard({ projectName, onBack }: InfrastructureDashboardProps) {
  const [services, setServices] = useState<Infrastructure[]>([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<string | null>(null)
  const [showAddForm, setShowAddForm] = useState(false)
  const [newService, setNewService] = useState({
    service_type: '',
    service_name: '',
    host: '',
    port: '',
    health_check_url: '',
  })
  const [saving, setSaving] = useState(false)
  const [checkingHealth, setCheckingHealth] = useState<number | null>(null)

  useEffect(() => {
    fetchServices()
  }, [projectName])

  const fetchServices = async () => {
    try {
      setLoading(true)
      const res = await fetch(`/api/projects/${projectName}/infrastructure`)
      if (!res.ok) throw new Error('Failed to fetch infrastructure services')
      const data = await res.json()
      setServices(data.services)
      setError(null)
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Unknown error')
    } finally {
      setLoading(false)
    }
  }

  const handleAddService = async () => {
    if (!newService.service_type || !newService.service_name || !newService.host || !newService.port) {
      setError('All required fields must be filled')
      return
    }

    try {
      setSaving(true)
      setError(null)

      const res = await fetch(`/api/projects/${projectName}/infrastructure`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          ...newService,
          port: parseInt(newService.port),
          health_check_url: newService.health_check_url || null,
        }),
      })

      if (!res.ok) {
        const data = await res.json()
        throw new Error(data.detail || 'Failed to create service')
      }

      setNewService({ service_type: '', service_name: '', host: '', port: '', health_check_url: '' })
      setShowAddForm(false)
      fetchServices()
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Unknown error')
    } finally {
      setSaving(false)
    }
  }

  const handleHealthCheck = async (serviceId: number) => {
    try {
      setCheckingHealth(serviceId)
      setError(null)

      const res = await fetch(`/api/projects/${projectName}/infrastructure/${serviceId}/health-check`, {
        method: 'POST',
      })

      if (!res.ok) {
        const data = await res.json()
        throw new Error(data.detail || 'Health check failed')
      }

      fetchServices()
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Unknown error')
    } finally {
      setCheckingHealth(null)
    }
  }

  const handleDeleteService = async (serviceId: number) => {
    try {
      setError(null)
      const res = await fetch(`/api/projects/${projectName}/infrastructure/${serviceId}`, {
        method: 'DELETE',
      })

      if (!res.ok) {
        const data = await res.json()
        throw new Error(data.detail || 'Failed to delete service')
      }

      fetchServices()
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Unknown error')
    }
  }

  const getServiceTypeIcon = (type: string) => {
    switch (type.toLowerCase()) {
      case 'database':
        return (
          <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
          </svg>
        )
      case 'cache':
        return (
          <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
          </svg>
        )
      case 'queue':
        return (
          <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
          </svg>
        )
      case 'api':
        return (
          <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
          </svg>
        )
      default:
        return (
          <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
          </svg>
        )
    }
  }

  if (loading) {
    return (

          Loading infrastructure...

    )
  }

  return (

      {/* Header */}

          <button
            onClick={onBack}
            className="flex items-center gap-2 text-text-secondary hover:text-text-primary transition-colors"
          >
            <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
            </svg>
            Back
          </button>

Infrastructure Dashboard

            {services.length} services

        <button
          onClick={() => setShowAddForm(!showAddForm)}
          className="flex items-center gap-2 px-3 py-1.5 bg-accent text-white text-sm rounded hover:bg-accent/80 transition-colors"
        >
          <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
          </svg>
          Add Service
        </button>

      {/* Error */}
      {error && (

          <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
          </svg>
          {error}

      )}

      {/* Add Service Form */}
      {showAddForm && (

Add New Service

              Service Type *
              <select
                value={newService.service_type}
                onChange={(e) => setNewService({ ...newService, service_type: e.target.value })}
                className="w-full px-3 py-2 bg-surface border border-border rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent"
              >
                <option value="">Select type...</option>
                <option value="database">Database</option>
                <option value="cache">Cache</option>
                <option value="queue">Message Queue</option>
                <option value="api">API Service</option>
                <option value="search">Search</option>
                <option value="storage">Storage</option>
                <option value="other">Other</option>
              </select>

              Service Name *
              <input
                type="text"
                value={newService.service_name}
                onChange={(e) => setNewService({ ...newService, service_name: e.target.value })}
                placeholder="e.g., PostgreSQL, Redis"
                className="w-full px-3 py-2 bg-surface border border-border rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent"
              />

              Host *
              <input
                type="text"
                value={newService.host}
                onChange={(e) => setNewService({ ...newService, host: e.target.value })}
                placeholder="e.g., localhost, db.example.com"
                className="w-full px-3 py-2 bg-surface border border-border rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent"
              />

              Port *
              <input
                type="number"
                value={newService.port}
                onChange={(e) => setNewService({ ...newService, port: e.target.value })}
                placeholder="e.g., 5432, 6379"
                className="w-full px-3 py-2 bg-surface border border-border rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent"
              />

              Health Check URL (optional)
              <input
                type="text"
                value={newService.health_check_url}
                onChange={(e) => setNewService({ ...newService, health_check_url: e.target.value })}
                placeholder="e.g., http://localhost:5432/health"
                className="w-full px-3 py-2 bg-surface border border-border rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent"
              />

            <button
              onClick={() => setShowAddForm(false)}
              className="px-3 py-1.5 text-sm border border-border rounded hover:bg-surface-elevated transition-colors"
            >
              Cancel
            </button>
            <button
              onClick={handleAddService}
              disabled={saving}
              className="px-3 py-1.5 text-sm bg-accent text-white rounded hover:bg-accent/80 transition-colors disabled:opacity-50"
            >
              {saving ? 'Adding...' : 'Add Service'}
            </button>

      )}

      {/* Services Grid */}

        {services.length === 0 ? (

            <svg className="w-12 h-12 mx-auto text-text-secondary/50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
            </svg>

No infrastructure services configured

Add services to monitor and manage your project infrastructure

        ) : (

            {services.map((service) => (

                    <div className={`p-2 rounded-lg ${service.is_healthy ? 'bg-success/20 text-success' : 'bg-error/20 text-error'}`}>
                      {getServiceTypeIcon(service.service_type)}

{service.service_name}

{service.service_type}

                    <div className={`w-2 h-2 rounded-full ${service.is_healthy ? 'bg-success' : 'bg-error'}`} />
                    {service.is_healthy ? 'Healthy' : 'Unhealthy'}

                    Host:
                    {service.host}

                    Port:
                    {service.port}

                  {service.last_health_check && (

                      Last Check:
                      {new Date(service.last_health_check).toLocaleString()}

                  )}

                  <button
                    onClick={() => handleHealthCheck(service.id)}
                    disabled={checkingHealth === service.id}
                    className="flex-1 px-2 py-1.5 text-xs border border-border rounded hover:bg-surface transition-colors disabled:opacity-50"
                  >
                    {checkingHealth === service.id ? 'Checking...' : 'Health Check'}
                  </button>
                  <button
                    onClick={() => handleDeleteService(service.id)}
                    className="p-1.5 text-text-secondary hover:text-error hover:bg-error/10 rounded transition-colors"
                    title="Delete service"
                  >
                    <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
                    </svg>
                  </button>

            ))}

        )}

  )
}

export default InfrastructureDashboard


Now I need to add the Infrastructure tab to App.tsx:


Edit

import { useState, useEffect } from 'react'
import ProjectList from './components/ProjectList'
import KanbanBoard from './components/KanbanBoard'
import SpecEditor from './components/SpecEditor'
import PermissionEditor from './components/PermissionEditor'

type ProjectView = 'kanban' | 'spec' | 'permissions'

import { useState, useEffect } from 'react'
import ProjectList from './components/ProjectList'
import KanbanBoard from './components/KanbanBoard'
import SpecEditor from './components/SpecEditor'
import PermissionEditor from './components/PermissionEditor'
import InfrastructureDashboard from './components/InfrastructureDashboard'

type ProjectView = 'kanban' | 'spec' | 'permissions' | 'infrastructure'