sidebar fix
This commit is contained in:
@ -7,8 +7,8 @@ import {
|
||||
IconStarFilled
|
||||
} from '@tabler/icons-react';
|
||||
import { FunctionList } from './components/FunctionList';
|
||||
import { FunctionForm } from './components/FunctionForm';
|
||||
import { ExecutionModal } from './components/ExecutionModal';
|
||||
import { FunctionSidebar } from './components/FunctionSidebar';
|
||||
import { ExecutionSidebar } from './components/ExecutionSidebar';
|
||||
import ExecutionList from './components/ExecutionList';
|
||||
import { FunctionDefinition } from './types';
|
||||
|
||||
@ -24,8 +24,8 @@ const App: React.FC = () => {
|
||||
const [currentRoute, setCurrentRoute] = useState(getCurrentRoute());
|
||||
const [isFavorited, setIsFavorited] = useState(false);
|
||||
const [selectedColor, setSelectedColor] = useState('');
|
||||
const [functionFormOpened, setFunctionFormOpened] = useState(false);
|
||||
const [executionModalOpened, setExecutionModalOpened] = useState(false);
|
||||
const [functionSidebarOpened, setFunctionSidebarOpened] = useState(false);
|
||||
const [executionSidebarOpened, setExecutionSidebarOpened] = useState(false);
|
||||
const [editingFunction, setEditingFunction] = useState<FunctionDefinition | null>(null);
|
||||
const [executingFunction, setExecutingFunction] = useState<FunctionDefinition | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
@ -57,30 +57,30 @@ const App: React.FC = () => {
|
||||
|
||||
const handleCreateFunction = () => {
|
||||
setEditingFunction(null);
|
||||
setFunctionFormOpened(true);
|
||||
setFunctionSidebarOpened(true);
|
||||
};
|
||||
|
||||
const handleEditFunction = (func: FunctionDefinition) => {
|
||||
setEditingFunction(func);
|
||||
setFunctionFormOpened(true);
|
||||
setFunctionSidebarOpened(true);
|
||||
};
|
||||
|
||||
const handleExecuteFunction = (func: FunctionDefinition) => {
|
||||
setExecutingFunction(func);
|
||||
setExecutionModalOpened(true);
|
||||
setExecutionSidebarOpened(true);
|
||||
};
|
||||
|
||||
const handleFormSuccess = () => {
|
||||
setRefreshKey(prev => prev + 1);
|
||||
};
|
||||
|
||||
const handleFormClose = () => {
|
||||
setFunctionFormOpened(false);
|
||||
const handleSidebarClose = () => {
|
||||
setFunctionSidebarOpened(false);
|
||||
setEditingFunction(null);
|
||||
};
|
||||
|
||||
const handleExecutionClose = () => {
|
||||
setExecutionModalOpened(false);
|
||||
setExecutionSidebarOpened(false);
|
||||
setExecutingFunction(null);
|
||||
};
|
||||
|
||||
@ -124,8 +124,15 @@ const App: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Box w="100%" pos="relative">
|
||||
<Stack gap="lg">
|
||||
<>
|
||||
<Stack
|
||||
gap="lg"
|
||||
style={{
|
||||
transition: 'margin-right 0.3s ease',
|
||||
marginRight: (functionSidebarOpened || executionSidebarOpened) ?
|
||||
(functionSidebarOpened ? '500px' : '600px') : '0',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div>
|
||||
@ -186,19 +193,19 @@ const App: React.FC = () => {
|
||||
</Tabs>
|
||||
</Stack>
|
||||
|
||||
<FunctionForm
|
||||
opened={functionFormOpened}
|
||||
onClose={handleFormClose}
|
||||
<FunctionSidebar
|
||||
opened={functionSidebarOpened}
|
||||
onClose={handleSidebarClose}
|
||||
onSuccess={handleFormSuccess}
|
||||
editFunction={editingFunction}
|
||||
/>
|
||||
|
||||
<ExecutionModal
|
||||
opened={executionModalOpened}
|
||||
<ExecutionSidebar
|
||||
opened={executionSidebarOpened}
|
||||
onClose={handleExecutionClose}
|
||||
function={executingFunction}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
457
faas/web/src/components/ExecutionSidebar.tsx
Normal file
457
faas/web/src/components/ExecutionSidebar.tsx
Normal file
@ -0,0 +1,457 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Paper,
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
Switch,
|
||||
Alert,
|
||||
Badge,
|
||||
Divider,
|
||||
JsonInput,
|
||||
Loader,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
Title,
|
||||
ScrollArea,
|
||||
Box,
|
||||
} from '@mantine/core';
|
||||
import { IconPlayerPlay, IconPlayerStop, IconRefresh, IconCopy, IconX } from '@tabler/icons-react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { functionApi, executionApi } from '../services/apiService';
|
||||
import { FunctionDefinition, ExecuteFunctionResponse, FunctionExecution } from '../types';
|
||||
|
||||
interface ExecutionSidebarProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
function: FunctionDefinition | null;
|
||||
}
|
||||
|
||||
export const ExecutionSidebar: React.FC<ExecutionSidebarProps> = ({
|
||||
opened,
|
||||
onClose,
|
||||
function: func,
|
||||
}) => {
|
||||
const [input, setInput] = useState('{}');
|
||||
const [async, setAsync] = useState(false);
|
||||
const [executing, setExecuting] = useState(false);
|
||||
const [result, setResult] = useState<ExecuteFunctionResponse | null>(null);
|
||||
const [execution, setExecution] = useState<FunctionExecution | null>(null);
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const [loadingLogs, setLoadingLogs] = useState(false);
|
||||
const [autoRefreshLogs, setAutoRefreshLogs] = useState(false);
|
||||
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const logsPollIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const stopLogsAutoRefresh = () => {
|
||||
if (logsPollIntervalRef.current) {
|
||||
clearInterval(logsPollIntervalRef.current);
|
||||
logsPollIntervalRef.current = null;
|
||||
}
|
||||
setAutoRefreshLogs(false);
|
||||
};
|
||||
|
||||
// Cleanup intervals on unmount or when sidebar closes
|
||||
useEffect(() => {
|
||||
if (!opened) {
|
||||
// Stop auto-refresh when sidebar closes
|
||||
stopLogsAutoRefresh();
|
||||
if (pollIntervalRef.current) {
|
||||
clearTimeout(pollIntervalRef.current);
|
||||
}
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
// Cleanup intervals on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pollIntervalRef.current) {
|
||||
clearTimeout(pollIntervalRef.current);
|
||||
}
|
||||
if (logsPollIntervalRef.current) {
|
||||
clearInterval(logsPollIntervalRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!func) return null;
|
||||
|
||||
const handleExecute = async () => {
|
||||
try {
|
||||
setExecuting(true);
|
||||
setResult(null);
|
||||
setExecution(null);
|
||||
setLogs([]);
|
||||
|
||||
let parsedInput;
|
||||
try {
|
||||
parsedInput = input.trim() ? JSON.parse(input) : undefined;
|
||||
} catch (e) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Invalid JSON input',
|
||||
color: 'red',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await functionApi.execute(func.id, {
|
||||
input: parsedInput,
|
||||
async,
|
||||
});
|
||||
|
||||
setResult(response.data);
|
||||
|
||||
if (async) {
|
||||
// Poll for execution status and start auto-refreshing logs
|
||||
pollExecution(response.data.execution_id);
|
||||
} else {
|
||||
// For synchronous executions, load logs immediately
|
||||
if (response.data.execution_id) {
|
||||
loadLogs(response.data.execution_id);
|
||||
}
|
||||
}
|
||||
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: `Function ${async ? 'invoked' : 'executed'} successfully`,
|
||||
color: 'green',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Execution error:', error);
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to execute function',
|
||||
color: 'red',
|
||||
});
|
||||
} finally {
|
||||
setExecuting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pollExecution = async (executionId: string) => {
|
||||
// Start auto-refreshing logs immediately for async executions
|
||||
startLogsAutoRefresh(executionId);
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const response = await executionApi.getById(executionId);
|
||||
setExecution(response.data);
|
||||
|
||||
if (response.data.status === 'running' || response.data.status === 'pending') {
|
||||
pollIntervalRef.current = setTimeout(poll, 2000); // Poll every 2 seconds
|
||||
} else {
|
||||
// Execution completed, stop auto-refresh and load final logs
|
||||
stopLogsAutoRefresh();
|
||||
loadLogs(executionId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error polling execution:', error);
|
||||
stopLogsAutoRefresh();
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
};
|
||||
|
||||
const loadLogs = async (executionId: string) => {
|
||||
try {
|
||||
console.debug(`[ExecutionSidebar] Loading logs for execution ${executionId}`);
|
||||
setLoadingLogs(true);
|
||||
const response = await executionApi.getLogs(executionId);
|
||||
console.debug(`[ExecutionSidebar] Loaded logs for execution ${executionId}:`, {
|
||||
logCount: response.data.logs?.length || 0,
|
||||
logs: response.data.logs
|
||||
});
|
||||
setLogs(response.data.logs || []);
|
||||
} catch (error) {
|
||||
console.error(`[ExecutionSidebar] Error loading logs for execution ${executionId}:`, error);
|
||||
} finally {
|
||||
setLoadingLogs(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startLogsAutoRefresh = (executionId: string) => {
|
||||
console.debug(`[ExecutionSidebar] Starting auto-refresh for execution ${executionId}`);
|
||||
|
||||
// Clear any existing interval
|
||||
if (logsPollIntervalRef.current) {
|
||||
clearInterval(logsPollIntervalRef.current);
|
||||
}
|
||||
|
||||
setAutoRefreshLogs(true);
|
||||
|
||||
// Load logs immediately
|
||||
loadLogs(executionId);
|
||||
|
||||
// Set up auto-refresh every 2 seconds
|
||||
logsPollIntervalRef.current = setInterval(async () => {
|
||||
try {
|
||||
console.debug(`[ExecutionSidebar] Auto-refreshing logs for execution ${executionId}`);
|
||||
const response = await executionApi.getLogs(executionId);
|
||||
console.debug(`[ExecutionSidebar] Auto-refresh got logs for execution ${executionId}:`, {
|
||||
logCount: response.data.logs?.length || 0,
|
||||
logs: response.data.logs
|
||||
});
|
||||
setLogs(response.data.logs || []);
|
||||
} catch (error) {
|
||||
console.error(`[ExecutionSidebar] Error auto-refreshing logs for execution ${executionId}:`, error);
|
||||
}
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (result && async) {
|
||||
try {
|
||||
await executionApi.cancel(result.execution_id);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Execution canceled',
|
||||
color: 'orange',
|
||||
});
|
||||
// Refresh execution status
|
||||
if (result.execution_id) {
|
||||
const response = await executionApi.getById(result.execution_id);
|
||||
setExecution(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to cancel execution',
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed': return 'green';
|
||||
case 'failed': return 'red';
|
||||
case 'running': return 'blue';
|
||||
case 'pending': return 'yellow';
|
||||
case 'canceled': return 'orange';
|
||||
case 'timeout': return 'red';
|
||||
default: return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
notifications.show({
|
||||
title: 'Copied',
|
||||
message: 'Copied to clipboard',
|
||||
color: 'green',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 60, // Below header
|
||||
right: opened ? 0 : '-600px',
|
||||
bottom: 0,
|
||||
width: '600px',
|
||||
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}>
|
||||
Execute Function: {func.name}
|
||||
</Title>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={onClose}
|
||||
>
|
||||
<IconX size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
{/* Content */}
|
||||
<ScrollArea style={{ flex: 1 }}>
|
||||
<Box p="md">
|
||||
<Stack gap="md">
|
||||
{/* Function Info */}
|
||||
<Paper withBorder p="sm" bg="gray.0">
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Text size="sm" fw={500}>{func.name}</Text>
|
||||
<Text size="xs" c="dimmed">{func.runtime} • {func.memory}MB • {func.timeout}</Text>
|
||||
</div>
|
||||
<Badge variant="light">
|
||||
{func.runtime}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{/* Input */}
|
||||
<JsonInput
|
||||
label="Function Input (JSON)"
|
||||
placeholder='{"key": "value"}'
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
minRows={4}
|
||||
maxRows={8}
|
||||
validationError="Invalid JSON"
|
||||
/>
|
||||
|
||||
{/* Execution Options */}
|
||||
<Group justify="space-between">
|
||||
<Switch
|
||||
label="Asynchronous execution"
|
||||
description="Execute in background"
|
||||
checked={async}
|
||||
onChange={(event) => setAsync(event.currentTarget.checked)}
|
||||
/>
|
||||
<Group>
|
||||
<Button
|
||||
leftSection={<IconPlayerPlay size={16} />}
|
||||
onClick={handleExecute}
|
||||
loading={executing}
|
||||
disabled={executing}
|
||||
>
|
||||
{async ? 'Invoke' : 'Execute'}
|
||||
</Button>
|
||||
{result && async && execution?.status === 'running' && (
|
||||
<Button
|
||||
leftSection={<IconPlayerStop size={16} />}
|
||||
color="orange"
|
||||
variant="light"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Results */}
|
||||
{result && (
|
||||
<>
|
||||
<Divider label="Execution Result" labelPosition="center" />
|
||||
|
||||
<Paper withBorder p="md">
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Text fw={500}>Execution #{result.execution_id.slice(0, 8)}...</Text>
|
||||
<Group gap="xs">
|
||||
<Badge color={getStatusColor(execution?.status || result.status)}>
|
||||
{execution?.status || result.status}
|
||||
</Badge>
|
||||
{result.duration && (
|
||||
<Badge variant="light">
|
||||
{result.duration}ms
|
||||
</Badge>
|
||||
)}
|
||||
{result.memory_used && (
|
||||
<Badge variant="light">
|
||||
{result.memory_used}MB
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Output */}
|
||||
{(result.output || execution?.output) && (
|
||||
<div>
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text size="sm" fw={500}>Output:</Text>
|
||||
<Tooltip label="Copy output">
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(JSON.stringify(result.output || execution?.output, null, 2))}
|
||||
>
|
||||
<IconCopy size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<Paper bg="gray.1" p="sm">
|
||||
<Text size="sm" component="pre" style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{JSON.stringify(result.output || execution?.output, null, 2)}
|
||||
</Text>
|
||||
</Paper>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{(result.error || execution?.error) && (
|
||||
<Alert color="red" mt="sm">
|
||||
<Text size="sm">{result.error || execution?.error}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Logs */}
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Group gap="xs">
|
||||
<Text size="sm" fw={500}>Logs:</Text>
|
||||
{autoRefreshLogs && (
|
||||
<Badge size="xs" color="blue" variant="light">
|
||||
Auto-refreshing
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
{result.execution_id && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant={autoRefreshLogs ? "filled" : "light"}
|
||||
color={autoRefreshLogs ? "red" : "blue"}
|
||||
leftSection={<IconRefresh size={12} />}
|
||||
onClick={() => {
|
||||
if (autoRefreshLogs) {
|
||||
stopLogsAutoRefresh();
|
||||
} else {
|
||||
startLogsAutoRefresh(result.execution_id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{autoRefreshLogs ? 'Stop Auto-refresh' : 'Auto-refresh'}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<IconRefresh size={12} />}
|
||||
onClick={() => result.execution_id && loadLogs(result.execution_id)}
|
||||
loading={loadingLogs}
|
||||
disabled={autoRefreshLogs}
|
||||
>
|
||||
Manual Refresh
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
<Paper bg="gray.9" p="sm" mah={200} style={{ overflow: 'auto' }}>
|
||||
{loadingLogs ? (
|
||||
<Group justify="center">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : (logs.length > 0 || (execution?.logs && execution.logs.length > 0)) ? (
|
||||
<Text size="xs" c="white" component="pre">
|
||||
{(execution?.logs || logs).join('\n')}
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="xs" c="gray.5">No logs available</Text>
|
||||
)}
|
||||
</Paper>
|
||||
</div>
|
||||
</Paper>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</ScrollArea>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
502
faas/web/src/components/FunctionSidebar.tsx
Normal file
502
faas/web/src/components/FunctionSidebar.tsx
Normal file
@ -0,0 +1,502 @@
|
||||
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 Editor from '@monaco-editor/react';
|
||||
import { functionApi, runtimeApi } from '../services/apiService';
|
||||
import { FunctionDefinition, CreateFunctionRequest, UpdateFunctionRequest, RuntimeType } from '../types';
|
||||
|
||||
interface FunctionSidebarProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
editFunction?: FunctionDefinition;
|
||||
}
|
||||
|
||||
export const FunctionSidebar: React.FC<FunctionSidebarProps> = ({
|
||||
opened,
|
||||
onClose,
|
||||
onSuccess,
|
||||
editFunction,
|
||||
}) => {
|
||||
const isEditing = !!editFunction;
|
||||
const [runtimeOptions, setRuntimeOptions] = useState<Array<{value: string; label: string}>>([]);
|
||||
|
||||
// Default images for each runtime
|
||||
const DEFAULT_IMAGES: Record<string, string> = {
|
||||
'nodejs18': 'node:18-alpine',
|
||||
'python3.9': 'python:3.9-alpine',
|
||||
'go1.20': 'golang:1.20-alpine',
|
||||
};
|
||||
|
||||
// Map runtime to Monaco editor language
|
||||
const getEditorLanguage = (runtime: string): string => {
|
||||
const languageMap: Record<string, string> = {
|
||||
'nodejs18': 'javascript',
|
||||
'python3.9': 'python',
|
||||
'go1.20': 'go',
|
||||
};
|
||||
return languageMap[runtime] || 'javascript';
|
||||
};
|
||||
|
||||
// Get default code template based on runtime
|
||||
const getDefaultCode = (runtime: string): string => {
|
||||
const templates: Record<string, string> = {
|
||||
'nodejs18': `exports.handler = async (event, context) => {
|
||||
console.log('Event:', JSON.stringify(event, null, 2));
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: JSON.stringify({
|
||||
message: 'Hello from Node.js!',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
};
|
||||
};`,
|
||||
'python3.9': `import json
|
||||
from datetime import datetime
|
||||
|
||||
def handler(event, context):
|
||||
print('Event:', json.dumps(event, indent=2))
|
||||
|
||||
return {
|
||||
'statusCode': 200,
|
||||
'body': json.dumps({
|
||||
'message': 'Hello from Python!',
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
}`,
|
||||
'go1.20': `package main
|
||||
|
||||
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)
|
||||
|
||||
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),
|
||||
}, nil
|
||||
}`
|
||||
};
|
||||
return templates[runtime] || templates['nodejs18'];
|
||||
};
|
||||
|
||||
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' },
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
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',
|
||||
},
|
||||
});
|
||||
} 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',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 60, // Below header
|
||||
right: opened ? 0 : '-500px',
|
||||
bottom: 0,
|
||||
width: '500px',
|
||||
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 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')}
|
||||
/>
|
||||
|
||||
<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>
|
||||
</ScrollArea>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
241
kms/web/src/components/ApplicationSidebar.tsx
Normal file
241
kms/web/src/components/ApplicationSidebar.tsx
Normal file
@ -0,0 +1,241 @@
|
||||
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 { apiService, Application, CreateApplicationRequest } from '../services/apiService';
|
||||
|
||||
interface ApplicationSidebarProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
editingApp?: Application | null;
|
||||
}
|
||||
|
||||
const ApplicationSidebar: React.FC<ApplicationSidebarProps> = ({
|
||||
opened,
|
||||
onClose,
|
||||
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]?)$/);
|
||||
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
|
||||
}
|
||||
};
|
||||
|
||||
// Update form values when editingApp changes
|
||||
useEffect(() => {
|
||||
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',
|
||||
},
|
||||
});
|
||||
} 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',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApplicationSidebar;
|
||||
@ -29,12 +29,13 @@ import {
|
||||
import { useForm } from '@mantine/form';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { apiService, Application, CreateApplicationRequest } from '../services/apiService';
|
||||
import ApplicationSidebar from './ApplicationSidebar';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const Applications: React.FC = () => {
|
||||
const [applications, setApplications] = useState<Application[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = 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);
|
||||
@ -122,7 +123,7 @@ const Applications: React.FC = () => {
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
setModalOpen(false);
|
||||
setSidebarOpen(false);
|
||||
setEditingApp(null);
|
||||
form.reset();
|
||||
loadApplications();
|
||||
@ -148,7 +149,7 @@ const Applications: React.FC = () => {
|
||||
max_token_duration: `${app.max_token_duration / 3600}h`,
|
||||
owner: app.owner,
|
||||
});
|
||||
setModalOpen(true);
|
||||
setSidebarOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (appId: string) => {
|
||||
@ -247,7 +248,13 @@ const Applications: React.FC = () => {
|
||||
));
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Stack
|
||||
gap="lg"
|
||||
style={{
|
||||
transition: 'margin-right 0.3s ease',
|
||||
marginRight: sidebarOpen ? '450px' : '0',
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Title order={2} mb="xs">
|
||||
@ -259,7 +266,7 @@ const Applications: React.FC = () => {
|
||||
onClick={() => {
|
||||
setEditingApp(null);
|
||||
form.reset();
|
||||
setModalOpen(true);
|
||||
setSidebarOpen(true);
|
||||
}}
|
||||
>
|
||||
New Application
|
||||
@ -288,7 +295,7 @@ const Applications: React.FC = () => {
|
||||
onClick={() => {
|
||||
setEditingApp(null);
|
||||
form.reset();
|
||||
setModalOpen(true);
|
||||
setSidebarOpen(true);
|
||||
}}
|
||||
>
|
||||
Create Application
|
||||
@ -312,86 +319,17 @@ const Applications: React.FC = () => {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
<Modal
|
||||
opened={modalOpen}
|
||||
<ApplicationSidebar
|
||||
opened={sidebarOpen}
|
||||
onClose={() => {
|
||||
setModalOpen(false);
|
||||
setSidebarOpen(false);
|
||||
setEditingApp(null);
|
||||
form.reset();
|
||||
}}
|
||||
title={editingApp ? 'Edit Application' : 'Create New Application'}
|
||||
size="lg"
|
||||
>
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Application ID"
|
||||
placeholder="my-app-id"
|
||||
required
|
||||
{...form.getInputProps('app_id')}
|
||||
disabled={!!editingApp}
|
||||
/>
|
||||
|
||||
<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="subtle"
|
||||
onClick={() => {
|
||||
setModalOpen(false);
|
||||
setEditingApp(null);
|
||||
form.reset();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
{editingApp ? 'Update Application' : 'Create Application'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
onSuccess={() => {
|
||||
loadApplications();
|
||||
}}
|
||||
editingApp={editingApp}
|
||||
/>
|
||||
|
||||
{/* Detail Modal */}
|
||||
<Modal
|
||||
|
||||
293
kms/web/src/components/TokenSidebar.tsx
Normal file
293
kms/web/src/components/TokenSidebar.tsx
Normal file
@ -0,0 +1,293 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Paper,
|
||||
TextInput,
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
Title,
|
||||
ActionIcon,
|
||||
ScrollArea,
|
||||
Box,
|
||||
Select,
|
||||
Alert,
|
||||
Code,
|
||||
Divider,
|
||||
Text,
|
||||
Modal,
|
||||
} from '@mantine/core';
|
||||
import { IconX, IconCheck, IconCopy } from '@tabler/icons-react';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import PermissionTree from './PermissionTree';
|
||||
import {
|
||||
apiService,
|
||||
Application,
|
||||
CreateTokenRequest,
|
||||
CreateTokenResponse,
|
||||
} from '../services/apiService';
|
||||
|
||||
interface TokenSidebarProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
applications: Application[];
|
||||
}
|
||||
|
||||
const TokenSidebar: React.FC<TokenSidebarProps> = ({
|
||||
opened,
|
||||
onClose,
|
||||
onSuccess,
|
||||
applications,
|
||||
}) => {
|
||||
const [tokenModalOpen, setTokenModalOpen] = useState(false);
|
||||
const [createdToken, setCreatedToken] = useState<CreateTokenResponse | null>(null);
|
||||
|
||||
const form = useForm<CreateTokenRequest & { app_id: string }>({
|
||||
initialValues: {
|
||||
app_id: '',
|
||||
owner: {
|
||||
type: 'individual',
|
||||
name: 'Admin User',
|
||||
owner: 'admin@example.com',
|
||||
},
|
||||
permissions: [],
|
||||
},
|
||||
validate: {
|
||||
app_id: (value) => value.length < 1 ? 'Application is required' : null,
|
||||
permissions: (value) => value.length < 1 ? 'At least one permission is required' : null,
|
||||
},
|
||||
});
|
||||
|
||||
// Reset form when sidebar opens
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
form.reset();
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
const handleSubmit = async (values: CreateTokenRequest & { app_id: string }) => {
|
||||
try {
|
||||
const { app_id, ...tokenData } = values;
|
||||
const response = await apiService.createToken(app_id, tokenData);
|
||||
setCreatedToken(response);
|
||||
setTokenModalOpen(true);
|
||||
form.reset();
|
||||
onSuccess();
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Token created successfully',
|
||||
color: 'green',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to create token:', error);
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to create token',
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
notifications.show({
|
||||
title: 'Copied',
|
||||
message: 'Copied to clipboard',
|
||||
color: 'blue',
|
||||
});
|
||||
};
|
||||
|
||||
const handleTokenModalClose = () => {
|
||||
setTokenModalOpen(false);
|
||||
setCreatedToken(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
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}>
|
||||
Create New Token
|
||||
</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">
|
||||
<Select
|
||||
label="Application"
|
||||
placeholder="Select an application"
|
||||
required
|
||||
data={applications.map(app => ({
|
||||
value: app.app_id,
|
||||
label: `${app.app_id} (${app.type.join(', ')})`,
|
||||
}))}
|
||||
{...form.getInputProps('app_id')}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs">
|
||||
Required Permissions
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb="md">
|
||||
Select the permissions this token should have
|
||||
</Text>
|
||||
<PermissionTree
|
||||
permissions={form.values.permissions}
|
||||
onChange={(permissions) => form.setFieldValue('permissions', permissions)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TextInput
|
||||
label="Owner Name"
|
||||
placeholder="Token owner name"
|
||||
{...form.getInputProps('owner.name')}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Owner Email"
|
||||
placeholder="owner@example.com"
|
||||
{...form.getInputProps('owner.owner')}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button
|
||||
variant="light"
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={applications.length === 0}
|
||||
>
|
||||
Create Token
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Box>
|
||||
</ScrollArea>
|
||||
</Paper>
|
||||
|
||||
{/* Token Created Modal */}
|
||||
<Modal
|
||||
opened={tokenModalOpen}
|
||||
onClose={handleTokenModalClose}
|
||||
title="Token Created Successfully"
|
||||
size="lg"
|
||||
closeOnEscape={false}
|
||||
closeOnClickOutside={false}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Alert
|
||||
icon={<IconCheck size={16} />}
|
||||
title="Success!"
|
||||
color="green"
|
||||
>
|
||||
Your token has been created successfully. Please copy and store it securely as you won't be able to see it again.
|
||||
</Alert>
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs">
|
||||
Token:
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Code
|
||||
block
|
||||
style={{
|
||||
flex: 1,
|
||||
wordBreak: 'break-all',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{createdToken?.token}
|
||||
</Code>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
onClick={() => createdToken?.token && copyToClipboard(createdToken.token)}
|
||||
title="Copy Token"
|
||||
>
|
||||
<IconCopy size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
{createdToken?.prefix && (
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs">
|
||||
Token Prefix:
|
||||
</Text>
|
||||
<Code>{createdToken.prefix}</Code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs">
|
||||
Token Details:
|
||||
</Text>
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm">Token ID:</Text>
|
||||
<Code>{createdToken?.id}</Code>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm">Type:</Text>
|
||||
<Code>{createdToken?.type}</Code>
|
||||
</Group>
|
||||
{createdToken?.permissions && (
|
||||
<div>
|
||||
<Text size="sm" mb="xs">Permissions:</Text>
|
||||
<Group gap="xs">
|
||||
{createdToken.permissions.map((permission) => (
|
||||
<Code key={permission} size="xs">
|
||||
{permission}
|
||||
</Code>
|
||||
))}
|
||||
</Group>
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button onClick={handleTokenModalClose}>
|
||||
Done
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TokenSidebar;
|
||||
@ -38,6 +38,7 @@ import {
|
||||
CreateTokenRequest,
|
||||
CreateTokenResponse,
|
||||
} from '../services/apiService';
|
||||
import TokenSidebar from './TokenSidebar';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
interface TokenWithApp extends StaticToken {
|
||||
@ -48,7 +49,7 @@ const Tokens: React.FC = () => {
|
||||
const [applications, setApplications] = useState<Application[]>([]);
|
||||
const [tokens, setTokens] = useState<TokenWithApp[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [tokenModalOpen, setTokenModalOpen] = useState(false);
|
||||
const [createdToken, setCreatedToken] = useState<CreateTokenResponse | null>(null);
|
||||
|
||||
@ -134,7 +135,7 @@ const Tokens: React.FC = () => {
|
||||
const { app_id, ...tokenData } = values;
|
||||
const response = await apiService.createToken(app_id, tokenData);
|
||||
setCreatedToken(response);
|
||||
setModalOpen(false);
|
||||
setSidebarOpen(false);
|
||||
setTokenModalOpen(true);
|
||||
form.reset();
|
||||
loadAllTokens();
|
||||
@ -237,7 +238,13 @@ const Tokens: React.FC = () => {
|
||||
));
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Stack
|
||||
gap="lg"
|
||||
style={{
|
||||
transition: 'margin-right 0.3s ease',
|
||||
marginRight: sidebarOpen ? '450px' : '0',
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Title order={2} mb="xs">
|
||||
@ -248,7 +255,7 @@ const Tokens: React.FC = () => {
|
||||
leftSection={<IconPlus size={16} />}
|
||||
onClick={() => {
|
||||
form.reset();
|
||||
setModalOpen(true);
|
||||
setSidebarOpen(true);
|
||||
}}
|
||||
disabled={applications.length === 0}
|
||||
>
|
||||
@ -288,7 +295,7 @@ const Tokens: React.FC = () => {
|
||||
leftSection={<IconPlus size={16} />}
|
||||
onClick={() => {
|
||||
form.reset();
|
||||
setModalOpen(true);
|
||||
setSidebarOpen(true);
|
||||
}}
|
||||
>
|
||||
Create Token
|
||||
@ -314,61 +321,17 @@ const Tokens: React.FC = () => {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Create Token Modal */}
|
||||
<Modal
|
||||
opened={modalOpen}
|
||||
<TokenSidebar
|
||||
opened={sidebarOpen}
|
||||
onClose={() => {
|
||||
setModalOpen(false);
|
||||
setSidebarOpen(false);
|
||||
form.reset();
|
||||
}}
|
||||
title="Create New Token"
|
||||
size="lg"
|
||||
>
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label="Application"
|
||||
placeholder="Select an application"
|
||||
required
|
||||
data={applications.map(app => ({
|
||||
value: app.app_id,
|
||||
label: `${app.app_id} (${app.type.join(', ')})`,
|
||||
}))}
|
||||
{...form.getInputProps('app_id')}
|
||||
/>
|
||||
|
||||
<PermissionTree
|
||||
permissions={form.values.permissions}
|
||||
onChange={(permissions) => form.setFieldValue('permissions', permissions)}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Owner Name"
|
||||
placeholder="Token owner name"
|
||||
{...form.getInputProps('owner.name')}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Owner Email"
|
||||
placeholder="owner@example.com"
|
||||
{...form.getInputProps('owner.owner')}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
setModalOpen(false);
|
||||
form.reset();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">Create Token</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
onSuccess={() => {
|
||||
loadAllTokens();
|
||||
}}
|
||||
applications={applications}
|
||||
/>
|
||||
|
||||
{/* Token Created Modal */}
|
||||
<Modal
|
||||
|
||||
3
user/web/.gitignore
vendored
Normal file
3
user/web/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
dist
|
||||
node_modules
|
||||
|
||||
@ -24,7 +24,7 @@ import {
|
||||
} from '@tabler/icons-react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { modals } from '@mantine/modals';
|
||||
import UserForm from './UserForm';
|
||||
import UserSidebar from './UserSidebar';
|
||||
import { userService } from '../services/userService';
|
||||
import { User, UserStatus, UserRole, ListUsersRequest } from '../types/user';
|
||||
|
||||
@ -36,7 +36,7 @@ const UserManagement: React.FC = () => {
|
||||
const [roleFilter, setRoleFilter] = useState<string | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalUsers, setTotalUsers] = useState(0);
|
||||
const [userFormOpened, setUserFormOpened] = useState(false);
|
||||
const [userSidebarOpened, setUserSidebarOpened] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||
|
||||
const pageSize = 10;
|
||||
@ -76,12 +76,12 @@ const UserManagement: React.FC = () => {
|
||||
|
||||
const handleCreateUser = () => {
|
||||
setEditingUser(null);
|
||||
setUserFormOpened(true);
|
||||
setUserSidebarOpened(true);
|
||||
};
|
||||
|
||||
const handleEditUser = (user: User) => {
|
||||
setEditingUser(user);
|
||||
setUserFormOpened(true);
|
||||
setUserSidebarOpened(true);
|
||||
};
|
||||
|
||||
const handleDeleteUser = (user: User) => {
|
||||
@ -115,12 +115,12 @@ const UserManagement: React.FC = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleUserFormSuccess = () => {
|
||||
const handleUserSidebarSuccess = () => {
|
||||
loadUsers();
|
||||
};
|
||||
|
||||
const handleUserFormClose = () => {
|
||||
setUserFormOpened(false);
|
||||
const handleUserSidebarClose = () => {
|
||||
setUserSidebarOpened(false);
|
||||
setEditingUser(null);
|
||||
};
|
||||
|
||||
@ -149,7 +149,13 @@ const UserManagement: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
{/* Main user management interface */}
|
||||
<Stack gap="md">
|
||||
<Stack
|
||||
gap="md"
|
||||
style={{
|
||||
transition: 'margin-right 0.3s ease',
|
||||
marginRight: userSidebarOpened ? '400px' : '0',
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<Button leftSection={<IconPlus size={16} />} onClick={handleCreateUser}>
|
||||
Add User
|
||||
@ -296,11 +302,11 @@ const UserManagement: React.FC = () => {
|
||||
</Paper>
|
||||
</Stack>
|
||||
|
||||
{/* User form modal */}
|
||||
<UserForm
|
||||
opened={userFormOpened}
|
||||
onClose={handleUserFormClose}
|
||||
onSuccess={handleUserFormSuccess}
|
||||
{/* User sidebar */}
|
||||
<UserSidebar
|
||||
opened={userSidebarOpened}
|
||||
onClose={handleUserSidebarClose}
|
||||
onSuccess={handleUserSidebarSuccess}
|
||||
editUser={editingUser}
|
||||
/>
|
||||
</>
|
||||
|
||||
255
user/web/src/components/UserSidebar.tsx
Normal file
255
user/web/src/components/UserSidebar.tsx
Normal file
@ -0,0 +1,255 @@
|
||||
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 { userService } from '../services/userService';
|
||||
import { User, CreateUserRequest, UpdateUserRequest, UserRole, UserStatus } from '../types/user';
|
||||
|
||||
interface UserSidebarProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
editUser?: User | null;
|
||||
}
|
||||
|
||||
const UserSidebar: React.FC<UserSidebarProps> = ({
|
||||
opened,
|
||||
onClose,
|
||||
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,
|
||||
},
|
||||
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),
|
||||
},
|
||||
});
|
||||
|
||||
// Update form values when editUser changes
|
||||
useEffect(() => {
|
||||
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,
|
||||
});
|
||||
} 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',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserSidebar;
|
||||
Reference in New Issue
Block a user