diff --git a/packages/noodl-editor/src/editor/src/views/panels/BackendServicesPanel/BackendServicesPanel.tsx b/packages/noodl-editor/src/editor/src/views/panels/BackendServicesPanel/BackendServicesPanel.tsx index b4ff96d..614c848 100644 --- a/packages/noodl-editor/src/editor/src/views/panels/BackendServicesPanel/BackendServicesPanel.tsx +++ b/packages/noodl-editor/src/editor/src/views/panels/BackendServicesPanel/BackendServicesPanel.tsx @@ -26,6 +26,8 @@ import { Text, TextType } from '@noodl-core-ui/components/typography/Text'; import { AddBackendDialog } from './AddBackendDialog/AddBackendDialog'; import { BackendCard } from './BackendCard/BackendCard'; +import { useLocalBackends } from './hooks/useLocalBackends'; +import { LocalBackendCard } from './LocalBackendCard/LocalBackendCard'; export function BackendServicesPanel() { const [backends, setBackends] = useState(BackendServices.instance.backends); @@ -34,6 +36,16 @@ export function BackendServicesPanel() { const [isAddDialogVisible, setIsAddDialogVisible] = useState(false); const [hasActivity, setHasActivity] = useState(false); + // Local backends hook + const { + backends: localBackends, + isLoading: isLoadingLocal, + createBackend: createLocalBackend, + deleteBackend: deleteLocalBackend, + startBackend: startLocalBackend, + stopBackend: stopLocalBackend + } = useLocalBackends(); + // Initialize backends on mount useEffect(() => { setIsLoading(true); @@ -113,14 +125,61 @@ export function BackendServicesPanel() { - {isLoading ? ( + {isLoading || isLoadingLocal ? ( ) : ( <> + {/* Local Backends Section */}
{ + const name = prompt('Enter backend name:'); + if (name) { + await createLocalBackend(name); + } + }} + testId="add-local-backend-button" + /> + } + > + {localBackends.length > 0 ? ( + + {localBackends.map((backend) => ( + startLocalBackend(backend.id)} + onStop={async () => stopLocalBackend(backend.id)} + onDelete={() => { + if (confirm('Delete this local backend? All data will be lost.')) { + deleteLocalBackend(backend.id); + } + }} + /> + ))} + + ) : ( + + No local backends + + + Create a local SQLite backend for zero-config database development. + + + + )} +
+ + {/* External Backends Section */} +
) : ( - No backends configured + No external backends configured Click the + button to add a Directus, Supabase, or custom REST backend. diff --git a/packages/noodl-editor/src/editor/src/views/panels/BackendServicesPanel/LocalBackendCard/LocalBackendCard.module.scss b/packages/noodl-editor/src/editor/src/views/panels/BackendServicesPanel/LocalBackendCard/LocalBackendCard.module.scss new file mode 100644 index 0000000..2336e94 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/panels/BackendServicesPanel/LocalBackendCard/LocalBackendCard.module.scss @@ -0,0 +1,65 @@ +.Root { + background-color: var(--theme-color-bg-3); + border: 1px solid var(--theme-color-border-default); + border-radius: 6px; + padding: 12px; + margin-bottom: 8px; + transition: border-color 0.2s ease; + + &:hover { + border-color: var(--theme-color-border-hover); + } +} + +.Header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 8px; +} + +.TypeIcon { + width: 32px; + height: 32px; + border-radius: 6px; + background-color: var(--theme-color-success); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.StatusBadge { + display: flex; + align-items: center; + padding: 2px 6px; + border-radius: 4px; + background-color: var(--theme-color-bg-2); +} + +.Endpoint { + display: flex; + align-items: center; + background-color: var(--theme-color-bg-2); + border: 1px solid var(--theme-color-border-default); + border-radius: 4px; + padding: 6px 8px; + margin-bottom: 8px; + cursor: pointer; + transition: background-color 0.15s ease; + + &:hover { + background-color: var(--theme-color-bg-1); + } +} + +.Info { + margin-bottom: 8px; + padding-top: 8px; + border-top: 1px solid var(--theme-color-border-default); +} + +.Actions { + padding-top: 8px; + border-top: 1px solid var(--theme-color-border-default); +} diff --git a/packages/noodl-editor/src/editor/src/views/panels/BackendServicesPanel/LocalBackendCard/LocalBackendCard.tsx b/packages/noodl-editor/src/editor/src/views/panels/BackendServicesPanel/LocalBackendCard/LocalBackendCard.tsx new file mode 100644 index 0000000..8c91d8e --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/panels/BackendServicesPanel/LocalBackendCard/LocalBackendCard.tsx @@ -0,0 +1,149 @@ +/** + * LocalBackendCard + * + * Card component for displaying and managing a local SQLite backend. + * Shows status, start/stop controls, and endpoint information. + * + * @module BackendServicesPanel/LocalBackendCard + * @since 1.2.0 + */ + +import React, { useCallback, useState } from 'react'; + +import { Icon, IconName, IconSize } from '@noodl-core-ui/components/common/Icon'; +import { IconButton } from '@noodl-core-ui/components/inputs/IconButton'; +import { PrimaryButton, PrimaryButtonSize, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton'; +import { HStack, VStack } from '@noodl-core-ui/components/layout/Stack'; +import { Text, TextType } from '@noodl-core-ui/components/typography/Text'; + +import { LocalBackendInfo } from '../hooks/useLocalBackends'; +import css from './LocalBackendCard.module.scss'; + +export interface LocalBackendCardProps { + /** Backend information */ + backend: LocalBackendInfo; + /** Called when start is requested */ + onStart: () => Promise | Promise; + /** Called when stop is requested */ + onStop: () => Promise | Promise; + /** Called when delete is requested */ + onDelete: () => void; + /** Called when export is requested */ + onExport?: () => void; +} + +/** + * Get status icon and color based on running status + */ +function getStatusDisplay(running: boolean): { icon: IconName; color: string; text: string } { + if (running) { + return { icon: IconName.Check, color: 'var(--theme-color-success)', text: 'Running' }; + } + return { icon: IconName.CircleOpen, color: 'var(--theme-color-fg-default-shy)', text: 'Stopped' }; +} + +export function LocalBackendCard({ backend, onStart, onStop, onDelete, onExport }: LocalBackendCardProps) { + const [isOperating, setIsOperating] = useState(false); + const statusDisplay = getStatusDisplay(backend.running); + + // Format date + const createdDate = new Date(backend.createdAt).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + + // Handle start/stop + const handleToggle = useCallback(async () => { + setIsOperating(true); + try { + if (backend.running) { + await onStop(); + } else { + await onStart(); + } + } finally { + setIsOperating(false); + } + }, [backend.running, onStart, onStop]); + + // Copy endpoint to clipboard + const handleCopyEndpoint = useCallback(() => { + if (backend.endpoint) { + navigator.clipboard.writeText(backend.endpoint); + } + }, [backend.endpoint]); + + return ( +
+ {/* Header */} +
+ +
+ L +
+ + {backend.name} + + Local SQLite • Port {backend.port} + + +
+ +
+ + + {statusDisplay.text} + +
+
+ + {/* Endpoint (when running) */} + {backend.running && backend.endpoint && ( +
+ + {backend.endpoint} + + + (click to copy) + +
+ )} + + {/* Info */} +
+ + Created {createdDate} + +
+ + {/* Actions */} +
+ + + {onExport && backend.running && ( + + )} + + +
+
+ ); +} diff --git a/packages/noodl-editor/src/editor/src/views/panels/BackendServicesPanel/hooks/useLocalBackends.ts b/packages/noodl-editor/src/editor/src/views/panels/BackendServicesPanel/hooks/useLocalBackends.ts new file mode 100644 index 0000000..8487d28 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/panels/BackendServicesPanel/hooks/useLocalBackends.ts @@ -0,0 +1,235 @@ +/** + * useLocalBackends + * + * React hook for managing local SQLite backends via IPC. + * Provides state and actions for the BYOB local backend system. + * + * @module BackendServicesPanel/hooks/useLocalBackends + * @since 1.2.0 + */ + +import { useCallback, useEffect, useState } from 'react'; +import { platform } from '@noodl/platform'; + +/** Backend metadata as stored in config.json */ +export interface LocalBackendMetadata { + id: string; + name: string; + createdAt: string; + port: number; + projectIds: string[]; +} + +/** Extended backend info with runtime status */ +export interface LocalBackendInfo extends LocalBackendMetadata { + running: boolean; + endpoint?: string; +} + +/** Hook return type */ +export interface UseLocalBackendsReturn { + /** List of all local backends */ + backends: LocalBackendInfo[]; + /** Whether initial loading is in progress */ + isLoading: boolean; + /** Whether an operation is in progress */ + isOperating: boolean; + /** Any error message */ + error: string | null; + /** Refresh the backends list */ + refresh: () => Promise; + /** Create a new backend */ + createBackend: (name: string) => Promise; + /** Delete a backend */ + deleteBackend: (id: string) => Promise; + /** Start a backend */ + startBackend: (id: string) => Promise; + /** Stop a backend */ + stopBackend: (id: string) => Promise; + /** Export schema */ + exportSchema: (id: string, format: 'postgres' | 'supabase' | 'json') => Promise; +} + +/** + * Invoke IPC handler with error handling + */ +async function invokeIPC(channel: string, ...args: unknown[]): Promise { + try { + // Use platform's invoke if available, otherwise fallback to window.electronAPI + const electronAPI = (window as unknown as { electronAPI?: { invoke: (ch: string, ...a: unknown[]) => Promise } }) + .electronAPI; + if (electronAPI?.invoke) { + return await electronAPI.invoke(channel, ...args); + } + // Try Noodl platform + if (platform && (platform as unknown as { ipcInvoke?: (ch: string, ...a: unknown[]) => Promise }).ipcInvoke) { + return await (platform as unknown as { ipcInvoke: (ch: string, ...a: unknown[]) => Promise }).ipcInvoke( + channel, + ...args + ); + } + console.warn('[useLocalBackends] No IPC transport available'); + return null; + } catch (error) { + console.error(`[useLocalBackends] IPC error (${channel}):`, error); + throw error; + } +} + +/** + * Hook for managing local backends + */ +export function useLocalBackends(): UseLocalBackendsReturn { + const [backends, setBackends] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isOperating, setIsOperating] = useState(false); + const [error, setError] = useState(null); + + // Fetch all backends and their statuses + const refresh = useCallback(async () => { + setError(null); + try { + const list = await invokeIPC('backend:list'); + if (!list) { + setBackends([]); + return; + } + + // Get status for each backend + const backendsWithStatus: LocalBackendInfo[] = await Promise.all( + list.map(async (backend) => { + const status = await invokeIPC<{ running: boolean; port?: number }>('backend:status', backend.id); + return { + ...backend, + running: status?.running ?? false, + endpoint: status?.running && status?.port ? `http://localhost:${status.port}` : undefined + }; + }) + ); + + setBackends(backendsWithStatus); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to fetch backends'; + setError(message); + console.error('[useLocalBackends] refresh error:', err); + } + }, []); + + // Initial load + useEffect(() => { + setIsLoading(true); + refresh().finally(() => setIsLoading(false)); + }, [refresh]); + + // Create a new backend + const createBackend = useCallback( + async (name: string): Promise => { + setIsOperating(true); + setError(null); + try { + const result = await invokeIPC('backend:create', name); + await refresh(); + return result; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to create backend'; + setError(message); + return null; + } finally { + setIsOperating(false); + } + }, + [refresh] + ); + + // Delete a backend + const deleteBackend = useCallback( + async (id: string): Promise => { + setIsOperating(true); + setError(null); + try { + await invokeIPC<{ deleted: boolean }>('backend:delete', id); + await refresh(); + return true; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to delete backend'; + setError(message); + return false; + } finally { + setIsOperating(false); + } + }, + [refresh] + ); + + // Start a backend + const startBackend = useCallback( + async (id: string): Promise => { + setIsOperating(true); + setError(null); + try { + await invokeIPC<{ running: boolean }>('backend:start', id); + await refresh(); + return true; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to start backend'; + setError(message); + return false; + } finally { + setIsOperating(false); + } + }, + [refresh] + ); + + // Stop a backend + const stopBackend = useCallback( + async (id: string): Promise => { + setIsOperating(true); + setError(null); + try { + await invokeIPC<{ running: boolean }>('backend:stop', id); + await refresh(); + return true; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to stop backend'; + setError(message); + return false; + } finally { + setIsOperating(false); + } + }, + [refresh] + ); + + // Export schema + const exportSchema = useCallback( + async (id: string, format: 'postgres' | 'supabase' | 'json'): Promise => { + setIsOperating(true); + setError(null); + try { + const result = await invokeIPC('backend:export-schema', id, format); + return result; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to export schema'; + setError(message); + return null; + } finally { + setIsOperating(false); + } + }, + [] + ); + + return { + backends, + isLoading, + isOperating, + error, + refresh, + createBackend, + deleteBackend, + startBackend, + stopBackend, + exportSchema + }; +}