Files
OpenNoodl/dev-docs/tasks/phase-5-multi-target-deployment/01-byob-backend/TASK-007-integrated-backend/TASK-007D-launcher-integration.md

1092 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# TASK-007D: Launcher Integration
## Overview
Add backend management UI to the Nodegex launcher, enabling users to create, manage, and monitor local backends independently of projects.
**Parent Task:** TASK-007 (Integrated Local Backend)
**Phase:** D (Launcher Integration)
**Effort:** 8-10 hours
**Priority:** MEDIUM
**Depends On:** TASK-007B (Backend Server)
---
## Objectives
1. Add backend management section to launcher
2. Create backend list with status indicators
3. Implement create/delete backend dialogs
4. Add start/stop controls for backends
5. Implement project-backend association UI
6. Create simple data administration panel
---
## Design
### Launcher Layout Update
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ NODEGEX [_] [□] [×] │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────┐ ┌─────────────────────────────────┐ │
│ │ PROJECTS │ │ BACKENDS │ │
│ ├─────────────────────────────────┤ ├─────────────────────────────────┤ │
│ │ │ │ │ │
│ │ ┌───────────────────────────┐ │ │ ┌───────────────────────────┐ │ │
│ │ │ 📁 My Todo App │ │ │ │ ⚡ My Todo Backend │ │ │
│ │ │ Backend: My Todo... │ │ │ │ ● Running on :8577 │ │ │
│ │ │ Last opened: 2h ago │ │ │ │ [Stop] [Admin] │ │ │
│ │ └───────────────────────────┘ │ │ └───────────────────────────┘ │ │
│ │ │ │ │ │
│ │ ┌───────────────────────────┐ │ │ ┌───────────────────────────┐ │ │
│ │ │ 📁 E-commerce Proto │ │ │ │ ⚡ E-commerce Data │ │ │
│ │ │ Backend: E-commerce... │ │ │ │ ○ Stopped │ │ │
│ │ │ Last opened: 1d ago │ │ │ │ [Start] [Delete] │ │ │
│ │ └───────────────────────────┘ │ │ └───────────────────────────┘ │ │
│ │ │ │ │ │
│ │ [+ New Project] │ │ [+ New Backend] │ │
│ │ │ │ │ │
│ └─────────────────────────────────┘ └─────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### Backend Card States
```
Running: Stopped: Error:
┌─────────────────────────┐ ┌─────────────────────────┐ ┌─────────────────────────┐
│ ⚡ My Backend │ │ ⚡ My Backend │ │ ⚡ My Backend │
│ ● Running on :8577 │ │ ○ Stopped │ │ ⚠ Error: Port in use │
│ 2 projects connected │ │ 2 projects connected │ │ 2 projects connected │
│ ─────────────────────── │ │ ─────────────────────── │ │ ─────────────────────── │
│ [Stop] [Admin] [Export] │ │ [Start] [Delete] │ │ [Retry] [Config] │
└─────────────────────────┘ └─────────────────────────┘ └─────────────────────────┘
```
---
## Implementation Steps
### Step 1: Backend List Component (2 hours)
**File:** `packages/noodl-editor/src/editor/src/views/Launcher/BackendManager/BackendList.tsx`
```tsx
import React, { useState, useEffect, useCallback } from 'react';
import { BackendCard } from './BackendCard';
import { CreateBackendDialog } from './CreateBackendDialog';
import { IconPlus } from '@noodl-core-ui/components/common/Icon';
import { PrimaryButton } from '@noodl-core-ui/components/inputs/PrimaryButton';
import styles from './BackendList.module.scss';
interface Backend {
id: string;
name: string;
createdAt: string;
port: number;
projectIds: string[];
status: 'running' | 'stopped' | 'error';
error?: string;
}
export function BackendList() {
const [backends, setBackends] = useState<Backend[]>([]);
const [loading, setLoading] = useState(true);
const [showCreateDialog, setShowCreateDialog] = useState(false);
const loadBackends = useCallback(async () => {
setLoading(true);
try {
const list = await window.electronAPI.backend.list();
// Get status for each backend
const withStatus = await Promise.all(
list.map(async (backend) => {
const status = await window.electronAPI.backend.status(backend.id);
return {
...backend,
status: status.running ? 'running' as const : 'stopped' as const,
port: status.port || backend.port
};
})
);
setBackends(withStatus);
} catch (error) {
console.error('Failed to load backends:', error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadBackends();
// Refresh every 5 seconds while launcher is open
const interval = setInterval(loadBackends, 5000);
return () => clearInterval(interval);
}, [loadBackends]);
const handleStart = async (id: string) => {
try {
await window.electronAPI.backend.start(id);
await loadBackends();
} catch (error: any) {
console.error('Failed to start backend:', error);
// Update status to show error
setBackends(prev => prev.map(b =>
b.id === id ? { ...b, status: 'error' as const, error: error.message } : b
));
}
};
const handleStop = async (id: string) => {
try {
await window.electronAPI.backend.stop(id);
await loadBackends();
} catch (error) {
console.error('Failed to stop backend:', error);
}
};
const handleDelete = async (id: string) => {
const backend = backends.find(b => b.id === id);
if (!backend) return;
const confirmed = await showConfirmDialog({
title: 'Delete Backend',
message: `Are you sure you want to delete "${backend.name}"? All data will be permanently lost.`,
confirmLabel: 'Delete',
confirmVariant: 'danger'
});
if (confirmed) {
try {
await window.electronAPI.backend.delete(id);
await loadBackends();
} catch (error) {
console.error('Failed to delete backend:', error);
}
}
};
const handleCreate = async (name: string) => {
try {
await window.electronAPI.backend.create(name);
setShowCreateDialog(false);
await loadBackends();
} catch (error) {
console.error('Failed to create backend:', error);
throw error; // Let dialog handle the error
}
};
return (
<div className={styles.container}>
<div className={styles.header}>
<h2 className={styles.title}>Local Backends</h2>
<PrimaryButton
label="New Backend"
icon={IconPlus}
onClick={() => setShowCreateDialog(true)}
/>
</div>
{loading && backends.length === 0 ? (
<div className={styles.loading}>
<span className={styles.spinner} />
Loading backends...
</div>
) : backends.length === 0 ? (
<div className={styles.empty}>
<div className={styles.emptyIcon}></div>
<h3>No Local Backends</h3>
<p>Create a backend to start building full-stack apps with zero configuration.</p>
<PrimaryButton
label="Create Your First Backend"
onClick={() => setShowCreateDialog(true)}
/>
</div>
) : (
<div className={styles.list}>
{backends.map(backend => (
<BackendCard
key={backend.id}
backend={backend}
onStart={() => handleStart(backend.id)}
onStop={() => handleStop(backend.id)}
onDelete={() => handleDelete(backend.id)}
onRefresh={loadBackends}
/>
))}
</div>
)}
{showCreateDialog && (
<CreateBackendDialog
onClose={() => setShowCreateDialog(false)}
onCreate={handleCreate}
/>
)}
</div>
);
}
// Helper for confirm dialog - would use existing dialog system
async function showConfirmDialog(options: {
title: string;
message: string;
confirmLabel: string;
confirmVariant: 'danger' | 'primary';
}): Promise<boolean> {
// Implementation would use existing dialog infrastructure
return window.confirm(options.message);
}
```
**File:** `packages/noodl-editor/src/editor/src/views/Launcher/BackendManager/BackendList.module.scss`
```scss
.container {
display: flex;
flex-direction: column;
height: 100%;
padding: 16px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.title {
font-size: 18px;
font-weight: 600;
color: var(--theme-color-text-default);
margin: 0;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 48px;
color: var(--theme-color-text-secondary);
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid var(--theme-color-border);
border-top-color: var(--theme-color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
text-align: center;
color: var(--theme-color-text-secondary);
h3 {
margin: 16px 0 8px;
color: var(--theme-color-text-default);
}
p {
margin: 0 0 24px;
max-width: 300px;
}
}
.emptyIcon {
font-size: 48px;
opacity: 0.5;
}
.list {
display: flex;
flex-direction: column;
gap: 12px;
overflow-y: auto;
flex: 1;
}
```
---
### Step 2: Backend Card Component (2 hours)
**File:** `packages/noodl-editor/src/editor/src/views/Launcher/BackendManager/BackendCard.tsx`
```tsx
import React, { useState } from 'react';
import {
IconPlay,
IconStop,
IconTrash,
IconDatabase,
IconExport,
IconSettings
} from '@noodl-core-ui/components/common/Icon';
import { IconButton } from '@noodl-core-ui/components/inputs/IconButton';
import { ExportDialog } from './ExportDialog';
import { AdminPanel } from './AdminPanel';
import styles from './BackendCard.module.scss';
interface Backend {
id: string;
name: string;
createdAt: string;
port: number;
projectIds: string[];
status: 'running' | 'stopped' | 'error';
error?: string;
}
interface Props {
backend: Backend;
onStart: () => void;
onStop: () => void;
onDelete: () => void;
onRefresh: () => void;
}
export function BackendCard({ backend, onStart, onStop, onDelete, onRefresh }: Props) {
const [showExport, setShowExport] = useState(false);
const [showAdmin, setShowAdmin] = useState(false);
const statusColor = {
running: 'var(--theme-color-success)',
stopped: 'var(--theme-color-text-secondary)',
error: 'var(--theme-color-danger)'
}[backend.status];
const statusIcon = {
running: '●',
stopped: '○',
error: '⚠'
}[backend.status];
const statusText = {
running: `Running on :${backend.port}`,
stopped: 'Stopped',
error: backend.error || 'Error'
}[backend.status];
const projectCount = backend.projectIds?.length || 0;
return (
<>
<div className={styles.card}>
<div className={styles.icon}>
<IconDatabase size={24} />
</div>
<div className={styles.info}>
<div className={styles.name}>{backend.name}</div>
<div className={styles.status} style={{ color: statusColor }}>
<span className={styles.statusIcon}>{statusIcon}</span>
{statusText}
</div>
<div className={styles.meta}>
{projectCount} project{projectCount !== 1 ? 's' : ''} connected
{' • '}
Created {formatRelativeTime(backend.createdAt)}
</div>
</div>
<div className={styles.actions}>
{backend.status === 'running' ? (
<>
<IconButton
icon={IconStop}
title="Stop Backend"
onClick={onStop}
/>
<IconButton
icon={IconDatabase}
title="Open Admin Panel"
onClick={() => setShowAdmin(true)}
/>
<IconButton
icon={IconExport}
title="Export Schema"
onClick={() => setShowExport(true)}
/>
</>
) : backend.status === 'stopped' ? (
<>
<IconButton
icon={IconPlay}
title="Start Backend"
onClick={onStart}
/>
<IconButton
icon={IconTrash}
title="Delete Backend"
variant="danger"
onClick={onDelete}
/>
</>
) : (
<>
<IconButton
icon={IconPlay}
title="Retry Start"
onClick={onStart}
/>
<IconButton
icon={IconSettings}
title="Configure"
onClick={() => {/* TODO: Config dialog */}}
/>
</>
)}
</div>
</div>
{showExport && (
<ExportDialog
backendId={backend.id}
backendName={backend.name}
onClose={() => setShowExport(false)}
/>
)}
{showAdmin && (
<AdminPanel
backendId={backend.id}
backendName={backend.name}
port={backend.port}
onClose={() => setShowAdmin(false)}
/>
)}
</>
);
}
function formatRelativeTime(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'today';
if (diffDays === 1) return 'yesterday';
if (diffDays < 7) return `${diffDays} days ago`;
if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`;
return date.toLocaleDateString();
}
```
**File:** `packages/noodl-editor/src/editor/src/views/Launcher/BackendManager/BackendCard.module.scss`
```scss
.card {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
background: var(--theme-color-bg-2);
border: 1px solid var(--theme-color-border);
border-radius: 8px;
transition: border-color 0.2s;
&:hover {
border-color: var(--theme-color-border-hover);
}
}
.icon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
background: var(--theme-color-bg-3);
border-radius: 8px;
color: var(--theme-color-primary);
}
.info {
flex: 1;
min-width: 0;
}
.name {
font-size: 16px;
font-weight: 600;
color: var(--theme-color-text-default);
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.status {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
margin-bottom: 2px;
}
.statusIcon {
font-size: 10px;
}
.meta {
font-size: 12px;
color: var(--theme-color-text-secondary);
}
.actions {
display: flex;
gap: 8px;
}
```
---
### Step 3: Create Backend Dialog (1 hour)
**File:** `packages/noodl-editor/src/editor/src/views/Launcher/BackendManager/CreateBackendDialog.tsx`
```tsx
import React, { useState } from 'react';
import { Dialog } from '@noodl-core-ui/components/layout/Dialog';
import { TextInput } from '@noodl-core-ui/components/inputs/TextInput';
import { PrimaryButton } from '@noodl-core-ui/components/inputs/PrimaryButton';
import { SecondaryButton } from '@noodl-core-ui/components/inputs/SecondaryButton';
import styles from './CreateBackendDialog.module.scss';
interface Props {
onClose: () => void;
onCreate: (name: string) => Promise<void>;
}
export function CreateBackendDialog({ onClose, onCreate }: Props) {
const [name, setName] = useState('');
const [creating, setCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleCreate = async () => {
if (!name.trim()) {
setError('Please enter a name');
return;
}
setCreating(true);
setError(null);
try {
await onCreate(name.trim());
} catch (e: any) {
setError(e.message || 'Failed to create backend');
setCreating(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !creating) {
handleCreate();
}
};
return (
<Dialog
title="Create New Backend"
onClose={onClose}
width={400}
>
<div className={styles.content}>
<p className={styles.description}>
Create a new local backend with SQLite database.
You can connect this backend to any of your projects.
</p>
<div className={styles.field}>
<label className={styles.label}>Backend Name</label>
<TextInput
value={name}
onChange={setName}
onKeyDown={handleKeyDown}
placeholder="e.g., My App Backend"
autoFocus
disabled={creating}
/>
{error && <div className={styles.error}>{error}</div>}
</div>
<div className={styles.actions}>
<SecondaryButton
label="Cancel"
onClick={onClose}
disabled={creating}
/>
<PrimaryButton
label={creating ? 'Creating...' : 'Create Backend'}
onClick={handleCreate}
disabled={creating || !name.trim()}
/>
</div>
</div>
</Dialog>
);
}
```
---
### Step 4: Backend Selector for Projects (2 hours)
**File:** `packages/noodl-editor/src/editor/src/views/Launcher/ProjectCard/BackendSelector.tsx`
```tsx
import React, { useState, useEffect, useRef } from 'react';
import { IconDatabase, IconChevronDown, IconPlus } from '@noodl-core-ui/components/common/Icon';
import styles from './BackendSelector.module.scss';
interface Backend {
id: string;
name: string;
status: 'running' | 'stopped';
}
interface Props {
currentBackendId?: string;
onSelect: (backendId: string | null) => void;
onCreateNew: () => void;
}
export function BackendSelector({ currentBackendId, onSelect, onCreateNew }: Props) {
const [isOpen, setIsOpen] = useState(false);
const [backends, setBackends] = useState<Backend[]>([]);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
loadBackends();
}, []);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const loadBackends = async () => {
try {
const list = await window.electronAPI.backend.list();
const withStatus = await Promise.all(
list.map(async (b) => {
const status = await window.electronAPI.backend.status(b.id);
return {
id: b.id,
name: b.name,
status: status.running ? 'running' as const : 'stopped' as const
};
})
);
setBackends(withStatus);
} catch (e) {
console.error('Failed to load backends:', e);
}
};
const currentBackend = backends.find(b => b.id === currentBackendId);
const handleSelect = (backendId: string | null) => {
onSelect(backendId);
setIsOpen(false);
};
return (
<div className={styles.container} ref={dropdownRef}>
<button
className={styles.trigger}
onClick={() => setIsOpen(!isOpen)}
>
<IconDatabase size={16} />
<span className={styles.label}>
{currentBackend ? currentBackend.name : 'No Backend'}
</span>
{currentBackend && (
<span
className={styles.status}
data-status={currentBackend.status}
>
{currentBackend.status === 'running' ? '●' : '○'}
</span>
)}
<IconChevronDown size={14} />
</button>
{isOpen && (
<div className={styles.dropdown}>
<button
className={styles.option}
onClick={() => handleSelect(null)}
>
<span className={styles.optionLabel}>No Backend</span>
{!currentBackendId && <span className={styles.check}></span>}
</button>
{backends.length > 0 && <div className={styles.divider} />}
{backends.map(backend => (
<button
key={backend.id}
className={styles.option}
onClick={() => handleSelect(backend.id)}
>
<span
className={styles.statusDot}
data-status={backend.status}
>
{backend.status === 'running' ? '●' : '○'}
</span>
<span className={styles.optionLabel}>{backend.name}</span>
{backend.id === currentBackendId && (
<span className={styles.check}></span>
)}
</button>
))}
<div className={styles.divider} />
<button
className={styles.option}
onClick={() => {
setIsOpen(false);
onCreateNew();
}}
>
<IconPlus size={14} />
<span className={styles.optionLabel}>Create New Backend</span>
</button>
</div>
)}
</div>
);
}
```
---
### Step 5: Simple Admin Panel (2 hours)
**File:** `packages/noodl-editor/src/editor/src/views/Launcher/BackendManager/AdminPanel.tsx`
```tsx
import React, { useState, useEffect } from 'react';
import { Dialog } from '@noodl-core-ui/components/layout/Dialog';
import { Tabs, Tab } from '@noodl-core-ui/components/layout/Tabs';
import styles from './AdminPanel.module.scss';
interface Props {
backendId: string;
backendName: string;
port: number;
onClose: () => void;
}
interface TableInfo {
name: string;
count: number;
}
export function AdminPanel({ backendId, backendName, port, onClose }: Props) {
const [tables, setTables] = useState<TableInfo[]>([]);
const [selectedTable, setSelectedTable] = useState<string | null>(null);
const [records, setRecords] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadSchema();
}, [backendId]);
const loadSchema = async () => {
setLoading(true);
try {
const response = await fetch(`http://localhost:${port}/api/_schema`);
const schema = await response.json();
const tableInfos: TableInfo[] = [];
for (const table of schema.tables || []) {
const countRes = await fetch(
`http://localhost:${port}/api/${table.name}?count=true&limit=0`
);
const countData = await countRes.json();
tableInfos.push({
name: table.name,
count: countData.count || 0
});
}
setTables(tableInfos);
if (tableInfos.length > 0 && !selectedTable) {
setSelectedTable(tableInfos[0].name);
}
} catch (e) {
console.error('Failed to load schema:', e);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (selectedTable) {
loadRecords(selectedTable);
}
}, [selectedTable]);
const loadRecords = async (table: string) => {
try {
const response = await fetch(
`http://localhost:${port}/api/${table}?limit=100`
);
const data = await response.json();
setRecords(data.results || []);
} catch (e) {
console.error('Failed to load records:', e);
setRecords([]);
}
};
return (
<Dialog
title={`Admin: ${backendName}`}
onClose={onClose}
width={800}
height={600}
>
<div className={styles.container}>
<div className={styles.sidebar}>
<div className={styles.sidebarHeader}>Tables</div>
{loading ? (
<div className={styles.loading}>Loading...</div>
) : tables.length === 0 ? (
<div className={styles.empty}>No tables yet</div>
) : (
<div className={styles.tableList}>
{tables.map(table => (
<button
key={table.name}
className={styles.tableItem}
data-selected={table.name === selectedTable}
onClick={() => setSelectedTable(table.name)}
>
<span className={styles.tableName}>{table.name}</span>
<span className={styles.tableCount}>{table.count}</span>
</button>
))}
</div>
)}
</div>
<div className={styles.main}>
{selectedTable ? (
<>
<div className={styles.toolbar}>
<h3>{selectedTable}</h3>
<span className={styles.recordCount}>
{records.length} record{records.length !== 1 ? 's' : ''}
</span>
</div>
<div className={styles.tableContainer}>
{records.length === 0 ? (
<div className={styles.noRecords}>No records</div>
) : (
<table className={styles.dataTable}>
<thead>
<tr>
{Object.keys(records[0] || {}).map(key => (
<th key={key}>{key}</th>
))}
</tr>
</thead>
<tbody>
{records.map((record, i) => (
<tr key={record.objectId || i}>
{Object.values(record).map((value: any, j) => (
<td key={j}>
{typeof value === 'object'
? JSON.stringify(value)
: String(value ?? '')}
</td>
))}
</tr>
))}
</tbody>
</table>
)}
</div>
</>
) : (
<div className={styles.noSelection}>
Select a table to view records
</div>
)}
</div>
</div>
</Dialog>
);
}
```
---
### Step 6: Integrate into Launcher (1 hour)
**File:** `packages/noodl-editor/src/editor/src/views/Launcher/Launcher.tsx` (modifications)
```tsx
// Add import
import { BackendList } from './BackendManager/BackendList';
// In the Launcher component, add backends section alongside projects:
return (
<div className={styles.launcher}>
<div className={styles.content}>
{/* Existing projects section */}
<div className={styles.section}>
<ProjectList
projects={projects}
onOpen={handleOpenProject}
// ... other props
/>
</div>
{/* New backends section */}
<div className={styles.section}>
<BackendList />
</div>
</div>
</div>
);
```
---
## Files to Create
```
packages/noodl-editor/src/editor/src/views/Launcher/BackendManager/
├── BackendList.tsx
├── BackendList.module.scss
├── BackendCard.tsx
├── BackendCard.module.scss
├── CreateBackendDialog.tsx
├── CreateBackendDialog.module.scss
├── ExportDialog.tsx
├── ExportDialog.module.scss
├── AdminPanel.tsx
├── AdminPanel.module.scss
└── index.ts
packages/noodl-editor/src/editor/src/views/Launcher/ProjectCard/
├── BackendSelector.tsx
└── BackendSelector.module.scss
```
## Files to Modify
```
packages/noodl-editor/src/editor/src/views/Launcher/Launcher.tsx
- Add BackendList section
packages/noodl-editor/src/editor/src/views/Launcher/Launcher.module.scss
- Add styles for two-column layout
```
---
## Testing Checklist
### Backend List
- [ ] List loads on launcher open
- [ ] Status indicators show correctly
- [ ] Auto-refresh works
- [ ] Empty state displays correctly
### Backend Card
- [ ] Start button works
- [ ] Stop button works
- [ ] Delete button shows confirmation
- [ ] Admin panel opens
- [ ] Export dialog opens
### Create Dialog
- [ ] Validation works
- [ ] Creates backend successfully
- [ ] Error handling works
- [ ] Dialog closes on success
### Backend Selector
- [ ] Shows in project cards
- [ ] Lists available backends
- [ ] Selection persists
- [ ] Create new option works
### Admin Panel
- [ ] Tables list loads
- [ ] Record viewing works
- [ ] Handles empty tables
- [ ] Handles large datasets
---
## Success Criteria
1. Users can manage backends without opening any project
2. Clear visual indication of backend status
3. Project-backend association is intuitive
4. Basic data exploration is possible
5. All actions have loading/error states
---
## Dependencies
**Internal:**
- TASK-007B (Backend Server + IPC handlers)
**Blocks:**
- None (UI layer)
---
## Estimated Session Breakdown
| Session | Focus | Hours |
|---------|-------|-------|
| 1 | BackendList + BackendCard | 3 |
| 2 | Create dialog + Backend selector | 2 |
| 3 | Admin panel | 2 |
| 4 | Integration + polish | 2 |
| **Total** | | **9** |