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

1
.gitignore vendored
View File

@ -1 +1,2 @@
dist
node_modules

1
demo/dist/396.js vendored

File diff suppressed because one or more lines are too long

2
demo/dist/540.js vendored

File diff suppressed because one or more lines are too long

View File

@ -1,9 +0,0 @@
/**
* @license React
* react.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

1
demo/dist/665.js vendored
View File

@ -1 +0,0 @@
"use strict";(self.webpackChunkdemo=self.webpackChunkdemo||[]).push([[665],{665:(e,t,a)=>{a.r(t),a.d(t,{default:()=>c});var r=a(914),l=a.n(r),n=a(435),i=a(101);const c=()=>{const[e,t]=(0,r.useState)(0),[a,c]=(0,r.useState)(!1);(0,r.useEffect)(()=>{const e=setInterval(()=>{t(e=>e>=100?0:e+1)},100);return()=>clearInterval(e)},[]);const o=[{label:"Active Users",value:"1,234",icon:i.IconUsers,color:"blue"},{label:"Total Revenue",value:"$45,678",icon:i.IconChartLine,color:"green"},{label:"Projects",value:"89",icon:i.IconRocket,color:"orange"}];return l().createElement(n.Container,{size:"xl",py:"xl"},l().createElement(n.Stack,{gap:"xl"},l().createElement(n.Group,{justify:"space-between",align:"center"},l().createElement("div",null,l().createElement(n.Title,{order:1},"Demo Application"),l().createElement(n.Text,{c:"dimmed",size:"lg",mt:"xs"},"A sample federated application showcasing module federation")),l().createElement(n.ActionIcon,{size:"lg",variant:"light",loading:a,onClick:()=>{c(!0),setTimeout(()=>c(!1),1500)}},l().createElement(i.IconRefresh,{size:18}))),l().createElement(n.Alert,{icon:l().createElement(i.IconInfoCircle,{size:16}),title:"Welcome!",color:"blue",variant:"light"},"This is a demo application loaded via Module Federation. It demonstrates how microfrontends can be seamlessly integrated into the shell application."),l().createElement(n.SimpleGrid,{cols:{base:1,sm:3},spacing:"md"},o.map(e=>l().createElement(n.Paper,{key:e.label,p:"md",radius:"md",withBorder:!0},l().createElement(n.Group,{justify:"space-between"},l().createElement("div",null,l().createElement(n.Text,{c:"dimmed",size:"sm",fw:500,tt:"uppercase"},e.label),l().createElement(n.Text,{fw:700,size:"xl"},e.value)),l().createElement(e.icon,{size:24,color:`var(--mantine-color-${e.color}-6)`}))))),l().createElement(n.Card,{shadow:"sm",padding:"lg",radius:"md",withBorder:!0},l().createElement(n.Card.Section,{withBorder:!0,inheritPadding:!0,py:"xs"},l().createElement(n.Group,{justify:"space-between"},l().createElement(n.Text,{fw:500},"System Performance"),l().createElement(n.Badge,{color:"green",variant:"light"},"Healthy"))),l().createElement(n.Card.Section,{inheritPadding:!0,py:"md"},l().createElement(n.Stack,{gap:"xs"},l().createElement(n.Text,{size:"sm",c:"dimmed"},"CPU Usage: ",e.toFixed(1),"%"),l().createElement(n.Progress,{value:e,size:"sm",color:"blue",animated:!0})))),l().createElement("div",null,l().createElement(n.Title,{order:2,mb:"md"},"Features"),l().createElement(n.SimpleGrid,{cols:{base:1,sm:2},spacing:"md"},[{title:"Real-time Analytics",description:"Monitor your data in real-time"},{title:"Team Collaboration",description:"Work together seamlessly"},{title:"Cloud Integration",description:"Connect with cloud services"},{title:"Custom Reports",description:"Generate detailed reports"}].map((e,t)=>l().createElement(n.Card,{key:t,shadow:"sm",padding:"lg",radius:"md",withBorder:!0},l().createElement(n.Group,{mb:"xs"},l().createElement(i.IconCheck,{size:16,color:"var(--mantine-color-green-6)"}),l().createElement(n.Text,{fw:500},e.title)),l().createElement(n.Text,{size:"sm",c:"dimmed"},e.description))))),l().createElement(n.Divider,null),l().createElement(n.Group,{justify:"center"},l().createElement(n.Button,{variant:"outline",size:"md"},"View Documentation"),l().createElement(n.Button,{size:"md"},"Get Started"))))}}}]);

2
demo/dist/81.js vendored

File diff suppressed because one or more lines are too long

View File

@ -1,9 +0,0 @@
/**
* @license React
* react-jsx-runtime.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

1
demo/dist/870.js vendored

File diff suppressed because one or more lines are too long

2
demo/dist/961.js vendored

File diff suppressed because one or more lines are too long

View File

@ -1,19 +0,0 @@
/**
* @license React
* react-dom.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* scheduler.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

View File

@ -1 +0,0 @@
<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Demo App</title><script defer="defer" src="main.js"></script><script defer="defer" src="remoteEntry.js"></script></head><body><div id="root"></div></body></html>

1
demo/dist/main.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -12,7 +12,11 @@
"react-dom": "^18.2.0",
"@mantine/core": "^7.0.0",
"@mantine/hooks": "^7.0.0",
"@tabler/icons-react": "^2.40.0"
"@mantine/notifications": "^7.0.0",
"@mantine/form": "^7.0.0",
"@mantine/modals": "^7.0.0",
"@tabler/icons-react": "^2.40.0",
"@skybridge/web-components": "workspace:*"
},
"devDependencies": {
"@babel/core": "^7.20.12",

View File

@ -22,11 +22,25 @@ import {
IconRefresh,
IconCheck,
IconInfoCircle,
IconPlus,
} from '@tabler/icons-react';
import {
DataTable,
TableColumn,
FormSidebar,
FormField
} from '@skybridge/web-components';
const DemoApp: React.FC = () => {
const [progress, setProgress] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [showTable, setShowTable] = useState(false);
const [sidebarOpened, setSidebarOpened] = useState(false);
const [demoData, setDemoData] = useState([
{ id: '1', name: 'John Doe', email: 'john@example.com', status: 'active', role: 'admin', created_at: '2024-01-15' },
{ id: '2', name: 'Jane Smith', email: 'jane@example.com', status: 'active', role: 'user', created_at: '2024-02-20' },
{ id: '3', name: 'Bob Johnson', email: 'bob@example.com', status: 'inactive', role: 'viewer', created_at: '2024-03-10' },
]);
useEffect(() => {
const timer = setInterval(() => {
@ -53,6 +67,63 @@ const DemoApp: React.FC = () => {
{ title: 'Custom Reports', description: 'Generate detailed reports' },
];
const tableColumns: TableColumn[] = [
{ key: 'name', label: 'Name', sortable: true },
{ key: 'email', label: 'Email', sortable: true },
{ key: 'role', label: 'Role', render: (value) => <Badge variant="light" size="sm">{value}</Badge> },
{ key: 'status', label: 'Status' }, // Uses default status rendering
{ key: 'created_at', label: 'Created', render: (value) => new Date(value).toLocaleDateString() },
];
const formFields: FormField[] = [
{ name: 'name', label: 'Full Name', type: 'text', required: true, placeholder: 'Enter full name' },
{ name: 'email', label: 'Email', type: 'email', required: true, placeholder: 'Enter email address', validation: { email: true } },
{
name: 'role',
label: 'Role',
type: 'select',
required: true,
options: [
{ value: 'admin', label: 'Admin' },
{ value: 'user', label: 'User' },
{ value: 'viewer', label: 'Viewer' },
],
defaultValue: 'user'
},
{
name: 'status',
label: 'Status',
type: 'select',
required: true,
options: [
{ value: 'active', label: 'Active' },
{ value: 'inactive', label: 'Inactive' },
],
defaultValue: 'active'
},
];
const handleFormSubmit = async (values: any) => {
// Simulate API call
console.log('Form submitted:', values);
const newItem = {
id: Date.now().toString(),
...values,
created_at: new Date().toISOString().split('T')[0]
};
setDemoData([...demoData, newItem]);
};
const handleEdit = (item: any) => {
console.log('Edit item:', item);
// Would normally open form with item data
setSidebarOpened(true);
};
const handleDelete = async (item: any) => {
setDemoData(demoData.filter(d => d.id !== item.id));
};
return (
<Container size="xl" py="xl">
<Stack gap="xl">
@ -135,6 +206,43 @@ const DemoApp: React.FC = () => {
<Divider />
<div>
<Title order={2} mb="md">Shared Components Demo</Title>
<Text c="dimmed" mb="lg">
Demonstration of shared components from @skybridge/web-components
</Text>
<Group mb="md">
<Button
leftSection={<IconPlus size={16} />}
onClick={() => setShowTable(!showTable)}
>
{showTable ? 'Hide' : 'Show'} DataTable Demo
</Button>
<Button
variant="outline"
onClick={() => setSidebarOpened(true)}
>
Show FormSidebar Demo
</Button>
</Group>
{showTable && (
<DataTable
data={demoData}
columns={tableColumns}
title="Demo User Management"
searchable
onAdd={() => setSidebarOpened(true)}
onEdit={handleEdit}
onDelete={handleDelete}
emptyMessage="No demo data available"
/>
)}
</div>
<Divider />
<Group justify="center">
<Button variant="outline" size="md">
View Documentation
@ -144,6 +252,19 @@ const DemoApp: React.FC = () => {
</Button>
</Group>
</Stack>
<FormSidebar
opened={sidebarOpened}
onClose={() => setSidebarOpened(false)}
onSuccess={() => {
setSidebarOpened(false);
setShowTable(true); // Show table after successful form submission
}}
title="Demo User"
fields={formFields}
onSubmit={handleFormSubmit}
width={400}
/>
</Container>
);
};

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

2
kms/web/dist/665.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,3 +1,13 @@
/**
* @license React
* react-jsx-runtime.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @remix-run/router v1.23.0
*

File diff suppressed because one or more lines are too long

View File

@ -16,9 +16,11 @@
"@mantine/notifications": "^7.0.0",
"@mantine/dates": "^7.0.0",
"@mantine/form": "^7.0.0",
"@mantine/modals": "^7.0.0",
"@tabler/icons-react": "^2.40.0",
"axios": "^1.11.0",
"dayjs": "^1.11.13"
"dayjs": "^1.11.13",
"@skybridge/web-components": "workspace:*"
},
"devDependencies": {
"@babel/core": "^7.20.12",

View File

@ -1,19 +1,8 @@
import React, { useEffect } from 'react';
import {
Paper,
TextInput,
MultiSelect,
Button,
Group,
Stack,
Title,
ActionIcon,
ScrollArea,
Box,
} from '@mantine/core';
import { IconX } from '@tabler/icons-react';
import { useForm } from '@mantine/form';
import { notifications } from '@mantine/notifications';
import React from 'react';
import {
FormSidebar,
FormField
} from '@skybridge/web-components';
import { apiService, Application, CreateApplicationRequest } from '../services/apiService';
interface ApplicationSidebarProps {
@ -29,35 +18,6 @@ const ApplicationSidebar: React.FC<ApplicationSidebarProps> = ({
onSuccess,
editingApp,
}) => {
const isEditing = !!editingApp;
const appTypeOptions = [
{ value: 'static', label: 'Static Token App' },
{ value: 'user', label: 'User Token App' },
];
const form = useForm<CreateApplicationRequest>({
initialValues: {
app_id: '',
app_link: '',
type: [],
callback_url: '',
token_prefix: '',
token_renewal_duration: '24h',
max_token_duration: '168h',
owner: {
type: 'individual',
name: 'Admin User',
owner: 'admin@example.com',
},
},
validate: {
app_id: (value) => value.length < 1 ? 'App ID is required' : null,
app_link: (value) => value.length < 1 ? 'App Link is required' : null,
callback_url: (value) => value.length < 1 ? 'Callback URL is required' : null,
},
});
const parseDuration = (duration: string): number => {
// Convert duration string like "24h" to seconds
const match = duration.match(/^(\d+)([hmd]?)$/);
@ -74,167 +34,97 @@ const ApplicationSidebar: React.FC<ApplicationSidebarProps> = ({
}
};
// Update form values when editingApp changes
useEffect(() => {
const fields: FormField[] = [
{
name: 'app_id',
label: 'Application ID',
type: 'text',
required: true,
placeholder: 'my-app-id',
disabled: !!editingApp, // Disable editing for existing apps
},
{
name: 'app_link',
label: 'Application Link',
type: 'text',
required: true,
placeholder: 'https://myapp.example.com',
validation: { url: true },
},
{
name: 'type',
label: 'Application Type',
type: 'multiselect',
required: true,
options: [
{ value: 'static', label: 'Static Token App' },
{ value: 'user', label: 'User Token App' },
],
},
{
name: 'callback_url',
label: 'Callback URL',
type: 'text',
required: true,
placeholder: 'https://myapp.example.com/callback',
validation: { url: true },
},
{
name: 'token_prefix',
label: 'Token Prefix (Optional)',
type: 'text',
required: false,
placeholder: 'myapp_',
},
{
name: 'token_renewal_duration',
label: 'Token Renewal Duration',
type: 'text',
required: false,
placeholder: '24h',
defaultValue: '24h',
},
{
name: 'max_token_duration',
label: 'Max Token Duration',
type: 'text',
required: false,
placeholder: '168h',
defaultValue: '168h',
},
];
const handleSubmit = async (values: any) => {
const submitData = {
...values,
token_renewal_duration_seconds: parseDuration(values.token_renewal_duration || '24h'),
max_token_duration_seconds: parseDuration(values.max_token_duration || '168h'),
owner: {
type: 'individual',
name: 'Admin User',
owner: 'admin@example.com',
},
};
if (editingApp) {
form.setValues({
app_id: editingApp.app_id || '',
app_link: editingApp.app_link || '',
type: editingApp.type || [],
callback_url: editingApp.callback_url || '',
token_prefix: editingApp.token_prefix || '',
token_renewal_duration: editingApp.token_renewal_duration || '24h',
max_token_duration: editingApp.max_token_duration || '168h',
owner: {
type: editingApp.owner?.type || 'individual',
name: editingApp.owner?.name || 'Admin User',
owner: editingApp.owner?.owner || 'admin@example.com',
},
});
await apiService.updateApplication(editingApp.app_id, submitData);
} else {
// Reset to default values when not editing
form.reset();
}
}, [editingApp, opened]);
const handleSubmit = async (values: typeof form.values) => {
try {
const submitData = {
...values,
token_renewal_duration_seconds: parseDuration(values.token_renewal_duration),
max_token_duration_seconds: parseDuration(values.max_token_duration),
};
if (isEditing && editingApp) {
await apiService.updateApplication(editingApp.app_id, submitData);
notifications.show({
title: 'Success',
message: 'Application updated successfully',
color: 'green',
});
} else {
await apiService.createApplication(submitData);
notifications.show({
title: 'Success',
message: 'Application created successfully',
color: 'green',
});
}
onSuccess();
onClose();
form.reset();
} catch (error) {
console.error('Error saving application:', error);
notifications.show({
title: 'Error',
message: `Failed to ${isEditing ? 'update' : 'create'} application`,
color: 'red',
});
await apiService.createApplication(submitData);
}
};
return (
<Paper
style={{
position: 'fixed',
top: 60, // Below header
right: opened ? 0 : '-450px',
bottom: 0,
width: '450px',
zIndex: 1000,
borderRadius: 0,
display: 'flex',
flexDirection: 'column',
borderLeft: '1px solid var(--mantine-color-gray-3)',
backgroundColor: 'var(--mantine-color-body)',
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 Application' : 'Create New Application'}
</Title>
<ActionIcon
variant="subtle"
color="gray"
onClick={onClose}
>
<IconX size={18} />
</ActionIcon>
</Group>
{/* Content */}
<ScrollArea style={{ flex: 1 }}>
<Box p="md">
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<TextInput
label="Application ID"
placeholder="my-app-id"
required
{...form.getInputProps('app_id')}
disabled={isEditing}
/>
<TextInput
label="Application Link"
placeholder="https://myapp.example.com"
required
{...form.getInputProps('app_link')}
/>
<MultiSelect
label="Application Type"
placeholder="Select application types"
data={appTypeOptions}
required
{...form.getInputProps('type')}
/>
<TextInput
label="Callback URL"
placeholder="https://myapp.example.com/callback"
required
{...form.getInputProps('callback_url')}
/>
<TextInput
label="Token Prefix (Optional)"
placeholder="myapp_"
{...form.getInputProps('token_prefix')}
/>
<Group grow>
<TextInput
label="Token Renewal Duration"
placeholder="24h"
{...form.getInputProps('token_renewal_duration')}
/>
<TextInput
label="Max Token Duration"
placeholder="168h"
{...form.getInputProps('max_token_duration')}
/>
</Group>
<Group justify="flex-end" mt="md">
<Button
variant="light"
onClick={onClose}
>
Cancel
</Button>
<Button type="submit">
{isEditing ? 'Update Application' : 'Create Application'}
</Button>
</Group>
</Stack>
</form>
</Box>
</ScrollArea>
</Paper>
<FormSidebar
opened={opened}
onClose={onClose}
onSuccess={onSuccess}
title="Application"
editMode={!!editingApp}
editItem={editingApp}
fields={fields}
onSubmit={handleSubmit}
width={450}
/>
);
};

View File

@ -1,34 +1,15 @@
import React, { useState, useEffect } from 'react';
import {
Table,
Button,
Stack,
Title,
Modal,
TextInput,
MultiSelect,
Group,
ActionIcon,
import {
DataTable,
TableColumn,
Badge,
Card,
Group,
Text,
Loader,
Alert,
Textarea,
Select,
NumberInput,
} from '@mantine/core';
import {
IconPlus,
IconEdit,
IconTrash,
IconEye,
IconCopy,
IconAlertCircle,
} from '@tabler/icons-react';
import { useForm } from '@mantine/form';
Stack
} from '@skybridge/web-components';
import { IconEye, IconCopy } from '@tabler/icons-react';
import { notifications } from '@mantine/notifications';
import { apiService, Application, CreateApplicationRequest } from '../services/apiService';
import { apiService, Application } from '../services/apiService';
import ApplicationSidebar from './ApplicationSidebar';
import dayjs from 'dayjs';
@ -37,30 +18,6 @@ const Applications: React.FC = () => {
const [loading, setLoading] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [editingApp, setEditingApp] = useState<Application | null>(null);
const [detailModalOpen, setDetailModalOpen] = useState(false);
const [selectedApp, setSelectedApp] = useState<Application | null>(null);
const form = useForm<CreateApplicationRequest>({
initialValues: {
app_id: '',
app_link: '',
type: [],
callback_url: '',
token_prefix: '',
token_renewal_duration: '24h',
max_token_duration: '168h',
owner: {
type: 'individual',
name: 'Admin User',
owner: 'admin@example.com',
},
},
validate: {
app_id: (value) => value.length < 1 ? 'App ID is required' : null,
app_link: (value) => value.length < 1 ? 'App Link is required' : null,
callback_url: (value) => value.length < 1 ? 'Callback URL is required' : null,
},
});
useEffect(() => {
loadApplications();
@ -73,109 +30,30 @@ const Applications: React.FC = () => {
setApplications(response.data);
} catch (error) {
console.error('Failed to load applications:', error);
notifications.show({
title: 'Error',
message: 'Failed to load applications',
color: 'red',
});
} finally {
setLoading(false);
}
};
const parseDuration = (duration: string): number => {
// Convert duration string like "24h" to seconds
const match = duration.match(/^(\d+)([hmd]?)$/);
if (!match) return 86400; // Default to 24h in seconds
const value = parseInt(match[1]);
const unit = match[2] || 'h';
switch (unit) {
case 'm': return value * 60; // minutes to seconds
case 'h': return value * 3600; // hours to seconds
case 'd': return value * 86400; // days to seconds
default: return value * 3600; // default to hours
}
};
const handleSubmit = async (values: CreateApplicationRequest) => {
try {
// Convert duration strings to seconds for API
const apiValues = {
...values,
token_renewal_duration: parseDuration(values.token_renewal_duration),
max_token_duration: parseDuration(values.max_token_duration),
};
if (editingApp) {
await apiService.updateApplication(editingApp.app_id, apiValues);
notifications.show({
title: 'Success',
message: 'Application updated successfully',
color: 'green',
});
} else {
await apiService.createApplication(apiValues);
notifications.show({
title: 'Success',
message: 'Application created successfully',
color: 'green',
});
}
setSidebarOpen(false);
setEditingApp(null);
form.reset();
loadApplications();
} catch (error) {
console.error('Failed to save application:', error);
notifications.show({
title: 'Error',
message: 'Failed to save application',
color: 'red',
});
}
const handleAdd = () => {
setEditingApp(null);
setSidebarOpen(true);
};
const handleEdit = (app: Application) => {
setEditingApp(app);
form.setValues({
app_id: app.app_id,
app_link: app.app_link,
type: app.type,
callback_url: app.callback_url,
token_prefix: app.token_prefix || '',
token_renewal_duration: `${app.token_renewal_duration / 3600}h`,
max_token_duration: `${app.max_token_duration / 3600}h`,
owner: app.owner,
});
setSidebarOpen(true);
};
const handleDelete = async (appId: string) => {
if (window.confirm('Are you sure you want to delete this application?')) {
try {
await apiService.deleteApplication(appId);
notifications.show({
title: 'Success',
message: 'Application deleted successfully',
color: 'green',
});
loadApplications();
} catch (error) {
console.error('Failed to delete application:', error);
notifications.show({
title: 'Error',
message: 'Failed to delete application',
color: 'red',
});
}
}
const handleDelete = async (app: Application) => {
await apiService.deleteApplication(app.app_id);
loadApplications();
};
const handleViewDetails = (app: Application) => {
setSelectedApp(app);
setDetailModalOpen(true);
const handleSuccess = () => {
setSidebarOpen(false);
setEditingApp(null);
loadApplications();
};
const copyToClipboard = (text: string) => {
@ -187,216 +65,85 @@ const Applications: React.FC = () => {
});
};
const appTypeOptions = [
{ value: 'static', label: 'Static' },
{ value: 'user', label: 'User' },
];
const rows = applications.map((app) => (
<Table.Tr key={app.app_id}>
<Table.Td>
<Text fw={500}>{app.app_id}</Text>
</Table.Td>
<Table.Td>
const columns: TableColumn[] = [
{
key: 'app_id',
label: 'Application ID',
render: (value) => <Text fw={500}>{value}</Text>
},
{
key: 'type',
label: 'Type',
render: (value: string[]) => (
<Group gap="xs">
{app.type.map((type) => (
{value.map((type) => (
<Badge key={type} variant="light" size="sm">
{type}
</Badge>
))}
</Group>
</Table.Td>
<Table.Td>
)
},
{
key: 'owner',
label: 'Owner',
render: (value: any) => (
<Text size="sm" c="dimmed">
{app.owner.name} ({app.owner.owner})
{value.name} ({value.owner})
</Text>
</Table.Td>
<Table.Td>
)
},
{
key: 'created_at',
label: 'Created',
render: (value) => (
<Text size="sm">
{dayjs(app.created_at).format('MMM DD, YYYY')}
{dayjs(value).format('MMM DD, YYYY')}
</Text>
</Table.Td>
<Table.Td>
<Group gap="xs">
<ActionIcon
variant="subtle"
color="blue"
onClick={() => handleViewDetails(app)}
title="View Details"
>
<IconEye size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
onClick={() => handleEdit(app)}
title="Edit"
>
<IconEdit size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
onClick={() => handleDelete(app.app_id)}
title="Delete"
>
<IconTrash size={16} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
));
)
},
];
const customActions = [
{
key: 'view',
label: 'View Details',
icon: <IconEye size={14} />,
onClick: (app: Application) => {
// Could open a modal or navigate to details page
console.log('View details for:', app.app_id);
},
},
{
key: 'copy',
label: 'Copy App ID',
icon: <IconCopy size={14} />,
onClick: (app: Application) => copyToClipboard(app.app_id),
},
];
return (
<Stack
gap="lg"
style={{
transition: 'margin-right 0.3s ease',
marginRight: sidebarOpen ? '450px' : '0',
}}
>
<Group justify="space-between">
<div>
<Title order={2} mb="xs">
Applications
</Title>
</div>
<Button
leftSection={<IconPlus size={16} />}
onClick={() => {
setEditingApp(null);
form.reset();
setSidebarOpen(true);
}}
>
New Application
</Button>
</Group>
{loading ? (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
<Text>Loading applications...</Text>
</Stack>
) : applications.length === 0 ? (
<Card shadow="sm" radius="md" withBorder p="xl">
<Stack align="center" gap="md">
<IconAlertCircle size={48} color="gray" />
<div style={{ textAlign: 'center' }}>
<Text fw={500} mb="xs">
No applications found
</Text>
<Text size="sm" c="dimmed">
Create your first application to get started with the key management system
</Text>
</div>
<Button
leftSection={<IconPlus size={16} />}
onClick={() => {
setEditingApp(null);
form.reset();
setSidebarOpen(true);
}}
>
Create Application
</Button>
</Stack>
</Card>
) : (
<Card shadow="sm" radius="md" withBorder>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Application ID</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Owner</Table.Th>
<Table.Th>Created</Table.Th>
<Table.Th>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
</Card>
)}
<Stack gap="md">
<DataTable
data={applications}
columns={columns}
loading={loading}
title="Applications"
searchable
onAdd={handleAdd}
onEdit={handleEdit}
onDelete={handleDelete}
onRefresh={loadApplications}
customActions={customActions}
emptyMessage="No applications found"
/>
<ApplicationSidebar
opened={sidebarOpen}
onClose={() => {
setSidebarOpen(false);
setEditingApp(null);
}}
onSuccess={() => {
loadApplications();
}}
onClose={() => setSidebarOpen(false)}
onSuccess={handleSuccess}
editingApp={editingApp}
/>
{/* Detail Modal */}
<Modal
opened={detailModalOpen}
onClose={() => setDetailModalOpen(false)}
title="Application Details"
size="md"
>
{selectedApp && (
<Stack gap="md">
<Group justify="space-between">
<Text fw={500}>Application ID:</Text>
<Group gap="xs">
<Text>{selectedApp.app_id}</Text>
<ActionIcon
size="sm"
variant="subtle"
onClick={() => copyToClipboard(selectedApp.app_id)}
>
<IconCopy size={12} />
</ActionIcon>
</Group>
</Group>
<Group justify="space-between">
<Text fw={500}>HMAC Key:</Text>
<Group gap="xs">
<Text size="sm" style={{ fontFamily: 'monospace' }}>
{selectedApp.hmac_key.substring(0, 16)}...
</Text>
<ActionIcon
size="sm"
variant="subtle"
onClick={() => copyToClipboard(selectedApp.hmac_key)}
>
<IconCopy size={12} />
</ActionIcon>
</Group>
</Group>
<Group justify="space-between">
<Text fw={500}>Application Link:</Text>
<Text size="sm">{selectedApp.app_link}</Text>
</Group>
<Group justify="space-between">
<Text fw={500}>Callback URL:</Text>
<Text size="sm">{selectedApp.callback_url}</Text>
</Group>
<Group justify="space-between">
<Text fw={500}>Token Renewal:</Text>
<Text size="sm">{selectedApp.token_renewal_duration / 3600}h</Text>
</Group>
<Group justify="space-between">
<Text fw={500}>Max Duration:</Text>
<Text size="sm">{selectedApp.max_token_duration / 3600}h</Text>
</Group>
<Group justify="space-between">
<Text fw={500}>Created:</Text>
<Text size="sm">{dayjs(selectedApp.created_at).format('MMM DD, YYYY HH:mm')}</Text>
</Group>
</Stack>
)}
</Modal>
</Stack>
);
};

View File

@ -1,29 +1,14 @@
import React, { useState, useEffect } from 'react';
import {
Button,
Group,
Paper,
Text,
Table,
import {
DataTable,
TableColumn,
Badge,
ActionIcon,
TextInput,
Select,
Pagination,
Stack,
LoadingOverlay,
Avatar,
} from '@mantine/core';
import {
IconPlus,
IconEdit,
IconTrash,
IconSearch,
IconUser,
IconMail,
} from '@tabler/icons-react';
import { notifications } from '@mantine/notifications';
import { modals } from '@mantine/modals';
Group,
Text,
Stack
} from '@skybridge/web-components';
import { Avatar } from '@mantine/core';
import { IconUser, IconMail } from '@tabler/icons-react';
import UserSidebar from './UserSidebar';
import { userService } from '../services/userService';
import { User, UserStatus, UserRole, ListUsersRequest } from '../types/user';
@ -31,9 +16,7 @@ import { User, UserStatus, UserRole, ListUsersRequest } from '../types/user';
const UserManagement: React.FC = () => {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [roleFilter, setRoleFilter] = useState<string | null>(null);
const [filters, setFilters] = useState({});
const [currentPage, setCurrentPage] = useState(1);
const [totalUsers, setTotalUsers] = useState(0);
const [userSidebarOpened, setUserSidebarOpened] = useState(false);
@ -41,13 +24,13 @@ const UserManagement: React.FC = () => {
const pageSize = 10;
const loadUsers = async (page: number = currentPage) => {
const loadUsers = async (page: number = currentPage, newFilters = filters) => {
setLoading(true);
try {
const request: ListUsersRequest = {
search: searchTerm || undefined,
status: statusFilter as UserStatus || undefined,
role: roleFilter as UserRole || undefined,
search: newFilters.search || undefined,
status: newFilters.status as UserStatus || undefined,
role: newFilters.role as UserRole || undefined,
limit: pageSize,
offset: (page - 1) * pageSize,
order_by: 'created_at',
@ -60,95 +43,108 @@ const UserManagement: React.FC = () => {
setCurrentPage(page);
} catch (error) {
console.error('Failed to load users:', error);
notifications.show({
title: 'Error',
message: 'Failed to load users',
color: 'red',
});
} finally {
setLoading(false);
}
};
useEffect(() => {
loadUsers(1);
}, [searchTerm, statusFilter, roleFilter]);
loadUsers(1, filters);
}, [filters]);
const handleCreateUser = () => {
const handleAdd = () => {
setEditingUser(null);
setUserSidebarOpened(true);
};
const handleEditUser = (user: User) => {
const handleEdit = (user: User) => {
setEditingUser(user);
setUserSidebarOpened(true);
};
const handleDeleteUser = (user: User) => {
modals.openConfirmModal({
title: 'Delete User',
children: (
<Text size="sm">
Are you sure you want to delete {user.first_name} {user.last_name}? This action cannot be undone.
</Text>
),
labels: { confirm: 'Delete', cancel: 'Cancel' },
confirmProps: { color: 'red' },
onConfirm: async () => {
try {
await userService.deleteUser(user.id);
notifications.show({
title: 'Success',
message: 'User deleted successfully',
color: 'green',
});
loadUsers();
} catch (error) {
console.error('Failed to delete user:', error);
notifications.show({
title: 'Error',
message: 'Failed to delete user',
color: 'red',
});
}
},
});
};
const handleUserSidebarSuccess = () => {
const handleDelete = async (user: User) => {
await userService.deleteUser(user.id);
loadUsers();
};
const handleUserSidebarClose = () => {
const handleSuccess = () => {
setUserSidebarOpened(false);
setEditingUser(null);
loadUsers();
};
const getStatusColor = (status: UserStatus): string => {
switch (status) {
case 'active': return 'green';
case 'inactive': return 'gray';
case 'suspended': return 'red';
case 'pending': return 'yellow';
default: return 'gray';
}
const handleFiltersChange = (newFilters) => {
setFilters(newFilters);
setCurrentPage(1);
};
const getRoleColor = (role: UserRole): string => {
switch (role) {
case 'admin': return 'red';
case 'moderator': return 'orange';
case 'user': return 'blue';
case 'viewer': return 'gray';
default: return 'gray';
}
};
const totalPages = Math.ceil(totalUsers / pageSize);
const columns: TableColumn[] = [
{
key: 'user',
label: 'User',
render: (_, user: User) => (
<Group gap="sm">
<Avatar
src={user.avatar || null}
radius="sm"
size={32}
>
<IconUser size={16} />
</Avatar>
<div>
<Text size="sm" fw={500}>
{user.display_name || `${user.first_name} ${user.last_name}`}
</Text>
<Text size="xs" c="dimmed">
{user.first_name} {user.last_name}
</Text>
</div>
</Group>
)
},
{
key: 'email',
label: 'Email',
render: (value) => (
<Group gap="xs">
<IconMail size={14} />
<Text size="sm">{value}</Text>
</Group>
)
},
{
key: 'role',
label: 'Role',
render: (value) => {
const roleColors = {
admin: 'red',
moderator: 'orange',
user: 'blue',
viewer: 'gray'
};
return (
<Badge color={roleColors[value] || 'blue'} size="sm">
{value}
</Badge>
);
}
},
{
key: 'status',
label: 'Status'
},
{
key: 'created_at',
label: 'Created',
render: (value) => (
<Text size="sm">
{new Date(value).toLocaleDateString()}
</Text>
)
},
];
return (
<>
{/* Main user management interface */}
<Stack
gap="md"
style={{
@ -156,160 +152,32 @@ const UserManagement: React.FC = () => {
marginRight: userSidebarOpened ? '400px' : '0',
}}
>
<Group justify="space-between">
<Button leftSection={<IconPlus size={16} />} onClick={handleCreateUser}>
Add User
</Button>
</Group>
<Paper p="md" withBorder>
<Group justify="space-between" mb="md">
<Group gap="sm">
<TextInput
placeholder="Search users..."
leftSection={<IconSearch size={16} />}
value={searchTerm}
onChange={(e) => setSearchTerm(e.currentTarget.value)}
style={{ minWidth: 250 }}
/>
<Select
placeholder="Filter by status"
data={[
{ value: 'active', label: 'Active' },
{ value: 'inactive', label: 'Inactive' },
{ value: 'suspended', label: 'Suspended' },
{ value: 'pending', label: 'Pending' },
]}
value={statusFilter}
onChange={setStatusFilter}
clearable
/>
<Select
placeholder="Filter by role"
data={[
{ value: 'admin', label: 'Admin' },
{ value: 'moderator', label: 'Moderator' },
{ value: 'user', label: 'User' },
{ value: 'viewer', label: 'Viewer' },
]}
value={roleFilter}
onChange={setRoleFilter}
clearable
/>
</Group>
<Text size="sm" c="dimmed">
{totalUsers} users found
</Text>
</Group>
<div style={{ position: 'relative' }}>
<LoadingOverlay visible={loading} />
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>User</Table.Th>
<Table.Th>Email</Table.Th>
<Table.Th>Role</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Created</Table.Th>
<Table.Th>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{users.map((user) => (
<Table.Tr key={user.id}>
<Table.Td>
<Group gap="sm">
<Avatar
src={user.avatar || null}
radius="sm"
size={32}
>
<IconUser size={16} />
</Avatar>
<div>
<Text size="sm" fw={500}>
{user.display_name || `${user.first_name} ${user.last_name}`}
</Text>
<Text size="xs" c="dimmed">
{user.first_name} {user.last_name}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>
<Group gap="xs">
<IconMail size={14} />
<Text size="sm">{user.email}</Text>
</Group>
</Table.Td>
<Table.Td>
<Badge color={getRoleColor(user.role)} variant="light">
{user.role}
</Badge>
</Table.Td>
<Table.Td>
<Badge color={getStatusColor(user.status)} variant="light">
{user.status}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm">
{new Date(user.created_at).toLocaleDateString()}
</Text>
</Table.Td>
<Table.Td>
<Group gap="xs">
<ActionIcon
variant="light"
size="sm"
onClick={() => handleEditUser(user)}
>
<IconEdit size={14} />
</ActionIcon>
<ActionIcon
variant="light"
color="red"
size="sm"
onClick={() => handleDeleteUser(user)}
>
<IconTrash size={14} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
{users.length === 0 && !loading && (
<Text ta="center" py="xl" c="dimmed">
No users found
</Text>
)}
</div>
{totalPages > 1 && (
<Group justify="center" mt="md">
<Pagination
value={currentPage}
onChange={loadUsers}
total={totalPages}
size="sm"
/>
</Group>
)}
</Paper>
</Stack>
{/* User sidebar */}
<DataTable
data={users}
columns={columns}
loading={loading}
title="User Management"
total={totalUsers}
page={currentPage}
pageSize={pageSize}
onPageChange={loadUsers}
searchable
filters={filters}
onFiltersChange={handleFiltersChange}
onAdd={handleAdd}
onEdit={handleEdit}
onDelete={handleDelete}
onRefresh={() => loadUsers()}
emptyMessage="No users found"
/>
<UserSidebar
opened={userSidebarOpened}
onClose={handleUserSidebarClose}
onSuccess={handleUserSidebarSuccess}
onClose={() => setUserSidebarOpened(false)}
onSuccess={handleSuccess}
editUser={editingUser}
/>
</>
</Stack>
);
};

View File

@ -1,21 +1,10 @@
import React, { useEffect } from 'react';
import {
Paper,
TextInput,
Select,
Button,
Group,
Stack,
Title,
ActionIcon,
ScrollArea,
Box,
} from '@mantine/core';
import { IconX } from '@tabler/icons-react';
import { useForm } from '@mantine/form';
import { notifications } from '@mantine/notifications';
import React from 'react';
import {
FormSidebar,
FormField
} from '@skybridge/web-components';
import { userService } from '../services/userService';
import { User, CreateUserRequest, UpdateUserRequest, UserRole, UserStatus } from '../types/user';
import { User } from '../types/user';
interface UserSidebarProps {
opened: boolean;
@ -30,225 +19,91 @@ const UserSidebar: React.FC<UserSidebarProps> = ({
onSuccess,
editUser,
}) => {
const isEditing = !!editUser;
const form = useForm({
initialValues: {
email: '',
first_name: '',
last_name: '',
display_name: '',
avatar: '',
role: 'user' as UserRole,
status: 'pending' as UserStatus,
const fields: FormField[] = [
{
name: 'first_name',
label: 'First Name',
type: 'text',
required: true,
placeholder: 'Enter first name',
},
validate: {
email: (value) => (/^\S+@\S+$/.test(value) ? null : 'Invalid email'),
first_name: (value) => (value.trim().length < 1 ? 'First name is required' : null),
last_name: (value) => (value.trim().length < 1 ? 'Last name is required' : null),
{
name: 'last_name',
label: 'Last Name',
type: 'text',
required: true,
placeholder: 'Enter last name',
},
});
{
name: 'display_name',
label: 'Display Name',
type: 'text',
required: false,
placeholder: 'Enter display name (optional)',
},
{
name: 'email',
label: 'Email',
type: 'email',
required: true,
placeholder: 'Enter email address',
validation: { email: true },
},
{
name: 'avatar',
label: 'Avatar URL',
type: 'text',
required: false,
placeholder: 'Enter avatar URL (optional)',
},
{
name: 'role',
label: 'Role',
type: 'select',
required: true,
options: [
{ value: 'admin', label: 'Admin' },
{ value: 'moderator', label: 'Moderator' },
{ value: 'user', label: 'User' },
{ value: 'viewer', label: 'Viewer' },
],
defaultValue: 'user',
},
{
name: 'status',
label: 'Status',
type: 'select',
required: true,
options: [
{ value: 'active', label: 'Active' },
{ value: 'inactive', label: 'Inactive' },
{ value: 'suspended', label: 'Suspended' },
{ value: 'pending', label: 'Pending' },
],
defaultValue: 'pending',
},
];
// Update form values when editUser changes
useEffect(() => {
const handleSubmit = async (values: any) => {
if (editUser) {
form.setValues({
email: editUser.email || '',
first_name: editUser.first_name || '',
last_name: editUser.last_name || '',
display_name: editUser.display_name || '',
avatar: editUser.avatar || '',
role: editUser.role || 'user' as UserRole,
status: editUser.status || 'pending' as UserStatus,
});
await userService.updateUser(editUser.id, values);
} else {
// Reset to default values when not editing
form.setValues({
email: '',
first_name: '',
last_name: '',
display_name: '',
avatar: '',
role: 'user' as UserRole,
status: 'pending' as UserStatus,
});
}
}, [editUser, opened]);
const handleSubmit = async (values: typeof form.values) => {
try {
if (isEditing && editUser) {
const updateRequest: UpdateUserRequest = {
email: values.email !== editUser.email ? values.email : undefined,
first_name: values.first_name !== editUser.first_name ? values.first_name : undefined,
last_name: values.last_name !== editUser.last_name ? values.last_name : undefined,
display_name: values.display_name !== editUser.display_name ? values.display_name : undefined,
avatar: values.avatar !== editUser.avatar ? values.avatar : undefined,
role: values.role !== editUser.role ? values.role : undefined,
status: values.status !== editUser.status ? values.status : undefined,
};
// Only send fields that have changed
const hasChanges = Object.values(updateRequest).some(value => value !== undefined);
if (!hasChanges) {
notifications.show({
title: 'No Changes',
message: 'No changes detected',
color: 'blue',
});
onClose();
return;
}
await userService.updateUser(editUser.id, updateRequest);
notifications.show({
title: 'Success',
message: 'User updated successfully',
color: 'green',
});
} else {
const createRequest: CreateUserRequest = {
email: values.email,
first_name: values.first_name,
last_name: values.last_name,
display_name: values.display_name || undefined,
avatar: values.avatar || undefined,
role: values.role,
status: values.status,
};
await userService.createUser(createRequest);
notifications.show({
title: 'Success',
message: 'User created successfully',
color: 'green',
});
}
onSuccess();
onClose();
form.reset();
} catch (error: any) {
console.error('Failed to save user:', error);
notifications.show({
title: 'Error',
message: error.message || 'Failed to save user',
color: 'red',
});
await userService.createUser(values);
}
};
return (
<Paper
style={{
position: 'fixed',
top: 60, // Below header
right: opened ? 0 : '-400px',
bottom: 0,
width: '400px',
zIndex: 1000,
borderRadius: 0,
display: 'flex',
flexDirection: 'column',
borderLeft: '1px solid var(--mantine-color-gray-3)',
backgroundColor: 'var(--mantine-color-body)',
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 User' : 'Create User'}
</Title>
<ActionIcon
variant="subtle"
color="gray"
onClick={onClose}
>
<IconX size={18} />
</ActionIcon>
</Group>
{/* Content */}
<ScrollArea style={{ flex: 1 }}>
<Box p="md">
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<Group grow>
<TextInput
label="First Name"
placeholder="Enter first name"
required
{...form.getInputProps('first_name')}
/>
<TextInput
label="Last Name"
placeholder="Enter last name"
required
{...form.getInputProps('last_name')}
/>
</Group>
<TextInput
label="Display Name"
placeholder="Enter display name (optional)"
{...form.getInputProps('display_name')}
/>
<TextInput
label="Email"
placeholder="Enter email address"
required
type="email"
{...form.getInputProps('email')}
/>
<TextInput
label="Avatar URL"
placeholder="Enter avatar URL (optional)"
{...form.getInputProps('avatar')}
/>
<Group grow>
<Select
label="Role"
placeholder="Select role"
required
data={[
{ value: 'admin', label: 'Admin' },
{ value: 'moderator', label: 'Moderator' },
{ value: 'user', label: 'User' },
{ value: 'viewer', label: 'Viewer' },
]}
{...form.getInputProps('role')}
/>
<Select
label="Status"
placeholder="Select status"
required
data={[
{ value: 'active', label: 'Active' },
{ value: 'inactive', label: 'Inactive' },
{ value: 'suspended', label: 'Suspended' },
{ value: 'pending', label: 'Pending' },
]}
{...form.getInputProps('status')}
/>
</Group>
<Group justify="flex-end" mt="xl">
<Button variant="light" onClick={onClose}>
Cancel
</Button>
<Button type="submit">
{isEditing ? 'Update' : 'Create'} User
</Button>
</Group>
</Stack>
</form>
</Box>
</ScrollArea>
</Paper>
<FormSidebar
opened={opened}
onClose={onClose}
onSuccess={onSuccess}
title="User"
editMode={!!editUser}
editItem={editUser}
fields={fields}
onSubmit={handleSubmit}
width={400}
/>
);
};

2
web/dist/main.js vendored

File diff suppressed because one or more lines are too long

View File

@ -14,7 +14,10 @@
"@mantine/core": "^7.0.0",
"@mantine/hooks": "^7.0.0",
"@mantine/notifications": "^7.0.0",
"@tabler/icons-react": "^2.40.0"
"@mantine/form": "^7.0.0",
"@mantine/modals": "^7.0.0",
"@tabler/icons-react": "^2.40.0",
"@skybridge/web-components": "workspace:*"
},
"devDependencies": {
"@babel/core": "^7.20.12",

View File

@ -7,8 +7,8 @@ import {
Group,
Stack,
Title,
Badge,
} from '@mantine/core';
import { Badge } from '@skybridge/web-components';
import { useNavigate } from 'react-router-dom';
import {
IconStar,