This commit is contained in:
2025-09-01 13:10:35 -04:00
parent d4f4747fde
commit aa524d8ac7
30 changed files with 731 additions and 1691 deletions

View File

@ -8,6 +8,7 @@
"@mantine/dates": "^7.0.0",
"@mantine/form": "^7.0.0",
"@mantine/hooks": "^7.0.0",
"@mantine/modals": "^7.0.0",
"@mantine/notifications": "^7.0.0",
"@monaco-editor/react": "^4.7.0",
"@tabler/icons-react": "^2.40.0",
@ -16,7 +17,8 @@
"monaco-editor": "^0.52.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.8.0"
"react-router-dom": "^6.8.0",
"@skybridge/web-components": "workspace:*"
},
"devDependencies": {
"@babel/core": "^7.22.0",

View File

@ -1,31 +1,16 @@
import React, { useEffect, useState } from 'react';
import {
Table,
Button,
Stack,
Title,
Group,
ActionIcon,
import {
DataTable,
TableColumn,
Badge,
Card,
Group,
Text,
Loader,
Alert,
Tooltip,
Menu,
} from '@mantine/core';
Stack
} from '@skybridge/web-components';
import {
IconPlayerPlay,
IconSettings,
IconTrash,
IconRocket,
IconCode,
IconDots,
IconPlus,
IconRefresh,
IconExclamationCircle,
} from '@tabler/icons-react';
import { notifications } from '@mantine/notifications';
import { functionApi } from '../services/apiService';
import { FunctionDefinition } from '../types';
@ -48,12 +33,10 @@ export const FunctionList: React.FC<FunctionListProps> = ({
try {
setLoading(true);
setError(null);
const response = await functionApi.list();
// Ensure we have a valid array
const functionsArray = response.data?.functions || [];
setFunctions(functionsArray);
} catch (err) {
console.error('Failed to load functions:', err);
const data = await functionApi.listFunctions();
setFunctions(data);
} catch (error) {
console.error('Failed to load functions:', error);
setError('Failed to load functions');
} finally {
setLoading(false);
@ -65,229 +48,78 @@ export const FunctionList: React.FC<FunctionListProps> = ({
}, []);
const handleDelete = async (func: FunctionDefinition) => {
if (!confirm(`Are you sure you want to delete function "${func.name}"?`)) {
return;
}
await functionApi.deleteFunction(func.id);
loadFunctions();
};
try {
await functionApi.delete(func.id);
notifications.show({
title: 'Success',
message: `Function "${func.name}" deleted successfully`,
color: 'green',
});
loadFunctions();
} catch (err) {
console.error('Failed to delete function:', err);
notifications.show({
title: 'Error',
message: `Failed to delete function "${func.name}"`,
color: 'red',
});
const getStatusColor = (status: string) => {
switch (status) {
case 'active': return 'green';
case 'inactive': return 'gray';
case 'error': return 'red';
case 'building': return 'yellow';
default: return 'blue';
}
};
const handleDeploy = async (func: FunctionDefinition) => {
try {
await functionApi.deploy(func.id);
notifications.show({
title: 'Success',
message: `Function "${func.name}" deployed successfully`,
color: 'green',
});
} catch (err) {
console.error('Failed to deploy function:', err);
notifications.show({
title: 'Error',
message: `Failed to deploy function "${func.name}"`,
color: 'red',
});
}
};
const getRuntimeColor = (runtime: string) => {
switch (runtime) {
case 'nodejs18': return 'green';
case 'python3.9': return 'blue';
case 'go1.20': return 'cyan';
default: return 'gray';
}
};
if (loading && functions.length === 0) {
return (
<Stack gap="lg">
<Group justify="space-between">
<Title order={2}>Functions</Title>
<Group>
<Button
leftSection={<IconRefresh size={16} />}
onClick={loadFunctions}
loading={loading}
>
Refresh
</Button>
<Button
leftSection={<IconPlus size={16} />}
onClick={onCreateFunction}
>
Create Function
</Button>
</Group>
const columns: TableColumn[] = [
{
key: 'name',
label: 'Function Name',
sortable: true,
render: (value, func: FunctionDefinition) => (
<Group gap="xs">
<IconCode size={16} />
<Text fw={500}>{value}</Text>
</Group>
)
},
{
key: 'runtime',
label: 'Runtime',
render: (value) => (
<Badge variant="light" size="sm">{value}</Badge>
)
},
{
key: 'status',
label: 'Status',
render: (value) => (
<Badge color={getStatusColor(value)} size="sm">{value}</Badge>
)
},
{
key: 'created_at',
label: 'Created',
render: (value) => new Date(value).toLocaleDateString()
},
];
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
<Text>Loading functions...</Text>
</Stack>
</Stack>
);
}
const customActions = [
{
key: 'execute',
label: 'Execute',
icon: <IconPlayerPlay size={14} />,
onClick: (func: FunctionDefinition) => onExecuteFunction(func),
},
];
return (
<Stack gap="lg">
<Group justify="space-between">
<Title order={2}>Functions</Title>
<Group>
<Button
leftSection={<IconRefresh size={16} />}
onClick={loadFunctions}
loading={loading}
>
Refresh
</Button>
<Button
leftSection={<IconPlus size={16} />}
onClick={onCreateFunction}
>
Create Function
</Button>
</Group>
</Group>
{error && (
<Alert color="red" title="Error">
{error}
<Button variant="light" size="xs" mt="sm" onClick={loadFunctions}>
Retry
</Button>
</Alert>
)}
{functions.length === 0 ? (
<Card shadow="sm" radius="md" withBorder p="xl">
<Stack align="center" gap="md">
<IconCode size={48} color="gray" />
<div style={{ textAlign: 'center' }}>
<Text fw={500} mb="xs">
No functions found
</Text>
<Text size="sm" c="dimmed">
Create your first serverless function to get started
</Text>
</div>
<Button
leftSection={<IconPlus size={16} />}
onClick={onCreateFunction}
>
Create Function
</Button>
</Stack>
</Card>
) : (
<Card shadow="sm" radius="md" withBorder>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>Runtime</Table.Th>
<Table.Th>Image</Table.Th>
<Table.Th>Memory</Table.Th>
<Table.Th>Timeout</Table.Th>
<Table.Th>Owner</Table.Th>
<Table.Th>Created</Table.Th>
<Table.Th>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{functions.map((func) => (
<Table.Tr key={func.id}>
<Table.Td>
<Text fw={500}>{func.name}</Text>
<Text size="xs" c="dimmed">{func.description || ''}</Text>
</Table.Td>
<Table.Td>
<Badge color={getRuntimeColor(func.runtime)} variant="light">
{func.runtime}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{func.image || ''}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{func.memoryLimit || 'N/A'} MB</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{func.timeout || 'N/A'}</Text>
</Table.Td>
<Table.Td>
<Text size="sm">N/A</Text>
</Table.Td>
<Table.Td>
<Text size="sm">
{func.createdAt ? new Date(func.createdAt).toLocaleDateString() : 'N/A'}
</Text>
</Table.Td>
<Table.Td>
<Group gap="xs">
<Tooltip label="Execute Function">
<ActionIcon
variant="subtle"
color="green"
size="sm"
onClick={() => onExecuteFunction(func)}
>
<IconPlayerPlay size={16} />
</ActionIcon>
</Tooltip>
<Menu position="bottom-end">
<Menu.Target>
<ActionIcon variant="subtle" size="sm">
<IconDots size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconSettings size={16} />}
onClick={() => onEditFunction(func)}
>
Edit
</Menu.Item>
<Menu.Item
leftSection={<IconRocket size={16} />}
onClick={() => handleDeploy(func)}
>
Deploy
</Menu.Item>
<Menu.Item
leftSection={<IconTrash size={16} />}
color="red"
onClick={() => handleDelete(func)}
>
Delete
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Card>
)}
<Stack gap="md">
<DataTable
data={functions}
columns={columns}
loading={loading}
error={error}
title="Functions"
searchable
onAdd={onCreateFunction}
onEdit={onEditFunction}
onDelete={handleDelete}
onRefresh={loadFunctions}
customActions={customActions}
emptyMessage="No functions found"
/>
</Stack>
);
};
};

View File

@ -1,23 +1,16 @@
import React, { useState, useEffect } from 'react';
import {
Paper,
TextInput,
Select,
NumberInput,
Button,
Group,
Stack,
Text,
Divider,
JsonInput,
Box,
Title,
ActionIcon,
ScrollArea,
} from '@mantine/core';
import { IconX } from '@tabler/icons-react';
import { useForm } from '@mantine/form';
import { notifications } from '@mantine/notifications';
import {
FormSidebar,
FormField
} from '@skybridge/web-components';
import Editor from '@monaco-editor/react';
import { functionApi, runtimeApi } from '../services/apiService';
import { FunctionDefinition, CreateFunctionRequest, UpdateFunctionRequest, RuntimeType } from '../types';
@ -35,9 +28,9 @@ export const FunctionSidebar: React.FC<FunctionSidebarProps> = ({
onSuccess,
editFunction,
}) => {
const isEditing = !!editFunction;
const [runtimeOptions, setRuntimeOptions] = useState<Array<{value: string; label: string}>>([]);
const [codeContent, setCodeContent] = useState('');
// Default images for each runtime
const DEFAULT_IMAGES: Record<string, string> = {
'nodejs18': 'node:18-alpine',
@ -88,30 +81,20 @@ import (
"context"
"encoding/json"
"fmt"
"log"
"time"
)
type Event map[string]interface{}
type Response struct {
StatusCode int \`json:"statusCode"\`
Body string \`json:"body"\`
}
func Handler(ctx context.Context, event Event) (Response, error) {
eventJSON, _ := json.MarshalIndent(event, "", " ")
log.Printf("Event: %s", eventJSON)
func Handler(ctx context.Context, event Event) (map[string]interface{}, error) {
fmt.Printf("Event: %+v\\n", event)
body := map[string]interface{}{
"message": "Hello from Go!",
"timestamp": time.Now().Format(time.RFC3339),
}
bodyJSON, _ := json.Marshal(body)
return Response{
StatusCode: 200,
Body: string(bodyJSON),
return map[string]interface{}{
"statusCode": 200,
"body": map[string]interface{}{
"message": "Hello from Go!",
"timestamp": time.Now().Format(time.RFC3339),
},
}, nil
}`
};
@ -119,196 +102,110 @@ func Handler(ctx context.Context, event Event) (Response, error) {
};
useEffect(() => {
// Fetch available runtimes from backend
const fetchRuntimes = async () => {
try {
const response = await runtimeApi.getRuntimes();
setRuntimeOptions(response.data.runtimes || []);
} catch (error) {
console.error('Failed to fetch runtimes:', error);
// Fallback to default options
setRuntimeOptions([
{ value: 'nodejs18', label: 'Node.js 18.x' },
{ value: 'python3.9', label: 'Python 3.9' },
{ value: 'go1.20', label: 'Go 1.20' },
]);
}
loadRuntimeOptions();
if (editFunction) {
setCodeContent(editFunction.code || '');
}
}, [editFunction]);
const loadRuntimeOptions = async () => {
try {
const runtimes = await runtimeApi.listRuntimes();
const options = runtimes.map((runtime: RuntimeType) => ({
value: runtime.name,
label: `${runtime.name} (${runtime.version})`
}));
setRuntimeOptions(options);
} catch (error) {
console.error('Failed to load runtimes:', error);
// Fallback options
setRuntimeOptions([
{ value: 'nodejs18', label: 'Node.js 18' },
{ value: 'python3.9', label: 'Python 3.9' },
{ value: 'go1.20', label: 'Go 1.20' }
]);
}
};
const fields: FormField[] = [
{
name: 'name',
label: 'Function Name',
type: 'text',
required: true,
placeholder: 'my-function',
validation: { pattern: /^[a-z0-9-]+$/ },
},
{
name: 'description',
label: 'Description',
type: 'textarea',
required: false,
placeholder: 'Function description...',
},
{
name: 'runtime',
label: 'Runtime',
type: 'select',
required: true,
options: runtimeOptions,
defaultValue: 'nodejs18',
},
{
name: 'timeout',
label: 'Timeout (seconds)',
type: 'number',
required: true,
defaultValue: 30,
},
{
name: 'memory',
label: 'Memory (MB)',
type: 'number',
required: true,
defaultValue: 128,
},
{
name: 'environment_variables',
label: 'Environment Variables',
type: 'json',
required: false,
defaultValue: '{}',
},
];
const handleSubmit = async (values: any) => {
const submitData = {
...values,
code: codeContent,
docker_image: DEFAULT_IMAGES[values.runtime] || DEFAULT_IMAGES['nodejs18'],
};
if (opened) {
fetchRuntimes();
}
}, [opened]);
// Update form values when editFunction changes
useEffect(() => {
if (editFunction) {
form.setValues({
name: editFunction.name || '',
app_id: editFunction.app_id || 'default',
runtime: editFunction.runtime || 'nodejs18' as RuntimeType,
image: editFunction.image || DEFAULT_IMAGES['nodejs18'] || '',
handler: editFunction.handler || 'index.handler',
code: editFunction.code || '',
environment: editFunction.environment ? JSON.stringify(editFunction.environment, null, 2) : '{}',
timeout: editFunction.timeout || '30s',
memory: editFunction.memory || 128,
owner: {
type: editFunction.owner?.type || 'team' as const,
name: editFunction.owner?.name || 'FaaS Team',
owner: editFunction.owner?.owner || 'admin@example.com',
},
});
const updateRequest: UpdateFunctionRequest = {
description: submitData.description,
code: submitData.code,
timeout: submitData.timeout,
memory: submitData.memory,
environment_variables: submitData.environment_variables,
docker_image: submitData.docker_image,
};
await functionApi.updateFunction(editFunction.id, updateRequest);
} else {
// Reset to default values when not editing
form.setValues({
name: '',
app_id: 'default',
runtime: 'nodejs18' as RuntimeType,
image: DEFAULT_IMAGES['nodejs18'] || '',
handler: 'index.handler',
code: getDefaultCode('nodejs18'),
environment: '{}',
timeout: '30s',
memory: 128,
owner: {
type: 'team' as const,
name: 'FaaS Team',
owner: 'admin@example.com',
},
});
}
}, [editFunction, opened]);
const form = useForm({
initialValues: {
name: '',
app_id: 'default',
runtime: 'nodejs18' as RuntimeType,
image: DEFAULT_IMAGES['nodejs18'] || '',
handler: 'index.handler',
code: getDefaultCode('nodejs18'),
environment: '{}',
timeout: '30s',
memory: 128,
owner: {
type: 'team' as const,
name: 'FaaS Team',
owner: 'admin@example.com',
},
},
validate: {
name: (value) => value.length < 1 ? 'Name is required' : null,
app_id: (value) => value.length < 1 ? 'App ID is required' : null,
runtime: (value) => !value ? 'Runtime is required' : null,
image: (value) => value.length < 1 ? 'Image is required' : null,
handler: (value) => value.length < 1 ? 'Handler is required' : null,
timeout: (value) => !value.match(/^\d+[smh]$/) ? 'Timeout must be in format like 30s, 5m, 1h' : null,
memory: (value) => value < 64 || value > 3008 ? 'Memory must be between 64 and 3008 MB' : null,
},
});
const handleRuntimeChange = (runtime: string | null) => {
if (runtime && DEFAULT_IMAGES[runtime]) {
form.setFieldValue('image', DEFAULT_IMAGES[runtime]);
}
form.setFieldValue('runtime', runtime as RuntimeType);
// If creating a new function and no code is set, provide default template
if (!isEditing && runtime && (!form.values.code || form.values.code.trim() === '')) {
form.setFieldValue('code', getDefaultCode(runtime));
}
};
const handleSubmit = async (values: typeof form.values) => {
console.log('handleSubmit called with values:', values);
console.log('Form validation errors:', form.errors);
console.log('Is form valid?', form.isValid());
// Check each field individually
const fieldNames = ['name', 'app_id', 'runtime', 'image', 'handler', 'timeout', 'memory'];
fieldNames.forEach(field => {
const error = form.validateField(field);
console.log(`Field ${field} error:`, error);
});
if (!form.isValid()) {
console.log('Form is not valid, validation errors:', form.errors);
return;
}
try {
// Parse environment variables JSON
let parsedEnvironment;
try {
parsedEnvironment = values.environment ? JSON.parse(values.environment) : undefined;
} catch (error) {
console.error('Error parsing environment variables:', error);
notifications.show({
title: 'Error',
message: 'Invalid JSON in environment variables',
color: 'red',
});
return;
}
if (isEditing && editFunction) {
const updateData: UpdateFunctionRequest = {
name: values.name,
runtime: values.runtime,
image: values.image,
handler: values.handler,
code: values.code || undefined,
environment: parsedEnvironment,
timeout: values.timeout,
memory: values.memory,
owner: values.owner,
};
await functionApi.update(editFunction.id, updateData);
notifications.show({
title: 'Success',
message: 'Function updated successfully',
color: 'green',
});
} else {
const createData: CreateFunctionRequest = {
name: values.name,
app_id: values.app_id,
runtime: values.runtime,
image: values.image,
handler: values.handler,
code: values.code || undefined,
environment: parsedEnvironment,
timeout: values.timeout,
memory: values.memory,
owner: values.owner,
};
await functionApi.create(createData);
notifications.show({
title: 'Success',
message: 'Function created successfully',
color: 'green',
});
}
onSuccess();
onClose();
form.reset();
} catch (error) {
console.error('Error saving function:', error);
notifications.show({
title: 'Error',
message: `Failed to ${isEditing ? 'update' : 'create'} function`,
color: 'red',
});
const createRequest: CreateFunctionRequest = submitData;
await functionApi.createFunction(createRequest);
}
};
// Create a custom sidebar that includes the Monaco editor
return (
<Paper
style={{
position: 'fixed',
top: 60, // Below header
right: opened ? 0 : '-500px',
top: 60,
right: opened ? 0 : '-600px',
bottom: 0,
width: '500px',
width: '600px',
zIndex: 1000,
borderRadius: 0,
display: 'flex',
@ -318,184 +215,41 @@ func Handler(ctx context.Context, event Event) (Response, error) {
transition: 'right 0.3s ease',
}}
>
{/* Header */}
<Group justify="space-between" p="md" style={{ borderBottom: '1px solid var(--mantine-color-gray-3)' }}>
<Title order={4}>
{isEditing ? 'Edit Function' : 'Create Function'}
</Title>
<ActionIcon
variant="subtle"
color="gray"
onClick={onClose}
>
<IconX size={18} />
</ActionIcon>
</Group>
{/* Content */}
<ScrollArea style={{ flex: 1 }}>
<Box p="md">
<form onSubmit={(e) => {
console.log('Form submit event triggered');
console.log('Form values:', form.values);
console.log('Form errors:', form.errors);
console.log('Is form valid?', form.isValid());
const result = form.onSubmit(handleSubmit)(e);
console.log('Form onSubmit result:', result);
return result;
}}>
<Stack gap="md">
<Group grow>
<TextInput
label="Function Name"
placeholder="my-function"
required
{...form.getInputProps('name')}
/>
<TextInput
label="App ID"
placeholder="my-app"
required
disabled={isEditing}
{...form.getInputProps('app_id')}
/>
</Group>
<Group grow>
<Select
label="Runtime"
placeholder="Select runtime"
required
data={runtimeOptions}
{...form.getInputProps('runtime')}
onChange={handleRuntimeChange}
/>
<NumberInput
label="Memory (MB)"
placeholder="128"
required
min={64}
max={3008}
{...form.getInputProps('memory')}
/>
</Group>
<Group grow>
<TextInput
label="Timeout"
placeholder="30s"
required
{...form.getInputProps('timeout')}
/>
</Group>
<TextInput
label="Handler"
description="The entry point for your function (e.g., 'index.handler' means handler function in index file)"
placeholder="index.handler"
required
{...form.getInputProps('handler')}
<Stack gap="md" p="md">
<FormSidebar
opened={true} // Always open since we're embedding it
onClose={() => {}} // Handled by parent
onSuccess={onSuccess}
title="Function"
editMode={!!editFunction}
editItem={editFunction}
fields={fields}
onSubmit={handleSubmit}
width={600}
style={{ position: 'relative', right: 'auto', top: 'auto', bottom: 'auto' }}
/>
<Divider />
<Box>
<Text fw={500} mb="sm">Code Editor</Text>
<Box h={300} style={{ border: '1px solid var(--mantine-color-gray-3)' }}>
<Editor
height="300px"
language={getEditorLanguage(editFunction?.runtime || 'nodejs18')}
value={codeContent}
onChange={(value) => setCodeContent(value || '')}
theme="vs-dark"
options={{
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontSize: 14,
}}
/>
<Box>
<Text size="sm" fw={500} mb={5}>
Function Code
</Text>
<Box
style={{
border: '1px solid #dee2e6',
borderRadius: '4px',
overflow: 'hidden'
}}
>
<Editor
height="300px"
language={getEditorLanguage(form.values.runtime)}
value={form.values.code}
onChange={(value) => form.setFieldValue('code', value || '')}
options={{
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontSize: 12,
lineNumbers: 'on',
roundedSelection: false,
scrollbar: {
vertical: 'visible',
horizontal: 'visible'
},
automaticLayout: true,
wordWrap: 'on',
tabSize: 2,
insertSpaces: true,
folding: true,
lineDecorationsWidth: 0,
lineNumbersMinChars: 3,
renderLineHighlight: 'line',
selectOnLineNumbers: true,
theme: 'vs-light'
}}
loading={<Text ta="center" p="xl">Loading editor...</Text>}
/>
</Box>
{form.errors.code && (
<Text size="xs" c="red" mt={5}>
{form.errors.code}
</Text>
)}
</Box>
<JsonInput
label="Environment Variables"
description="JSON object with key-value pairs that will be available in your function runtime"
placeholder={`{
"NODE_ENV": "production",
"API_URL": "https://api.example.com"
}`}
validationError="Invalid JSON - please check your syntax"
formatOnBlur
autosize
minRows={3}
{...form.getInputProps('environment')}
/>
<Paper withBorder p="md" bg="gray.0">
<Text size="sm" fw={500} mb="xs">Owner Information</Text>
<Group grow>
<Select
label="Owner Type"
data={[
{ value: 'individual', label: 'Individual' },
{ value: 'team', label: 'Team' },
]}
{...form.getInputProps('owner.type')}
/>
<TextInput
label="Owner Name"
placeholder="Team Name"
{...form.getInputProps('owner.name')}
/>
</Group>
<TextInput
label="Owner Email"
placeholder="owner@example.com"
mt="xs"
{...form.getInputProps('owner.owner')}
/>
</Paper>
<Divider />
<Group justify="flex-end">
<Button variant="light" onClick={onClose}>
Cancel
</Button>
<Button type="submit">
{isEditing ? 'Update' : 'Create'} Function
</Button>
</Group>
</Stack>
</form>
</Box>
</Box>
</Box>
</Stack>
</ScrollArea>
</Paper>
);