mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-03-07 17:43:28 +01:00
Merge feature/task-007d-launcher-ui into cline-dev
TASK-007D: Local Backends UI Integration - useLocalBackends hook for IPC communication - LocalBackendCard component for backend management - Enhanced BackendServicesPanel with Local Backends section
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user