feat(editor): add local backends UI to BackendServicesPanel

- Add useLocalBackends hook for IPC communication with local backend server
- Create LocalBackendCard component for managing local SQLite backends
- Add 'Local Backends' section to BackendServicesPanel
- Support start/stop/delete operations for local backends
- Display status, endpoint URL, and port information

Part of TASK-007D: Launcher Integration
This commit is contained in:
Richard Osborne
2026-01-15 18:30:42 +01:00
parent 98fa779548
commit 5f61317ca4
4 changed files with 511 additions and 3 deletions

View File

@@ -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() {
<BasePanel title="Backend Services" hasActivityBlocker={hasActivity} hasContentScroll>
<DeleteDialog />
{isLoading ? (
{isLoading || isLoadingLocal ? (
<Container hasLeftSpacing hasTopSpacing>
<ActivityIndicator />
</Container>
) : (
<>
{/* Local Backends Section */}
<Section
title="Available Backends"
title="Local Backends"
variant={SectionVariant.Panel}
actions={
<IconButton
icon={IconName.Plus}
size={IconSize.Small}
onClick={async () => {
const name = prompt('Enter backend name:');
if (name) {
await createLocalBackend(name);
}
}}
testId="add-local-backend-button"
/>
}
>
{localBackends.length > 0 ? (
<VStack>
{localBackends.map((backend) => (
<LocalBackendCard
key={backend.id}
backend={backend}
onStart={async () => startLocalBackend(backend.id)}
onStop={async () => stopLocalBackend(backend.id)}
onDelete={() => {
if (confirm('Delete this local backend? All data will be lost.')) {
deleteLocalBackend(backend.id);
}
}}
/>
))}
</VStack>
) : (
<Container hasLeftSpacing hasTopSpacing hasBottomSpacing>
<Text>No local backends</Text>
<Box hasTopSpacing>
<Text textType={TextType.Shy}>
Create a local SQLite backend for zero-config database development.
</Text>
</Box>
</Container>
)}
</Section>
{/* External Backends Section */}
<Section
title="External Backends"
variant={SectionVariant.Panel}
actions={
<IconButton
@@ -147,7 +206,7 @@ export function BackendServicesPanel() {
</VStack>
) : (
<Container hasLeftSpacing hasTopSpacing hasBottomSpacing>
<Text>No backends configured</Text>
<Text>No external backends configured</Text>
<Box hasTopSpacing>
<Text textType={TextType.Shy}>
Click the + button to add a Directus, Supabase, or custom REST backend.

View File

@@ -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);
}

View File

@@ -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<void> | Promise<boolean>;
/** Called when stop is requested */
onStop: () => Promise<void> | Promise<boolean>;
/** 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 (
<div className={css.Root} data-test={`local-backend-card-${backend.id}`}>
{/* Header */}
<div className={css.Header}>
<HStack hasSpacing>
<div className={css.TypeIcon}>
<Text textType={TextType.Proud}>L</Text>
</div>
<VStack>
<Text textType={TextType.DefaultContrast}>{backend.name}</Text>
<Text textType={TextType.Shy} style={{ fontSize: '11px' }}>
Local SQLite Port {backend.port}
</Text>
</VStack>
</HStack>
<div className={css.StatusBadge} style={{ color: statusDisplay.color }}>
<Icon icon={statusDisplay.icon} size={IconSize.Tiny} UNSAFE_style={{ color: statusDisplay.color }} />
<Text textType={TextType.Shy} style={{ fontSize: '10px', marginLeft: '4px' }}>
{statusDisplay.text}
</Text>
</div>
</div>
{/* Endpoint (when running) */}
{backend.running && backend.endpoint && (
<div className={css.Endpoint} onClick={handleCopyEndpoint}>
<Text textType={TextType.Shy} style={{ fontSize: '11px' }}>
{backend.endpoint}
</Text>
<Text textType={TextType.Shy} style={{ fontSize: '10px', marginLeft: '8px' }}>
(click to copy)
</Text>
</div>
)}
{/* Info */}
<div className={css.Info}>
<Text textType={TextType.Shy} style={{ fontSize: '11px' }}>
Created {createdDate}
</Text>
</div>
{/* Actions */}
<div className={css.Actions}>
<HStack hasSpacing>
<PrimaryButton
label={isOperating ? 'Processing...' : backend.running ? 'Stop' : 'Start'}
size={PrimaryButtonSize.Small}
variant={backend.running ? PrimaryButtonVariant.Muted : PrimaryButtonVariant.Muted}
onClick={handleToggle}
isDisabled={isOperating}
/>
{onExport && backend.running && (
<PrimaryButton
label="Export"
size={PrimaryButtonSize.Small}
variant={PrimaryButtonVariant.Muted}
onClick={onExport}
/>
)}
<IconButton
icon={IconName.Trash}
size={IconSize.Small}
onClick={onDelete}
isDisabled={backend.running}
testId={`delete-local-backend-${backend.id}`}
/>
</HStack>
</div>
</div>
);
}

View File

@@ -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<void>;
/** Create a new backend */
createBackend: (name: string) => Promise<LocalBackendMetadata | null>;
/** Delete a backend */
deleteBackend: (id: string) => Promise<boolean>;
/** Start a backend */
startBackend: (id: string) => Promise<boolean>;
/** Stop a backend */
stopBackend: (id: string) => Promise<boolean>;
/** Export schema */
exportSchema: (id: string, format: 'postgres' | 'supabase' | 'json') => Promise<string | null>;
}
/**
* Invoke IPC handler with error handling
*/
async function invokeIPC<T>(channel: string, ...args: unknown[]): Promise<T | null> {
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<T> } })
.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<T> }).ipcInvoke) {
return await (platform as unknown as { ipcInvoke: (ch: string, ...a: unknown[]) => Promise<T> }).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<LocalBackendInfo[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isOperating, setIsOperating] = useState(false);
const [error, setError] = useState<string | null>(null);
// Fetch all backends and their statuses
const refresh = useCallback(async () => {
setError(null);
try {
const list = await invokeIPC<LocalBackendMetadata[]>('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<LocalBackendMetadata | null> => {
setIsOperating(true);
setError(null);
try {
const result = await invokeIPC<LocalBackendMetadata>('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<boolean> => {
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<boolean> => {
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<boolean> => {
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<string | null> => {
setIsOperating(true);
setError(null);
try {
const result = await invokeIPC<string>('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
};
}