# 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([]); 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 (

Local Backends

setShowCreateDialog(true)} />
{loading && backends.length === 0 ? (
Loading backends...
) : backends.length === 0 ? (

No Local Backends

Create a backend to start building full-stack apps with zero configuration.

setShowCreateDialog(true)} />
) : (
{backends.map(backend => ( handleStart(backend.id)} onStop={() => handleStop(backend.id)} onDelete={() => handleDelete(backend.id)} onRefresh={loadBackends} /> ))}
)} {showCreateDialog && ( setShowCreateDialog(false)} onCreate={handleCreate} /> )}
); } // Helper for confirm dialog - would use existing dialog system async function showConfirmDialog(options: { title: string; message: string; confirmLabel: string; confirmVariant: 'danger' | 'primary'; }): Promise { // 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 ( <>
{backend.name}
{statusIcon} {statusText}
{projectCount} project{projectCount !== 1 ? 's' : ''} connected {' • '} Created {formatRelativeTime(backend.createdAt)}
{backend.status === 'running' ? ( <> setShowAdmin(true)} /> setShowExport(true)} /> ) : backend.status === 'stopped' ? ( <> ) : ( <> {/* TODO: Config dialog */}} /> )}
{showExport && ( setShowExport(false)} /> )} {showAdmin && ( 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; } export function CreateBackendDialog({ onClose, onCreate }: Props) { const [name, setName] = useState(''); const [creating, setCreating] = useState(false); const [error, setError] = useState(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 (

Create a new local backend with SQLite database. You can connect this backend to any of your projects.

{error &&
{error}
}
); } ``` --- ### 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([]); const dropdownRef = useRef(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 (
{isOpen && (
{backends.length > 0 &&
} {backends.map(backend => ( ))}
)}
); } ``` --- ### 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([]); const [selectedTable, setSelectedTable] = useState(null); const [records, setRecords] = useState([]); 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 (
Tables
{loading ? (
Loading...
) : tables.length === 0 ? (
No tables yet
) : (
{tables.map(table => ( ))}
)}
{selectedTable ? ( <>

{selectedTable}

{records.length} record{records.length !== 1 ? 's' : ''}
{records.length === 0 ? (
No records
) : ( {Object.keys(records[0] || {}).map(key => ( ))} {records.map((record, i) => ( {Object.values(record).map((value: any, j) => ( ))} ))}
{key}
{typeof value === 'object' ? JSON.stringify(value) : String(value ?? '')}
)}
) : (
Select a table to view records
)}
); } ``` --- ### 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 (
{/* Existing projects section */}
{/* New backends section */}
); ``` --- ## 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** |