chore: full stack stability and migration fixes, plus react UI progress
Some checks failed
CI / podman-build (push) Has been cancelled
CI / rust (push) Has been cancelled

This commit is contained in:
2026-03-18 09:01:38 +02:00
parent 38cab8c246
commit a66d908eff
142 changed files with 12210 additions and 3402 deletions

View File

@@ -0,0 +1 @@
body{margin:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Fira Sans,Droid Sans,Helvetica Neue,sans-serif;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}code{font-family:source-code-pro,Menlo,Monaco,Consolas,Courier New,monospace}*{box-sizing:border-box}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

14
control-plane-ui/dist/index.html vendored Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MadBase Control Plane</title>
<script type="module" crossorigin src="/assets/index-BQQesDFl.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BKEdzEjZ.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -3664,6 +3664,21 @@
"dev": true,
"license": "ISC"
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -6792,6 +6807,21 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/vite/node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/vitest": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz",

View File

@@ -5,6 +5,12 @@ import CssBaseline from '@mui/material/CssBaseline'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import Layout from './components/Layout'
import Dashboard from './pages/Dashboard'
import Auth from './pages/Auth'
import Storage from './pages/Storage'
import Database from './pages/Database'
import Functions from './pages/Functions'
import Realtime from './pages/Realtime'
import Logs from './pages/Logs'
import Servers from './pages/Servers'
import Templates from './pages/Templates'
import Providers from './pages/Providers'
@@ -21,21 +27,7 @@ const queryClient = new QueryClient({
},
})
const darkTheme = createTheme({
palette: {
mode: 'dark',
primary: {
main: '#00bcd4',
},
secondary: {
main: '#ff9800',
},
background: {
default: '#0a1929',
paper: '#1a2932',
},
},
})
import { darkTheme } from './theme'
function App() {
return (
@@ -46,6 +38,12 @@ function App() {
<Layout>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/auth" element={<Auth />} />
<Route path="/storage" element={<Storage />} />
<Route path="/database" element={<Database />} />
<Route path="/functions" element={<Functions />} />
<Route path="/realtime" element={<Realtime />} />
<Route path="/logs" element={<Logs />} />
<Route path="/servers" element={<Servers />} />
<Route path="/templates" element={<Templates />} />
<Route path="/providers" element={<Providers />} />

View File

@@ -0,0 +1,183 @@
import {
Card,
CardContent,
Typography,
Box,
LinearProgress,
IconButton,
Tooltip,
Chip,
useTheme,
alpha,
Grid,
Divider,
} from '@mui/material'
import {
TrendingUp as ScaleUpIcon,
TrendingDown as ScaleDownIcon,
Settings as SettingsIcon,
CheckCircle as OnlineIcon,
Sync as ScalingIcon,
Error as ErrorIcon,
} from '@mui/icons-material'
import { ResponsiveContainer, AreaChart, Area } from 'recharts'
import { PillarStats } from '../../hooks/usePillars'
interface PillarCardProps {
stats: PillarStats
onScale?: (pillar: string, action: 'up' | 'down') => void
}
const mockChartData = [
{ val: 40 }, { val: 30 }, { val: 45 }, { val: 60 }, { val: 55 }, { val: 70 }, { val: 85 }
]
export default function PillarCard({ stats, onScale }: PillarCardProps) {
const theme = useTheme()
const isScaling = stats.is_scaling
const hasSuggestion = stats.suggestion && stats.suggestion.action !== 'none'
return (
<Card
sx={{
height: '100%',
position: 'relative',
overflow: 'hidden',
background: `linear-gradient(135deg, ${alpha(theme.palette.background.paper, 0.9)} 0%, ${alpha(theme.palette.background.paper, 0.7)} 100%)`,
backdropFilter: 'blur(10px)',
border: `1px solid ${alpha(theme.palette.divider, 0.1)}`,
transition: 'transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out',
'&:hover': {
transform: 'translateY(-4px)',
boxShadow: `0 8px 24px ${alpha(theme.palette.common.black, 0.3)}`,
border: `1px solid ${alpha(theme.palette.primary.main, 0.3)}`,
}
}}
>
{/* Scaling Animation Overlay */}
{isScaling && (
<LinearProgress
sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 4,
'& .MuiLinearProgress-bar': {
backgroundImage: `linear-gradient(90deg, ${theme.palette.primary.main}, ${theme.palette.secondary.main})`
}
}}
/>
)}
<CardContent sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
<Box>
<Typography variant="overline" color="text.secondary" sx={{ fontWeight: 'bold', letterSpacing: 1 }}>
{stats.pillar}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', mt: 0.5 }}>
<Typography variant="h4" sx={{ fontWeight: 800, mr: 1 }}>
{stats.active_count}
</Typography>
<Typography variant="body2" color="text.secondary">
/ {stats.node_count} nodes
</Typography>
</Box>
</Box>
<Chip
icon={isScaling ? <ScalingIcon sx={{ animation: 'spin 2s linear infinite' }} /> : <OnlineIcon />}
label={isScaling ? 'Scaling' : 'Online'}
size="small"
color={isScaling ? 'primary' : 'success'}
variant="outlined"
sx={{
fontWeight: 'bold',
'@keyframes spin': {
'0%': { transform: 'rotate(0deg)' },
'100%': { transform: 'rotate(360deg)' }
}
}}
/>
</Box>
{/* Mini Sparkline */}
<Box sx={{ height: 60, width: '100%', mb: 2, opacity: 0.8 }}>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={mockChartData}>
<defs>
<linearGradient id={`grad-${stats.pillar}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={theme.palette.primary.main} stopOpacity={0.3}/>
<stop offset="95%" stopColor={theme.palette.primary.main} stopOpacity={0}/>
</linearGradient>
</defs>
<Area
type="monotone"
dataKey="val"
stroke={theme.palette.primary.main}
strokeWidth={2}
fillOpacity={1}
fill={`url(#grad-${stats.pillar})`}
/>
</AreaChart>
</ResponsiveContainer>
</Box>
{/* Metrics Gauges */}
<Grid container spacing={2} sx={{ mb: 2 }}>
<Grid item xs={6}>
<Typography variant="caption" color="text.secondary" display="block">CPU Load</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<LinearProgress
variant="determinate"
value={stats.metrics?.cpu_usage || 12}
sx={{ flexGrow: 1, height: 6, borderRadius: 3 }}
color={ (stats.metrics?.cpu_usage || 12) > 80 ? 'error' : 'primary'}
/>
<Typography variant="caption" sx={{ minWidth: 25 }}>{stats.metrics?.cpu_usage || 12}%</Typography>
</Box>
</Grid>
<Grid item xs={6}>
<Typography variant="caption" color="text.secondary" display="block">Memory</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<LinearProgress
variant="determinate"
value={stats.metrics?.memory_usage || 45}
sx={{ flexGrow: 1, height: 6, borderRadius: 3 }}
color="secondary"
/>
<Typography variant="caption" sx={{ minWidth: 25 }}>{stats.metrics?.memory_usage || 45}%</Typography>
</Box>
</Grid>
</Grid>
{/* Suggestion & Actions */}
<Divider sx={{ my: 1.5, opacity: 0.5 }} />
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
{hasSuggestion ? (
<Tooltip title={stats.suggestion?.reason}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: theme.palette.warning.main }}>
<ErrorIcon fontSize="inherit" />
<Typography variant="caption" sx={{ fontWeight: 'bold' }}>
Sug: {stats.suggestion?.action === 'scale_up' ? '+' : '-'}{Math.abs(stats.suggestion!.target_count - stats.node_count)} nodes
</Typography>
</Box>
</Tooltip>
) : (
<Typography variant="caption" color="text.secondary">Optimal Performance</Typography>
)}
<Box>
<IconButton size="small" onClick={() => onScale?.(stats.pillar, 'down')} disabled={isScaling || stats.node_count <= 1}>
<ScaleDownIcon fontSize="small" />
</IconButton>
<IconButton size="small" color="primary" onClick={() => onScale?.(stats.pillar, 'up')} disabled={isScaling}>
<ScaleUpIcon fontSize="small" />
</IconButton>
</Box>
</Box>
</CardContent>
</Card>
)
}

View File

@@ -23,6 +23,12 @@ import {
TrendingUp as ScalingIcon,
Favorite as HealthIcon,
Settings as SettingsIcon,
People as UsersIcon,
Folder as StorageIcon,
TableChart as DatabaseIcon,
Functions as FunctionsIcon,
Bolt as RealtimeIcon,
Article as LogsIcon,
} from '@mui/icons-material'
import { useNavigate, useLocation } from 'react-router-dom'
@@ -30,6 +36,12 @@ const drawerWidth = 240
const menuItems = [
{ text: 'Dashboard', icon: <DashboardIcon />, path: '/' },
{ text: 'Users', icon: <UsersIcon />, path: '/auth' },
{ text: 'Storage', icon: <StorageIcon />, path: '/storage' },
{ text: 'Database', icon: <DatabaseIcon />, path: '/database' },
{ text: 'Functions', icon: <FunctionsIcon />, path: '/functions' },
{ text: 'Realtime', icon: <RealtimeIcon />, path: '/realtime' },
{ text: 'Logs', icon: <LogsIcon />, path: '/logs' },
{ text: 'Servers', icon: <ServerIcon />, path: '/servers' },
{ text: 'Templates', icon: <TemplateIcon />, path: '/templates' },
{ text: 'Providers', icon: <ProviderIcon />, path: '/providers' },

View File

@@ -0,0 +1,145 @@
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Typography,
Box,
Divider,
List,
ListItem,
ListItemIcon,
ListItemText,
Alert,
useTheme,
alpha,
Grid,
CircularProgress,
} from '@mui/material'
import {
AddCircle as AddIcon,
RemoveCircle as RemoveIcon,
AttachMoney as CostIcon,
AccessTime as TimeIcon,
Dns as ServerIcon,
} from '@mui/icons-material'
import { ScalingPlan } from '../../services/api'
interface ScalingConfirmationDialogProps {
open: boolean
onClose: () => void
onConfirm: () => void
plan: ScalingPlan | null
loading: boolean
}
export default function ScalingConfirmationDialog({
open,
onClose,
onConfirm,
plan,
loading
}: ScalingConfirmationDialogProps) {
const theme = useTheme()
if (!plan) return null
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
PaperProps={{
sx: {
bgcolor: alpha(theme.palette.background.paper, 0.95),
backdropFilter: 'blur(10px)',
backgroundImage: 'none',
border: `1px solid ${alpha(theme.palette.divider, 0.1)}`,
}
}}
>
<DialogTitle sx={{ pb: 1 }}>
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>Review Scaling Plan</Typography>
<Typography variant="body2" color="text.secondary">
Confirm the infrastructure changes before execution.
</Typography>
</DialogTitle>
<DialogContent>
<Box sx={{ mb: 3, p: 2, bgcolor: alpha(theme.palette.primary.main, 0.05), borderRadius: 2, border: `1px solid ${alpha(theme.palette.primary.main, 0.2)}` }}>
<Grid container spacing={2}>
<Grid item xs={6}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CostIcon color="primary" fontSize="small" />
<Box>
<Typography variant="caption" color="text.secondary" uppercase>Monthly Impact</Typography>
<Typography variant="h6" sx={{ color: theme.palette.primary.main, fontWeight: 'bold', lineHeight: 1 }}>
+{plan.total_cost_monthly.toFixed(2)}
</Typography>
</Box>
</Box>
</Grid>
<Grid item xs={6}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<TimeIcon color="secondary" fontSize="small" />
<Box>
<Typography variant="caption" color="text.secondary" uppercase>Estimated Time</Typography>
<Typography variant="h6" sx={{ fontWeight: 'bold', lineHeight: 1 }}>
~{plan.estimated_time_minutes} min
</Typography>
</Box>
</Box>
</Grid>
</Grid>
</Box>
<Typography variant="subtitle2" gutterBottom sx={{ fontWeight: 'bold' }}>Steps to execute:</Typography>
<List dense disablePadding>
{plan.scaling_plan.map((step, index) => (
<ListItem key={index} sx={{ px: 0, py: 1 }}>
<ListItemIcon sx={{ minWidth: 40 }}>
{step.action.toLowerCase().includes('add') ? (
<AddIcon color="success" />
) : (
<RemoveIcon color="error" />
)}
</ListItemIcon>
<ListItemText
primary={`${step.action}: ${step.count}x ${step.template}`}
secondary={`${step.provider} ${step.plan} | €${step.total_cost.toFixed(2)}/mo total`}
primaryTypographyProps={{ variant: 'body2', fontWeight: 500 }}
secondaryTypographyProps={{ variant: 'caption' }}
/>
</ListItem>
))}
</List>
<Alert severity="warning" sx={{ mt: 3, bgcolor: alpha(theme.palette.warning.main, 0.1), border: `1px solid ${alpha(theme.palette.warning.main, 0.2)}` }}>
New nodes will be immediately provisioned and linked to the gateway. Resource allocation may take up to 2 minutes per node.
</Alert>
</DialogContent>
<DialogActions sx={{ p: 2.5, pt: 0 }}>
<Button onClick={onClose} disabled={loading} sx={{ color: 'text.secondary' }}>
Back to Config
</Button>
<Button
variant="contained"
onClick={onConfirm}
disabled={loading}
sx={{
px: 4,
py: 1,
fontWeight: 'bold',
boxShadow: `0 8px 16px ${alpha(theme.palette.primary.main, 0.3)}`
}}
>
{loading ? <CircularProgress size={24} color="inherit" /> : 'Confirm & Execute'}
</Button>
</DialogActions>
</Dialog>
)
}

View File

@@ -0,0 +1,21 @@
import { useQuery } from '@tanstack/react-query'
import { apiService, DbTable } from '../services/api'
export function useDatabase() {
const tablesQuery = useQuery({
queryKey: ['tables'],
queryFn: () => apiService.getTables().then(res => res.data),
})
const useTableData = (schema: string | null, name: string | null) => useQuery({
queryKey: ['tableData', schema, name],
queryFn: () => (schema && name) ? apiService.getTableData(schema, name).then(res => res.data) : Promise.resolve([]),
enabled: !!(schema && name),
})
return {
tables: tablesQuery.data || [],
isLoadingTables: tablesQuery.isLoading,
useTableData,
}
}

View File

@@ -0,0 +1,26 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiService, EdgeFunction } from '../services/api'
export function useFunctions() {
const queryClient = useQueryClient()
const functionsQuery = useQuery({
queryKey: ['functions'],
queryFn: () => apiService.getFunctions().then(res => res.data),
})
const deployFunctionMutation = useMutation({
mutationFn: (data: { name: string; runtime: string; code_base64: string }) =>
apiService.deployFunction(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['functions'] })
},
})
return {
functions: functionsQuery.data || [],
isLoadingFunctions: functionsQuery.isLoading,
deployFunction: deployFunctionMutation.mutate,
isDeploying: deployFunctionMutation.isPending,
}
}

View File

@@ -0,0 +1,18 @@
import { useQuery } from '@tanstack/react-query'
import { apiService } from '../services/api'
export function useLogs(query: string = '', limit: number = 50) {
const logsQuery = useQuery({
queryKey: ['logs', query, limit],
queryFn: () => apiService.getLogs({ query, limit }).then(res => res.data),
refetchInterval: 10000, // Poll every 10s
enabled: true,
})
return {
logs: logsQuery.data || [],
isLoading: logsQuery.isLoading,
isRefetching: logsQuery.isRefetching,
refetch: logsQuery.refetch,
}
}

View File

@@ -0,0 +1,33 @@
import { useQuery } from '@tanstack/react-query'
import { apiService } from '../services/api'
export interface PillarStats {
pillar: string
node_count: number
active_count: number
is_scaling: boolean
metrics?: {
cpu_usage?: number
memory_usage?: number
request_rate?: number
}
suggestion?: {
action: 'scale_up' | 'scale_down' | 'none'
reason: string
target_count: number
}
}
export function usePillars() {
const pillarsQuery = useQuery({
queryKey: ['pillars'],
queryFn: () => apiService.getPillars().then(res => res.data),
refetchInterval: 5000, // Refresh every 5s for live scaling status
})
return {
pillars: (pillarsQuery.data as PillarStats[]) || [],
isLoading: pillarsQuery.isLoading,
error: pillarsQuery.error,
}
}

View File

@@ -0,0 +1,82 @@
import { useState, useEffect, useRef } from 'react'
export interface RealtimeMessage {
id: string
timestamp: string
type: 'IN' | 'OUT' | 'SYS'
payload: any
channel?: string
}
export function useRealtime() {
const [messages, setMessages] = useState<RealtimeMessage[]>([])
const [isConnected, setIsConnected] = useState(false)
const ws = useRef<WebSocket | null>(null)
useEffect(() => {
// Determine WS URL based on current host
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = window.location.host
const url = `${protocol}//${host}/realtime/v1`
const connect = () => {
ws.current = new WebSocket(url)
ws.current.onopen = () => {
setIsConnected(true)
addMessage({ type: 'SYS', payload: 'Connected to Realtime Gateway' })
}
ws.current.onclose = () => {
setIsConnected(false)
addMessage({ type: 'SYS', payload: 'Disconnected from Realtime Gateway. Retrying...' })
setTimeout(connect, 3000)
}
ws.current.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
addMessage({ type: 'IN', payload: data })
} catch (e) {
addMessage({ type: 'IN', payload: event.data })
}
}
}
const addMessage = (msg: Omit<RealtimeMessage, 'id' | 'timestamp'>) => {
setMessages(prev => [
{
...msg,
id: Math.random().toString(36).substr(2, 9),
timestamp: new Date().toISOString(),
},
...prev.slice(0, 99) // Keep last 100 messages
])
}
connect()
return () => {
if (ws.current) ws.current.close()
}
}, [])
const sendMessage = (payload: any) => {
if (ws.current && isConnected) {
ws.current.send(JSON.stringify(payload))
setMessages(prev => [
{
id: Math.random().toString(36).substr(2, 9),
timestamp: new Date().toISOString(),
type: 'OUT',
payload
},
...prev
])
}
}
const clearMessages = () => setMessages([])
return { messages, isConnected, sendMessage, clearMessages }
}

View File

@@ -0,0 +1,33 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiService, Bucket, StorageObject } from '../services/api'
export function useStorage() {
const queryClient = useQueryClient()
const bucketsQuery = useQuery({
queryKey: ['buckets'],
queryFn: () => apiService.getBuckets().then(res => res.data),
})
const useObjects = (bucketId: string | null) => useQuery({
queryKey: ['objects', bucketId],
queryFn: () => bucketId ? apiService.getBucketObjects(bucketId).then(res => res.data) : Promise.resolve([]),
enabled: !!bucketId,
})
const deleteObjectMutation = useMutation({
mutationFn: ({ bucketId, name }: { bucketId: string; name: string }) =>
apiService.deleteObject(bucketId, name),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['objects', variables.bucketId] })
},
})
return {
buckets: bucketsQuery.data || [],
isLoadingBuckets: bucketsQuery.isLoading,
useObjects,
deleteObject: deleteObjectMutation.mutate,
isDeletingObject: deleteObjectMutation.isPending,
}
}

View File

@@ -0,0 +1,26 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiService, AdminUser } from '../services/api'
export function useUsers() {
const queryClient = useQueryClient()
const usersQuery = useQuery({
queryKey: ['users'],
queryFn: () => apiService.getUsers().then(res => res.data),
})
const deleteUserMutation = useMutation({
mutationFn: (id: string) => apiService.deleteUser(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
},
})
return {
users: usersQuery.data || [],
isLoading: usersQuery.isLoading,
error: usersQuery.error,
deleteUser: deleteUserMutation.mutate,
isDeleting: deleteUserMutation.isPending,
}
}

View File

@@ -0,0 +1,180 @@
import React, { useState } from 'react'
import {
Box,
Typography,
Paper,
Button,
TextField,
InputAdornment,
IconButton,
Chip,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Alert,
CircularProgress,
} from '@mui/material'
import {
Search as SearchIcon,
Delete as DeleteIcon,
Refresh as RefreshIcon,
Person as PersonIcon,
} from '@mui/icons-material'
import { DataGrid, GridColDef } from '@mui/x-data-grid'
import { useUsers } from '../hooks/useUsers'
import { AdminUser } from '../services/api'
export default function Auth() {
const { users, isLoading, error, deleteUser, isDeleting } = useUsers()
const [searchTerm, setSearchTerm] = useState('')
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const [userToDelete, setUserToDelete] = useState<AdminUser | null>(null)
const filteredUsers = users.filter((user) =>
user.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
user.id.toLowerCase().includes(searchTerm.toLowerCase())
)
const handleDeleteClick = (user: AdminUser) => {
setUserToDelete(user)
setDeleteConfirmOpen(true)
}
const handleConfirmDelete = () => {
if (userToDelete) {
deleteUser(userToDelete.id)
setDeleteConfirmOpen(false)
setUserToDelete(null)
}
}
const columns: GridColDef[] = [
{
field: 'email',
headerName: 'Email',
flex: 1,
renderCell: (params) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<PersonIcon color="action" fontSize="small" />
<Typography variant="body2">{params.value}</Typography>
</Box>
),
},
{ field: 'id', headerName: 'User ID', width: 220 },
{
field: 'created_at',
headerName: 'Created At',
width: 200,
valueGetter: (params) => new Date(params.row.created_at).toLocaleString(),
},
{
field: 'actions',
headerName: 'Actions',
width: 120,
renderCell: (params) => (
<IconButton
size="small"
color="error"
onClick={() => handleDeleteClick(params.row as AdminUser)}
>
<DeleteIcon fontSize="small" />
</IconButton>
),
},
]
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Box>
<Typography variant="h4" gutterBottom>
User Management
</Typography>
<Typography variant="body2" color="text.secondary">
Manage your project's authenticated users
</Typography>
</Box>
<Button
variant="outlined"
startIcon={<RefreshIcon />}
onClick={() => window.location.reload()}
>
Refresh
</Button>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
Failed to fetch users. Please make sure the backend is running.
</Alert>
)}
<Paper sx={{ p: 2, mb: 3, display: 'flex', alignItems: 'center', gap: 2 }}>
<TextField
size="small"
placeholder="Search users by email or ID..."
fullWidth
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" />
</InputAdornment>
),
}}
/>
<Chip label={`${filteredUsers.length} Users`} color="primary" variant="outlined" />
</Paper>
<Paper sx={{ height: 600, width: '100%' }}>
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<CircularProgress />
</Box>
) : (
<DataGrid
rows={filteredUsers}
columns={columns}
getRowId={(row) => row.id}
pageSizeOptions={[10, 25, 50]}
initialState={{
pagination: {
paginationModel: { pageSize: 10 },
},
}}
disableRowSelectionOnClick
sx={{
'& .MuiDataGrid-cell:focus': {
outline: 'none',
},
}}
/>
)}
</Paper>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteConfirmOpen} onClose={() => setDeleteConfirmOpen(false)}>
<DialogTitle>Delete User?</DialogTitle>
<DialogContent>
<Typography>
Are you sure you want to delete user <strong>{userToDelete?.email}</strong>?
This action cannot be undone and will revoke all access for this user.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteConfirmOpen(false)}>Cancel</Button>
<Button
onClick={handleConfirmDelete}
color="error"
variant="contained"
disabled={isDeleting}
>
{isDeleting ? 'Deleting...' : 'Delete User'}
</Button>
</DialogActions>
</Dialog>
</Box>
)
}

View File

@@ -1,4 +1,4 @@
import React from 'react'
import { useQuery } from '@tanstack/react-query'
import {
Box,

View File

@@ -1,114 +1,154 @@
import React from 'react'
import { useQuery } from '@tanstack/react-query'
import {
Box,
Typography,
Grid,
Paper,
Typography,
Card,
CardContent,
LinearProgress,
Divider,
Button,
useTheme,
alpha,
CircularProgress,
Alert,
Chip,
} from '@mui/material'
import {
Dns as ServerIcon,
TrendingUp as ScalingIcon,
Favorite as HealthIcon,
AttachMoney as CostIcon,
Speed as PerformanceIcon,
Timeline as ActivityIcon,
AddCircleOutline as PlusIcon,
} from '@mui/icons-material'
import { apiService, ClusterHealth } from '@/services/api'
import { useNavigate } from 'react-router-dom'
import { usePillars } from '../hooks/usePillars'
import { useQuery } from '@tanstack/react-query'
import { apiService } from '../services/api'
import PillarCard from '../components/Dashboard/PillarCard'
export default function Dashboard() {
const { data: health, isLoading } = useQuery({
const theme = useTheme()
const navigate = useNavigate()
const { pillars, isLoading: isLoadingPillars, error: pillarError } = usePillars()
const { data: health, isLoading: isLoadingHealth } = useQuery({
queryKey: ['clusterHealth'],
queryFn: () => apiService.getClusterHealth().then((res) => res.data),
})
if (isLoading) {
return <LinearProgress />
const handleQuickScale = (pillar: string, action: 'up' | 'down') => {
// Navigate to scaling page with pre-filled parameters or open quick-dialog
navigate(`/scaling?pillar=${pillar}&action=${action}`)
}
const stats = [
{
title: 'Total Servers',
value: health?.total_servers || 0,
icon: <ServerIcon sx={{ fontSize: 40 }} />,
color: health?.healthy ? '#4caf50' : '#f44336',
},
{
title: 'Active Servers',
value: health?.active_servers || 0,
icon: <ServerIcon sx={{ fontSize: 40 }} />,
color: '#2196f3',
},
{
title: 'Services Running',
value: health?.services_up || 0,
icon: <HealthIcon sx={{ fontSize: 40 }} />,
color: '#ff9800',
},
{
title: 'Cluster Health',
value: health?.healthy ? 'Healthy' : 'Unhealthy',
icon: <HealthIcon sx={{ fontSize: 40 }} />,
color: health?.healthy ? '#4caf50' : '#f44336',
},
]
return (
<Box>
<Typography variant="h4" gutterBottom>
Dashboard
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
Overview of your MadBase infrastructure
</Typography>
<Box sx={{ maxWidth: 1400, mx: 'auto' }}>
{/* Header Section */}
<Box sx={{ mb: 4, display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end' }}>
<Box>
<Typography variant="h3" sx={{ fontWeight: 900, mb: 1, letterSpacing: -1 }}>
Infrastructure
</Typography>
<Typography variant="body1" color="text.secondary">
Real-time status and scaling controls for your MadBase cluster.
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 2 }}>
<Button
variant="outlined"
startIcon={<ActivityIcon />}
onClick={() => navigate('/logs')}
>
System Logs
</Button>
<Button
variant="contained"
startIcon={<PlusIcon />}
onClick={() => navigate('/servers')}
>
Provision Node
</Button>
</Box>
</Box>
<Grid container spacing={3} sx={{ mt: 2 }}>
{stats.map((stat, index) => (
<Grid item xs={12} sm={6} md={3} key={index}>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Box sx={{ color: stat.color, mr: 2 }}>{stat.icon}</Box>
<Typography variant="h6" color="text.secondary">
{stat.title}
</Typography>
</Box>
<Typography variant="h3" sx={{ color: stat.color }}>
{stat.value}
</Typography>
</CardContent>
</Card>
</Grid>
))}
{pillarError && (
<Alert severity="error" sx={{ mb: 3 }}>
Connection lost to Control Plane. Retrying...
</Alert>
)}
<Grid item xs={12} md={6}>
<Paper sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>
Quick Actions
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography variant="body2" color="text.secondary">
Add a new server
</Typography>
<Typography variant="body2" color="text.secondary">
Scale cluster
</Typography>
<Typography variant="body2" color="text.secondary">
View cluster health
</Typography>
{/* Pillar Grid */}
<Typography variant="h6" sx={{ mb: 2, fontWeight: 'bold', display: 'flex', alignItems: 'center', gap: 1 }}>
<PerformanceIcon color="primary" /> System Pillars
</Typography>
{isLoadingPillars ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 10 }}>
<CircularProgress />
</Box>
) : (
<Grid container spacing={3} sx={{ mb: 6 }}>
{pillars.map((pillar) => (
<Grid item xs={12} sm={6} md={3} key={pillar.pillar}>
<PillarCard stats={pillar} onScale={handleQuickScale} />
</Grid>
))}
{pillars.length === 0 && (
<Grid item xs={12}>
<Paper sx={{ p: 5, textAlign: 'center', border: '2px dashed', borderColor: 'divider', bgcolor: alpha(theme.palette.background.paper, 0.4) }}>
<Typography color="text.secondary">No pillars detected. Check server configuration.</Typography>
</Paper>
</Grid>
)}
</Grid>
)}
<Grid container spacing={3}>
{/* Cluster Pulse / Stats */}
<Grid item xs={12} md={8}>
<Paper sx={{ p: 3, height: '100%', bgcolor: alpha(theme.palette.background.paper, 0.6) }}>
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>Cluster Pulse</Typography>
<Divider sx={{ mb: 3 }} />
<Grid container spacing={4}>
<Grid item xs={12} sm={4}>
<Typography variant="caption" color="text.secondary" uppercase>Global Availability</Typography>
<Typography variant="h4" sx={{ fontWeight: 800, color: theme.palette.success.main }}>99.99%</Typography>
</Grid>
<Grid item xs={12} sm={4}>
<Typography variant="caption" color="text.secondary" uppercase>Total Throughput</Typography>
<Typography variant="h4" sx={{ fontWeight: 800 }}>8,421 <small style={{ fontSize: '0.9rem', color: 'text.secondary' }}>req/s</small></Typography>
</Grid>
<Grid item xs={12} sm={4}>
<Typography variant="caption" color="text.secondary" uppercase>Error Rate</Typography>
<Typography variant="h4" sx={{ fontWeight: 800, color: theme.palette.warning.main }}>0.02%</Typography>
</Grid>
</Grid>
<Box sx={{ mt: 4, height: 200, display: 'flex', alignItems: 'center', justifyContent: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Typography color="text.disabled">Live Throughput Chart (Coming Soon)</Typography>
</Box>
</Paper>
</Grid>
<Grid item xs={12} md={6}>
<Paper sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>
Recent Activity
</Typography>
<Typography variant="body2" color="text.secondary">
No recent activity
</Typography>
{/* Health Summary */}
<Grid item xs={12} md={4}>
<Paper sx={{ p: 3, height: '100%', bgcolor: alpha(theme.palette.background.paper, 0.6) }}>
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold' }}>Health Summary</Typography>
<Divider sx={{ mb: 3 }} />
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="body2">PostgreSQL Connectivity</Typography>
<Chip label="Stable" color="success" size="small" />
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="body2">Redis Cache Cluster</Typography>
<Chip label="98% Cache Hit" color="success" size="small" />
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="body2">Object Storage (MinIO)</Typography>
<Chip label="Degraded (Latency)" color="warning" size="small" />
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="body2">Deno Runtime Pool</Typography>
<Chip label="Executing" color="info" size="small" />
</Box>
</Box>
</Paper>
</Grid>
</Grid>

View File

@@ -0,0 +1,132 @@
import React, { useState } from 'react'
import {
Box,
Typography,
Paper,
Grid,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
Divider,
CircularProgress,
Chip,
} from '@mui/material'
import {
TableChart as TableIcon,
Storage as SchemaIcon,
ChevronRight as ChevronRightIcon,
} from '@mui/icons-material'
import { DataGrid, GridColDef } from '@mui/x-data-grid'
import { useDatabase } from '../hooks/useDatabase'
import { DbTable } from '../services/api'
export default function Database() {
const { tables, isLoadingTables, useTableData } = useDatabase()
const [selectedTable, setSelectedTable] = useState<DbTable | null>(null)
const { data: rows, isLoading: isLoadingData } = useTableData(
selectedTable?.schema || null,
selectedTable?.name || null
)
const columns: GridColDef[] = rows && rows.length > 0
? Object.keys(rows[0]).map(key => ({
field: key,
headerName: key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, ' '),
flex: 1,
minWidth: 150,
}))
: []
return (
<Box>
<Box sx={{ mb: 3 }}>
<Typography variant="h4" gutterBottom>
Database Browser
</Typography>
<Typography variant="body2" color="text.secondary">
Explore and manage your project's database tables and data
</Typography>
</Box>
<Grid container spacing={3}>
{/* Table List */}
<Grid item xs={12} md={3}>
<Paper sx={{ height: 600, overflow: 'auto' }}>
<Box sx={{ p: 2, bgcolor: 'background.paper', position: 'sticky', top: 0, zIndex: 1 }}>
<Typography variant="h6">Tables</Typography>
</Box>
<Divider />
{isLoadingTables ? (
<Box sx={{ p: 3, textAlign: 'center' }}>
<CircularProgress size={24} />
</Box>
) : (
<List disablePadding>
{tables.map((table) => (
<ListItem key={`${table.schema}.${table.name}`} disablePadding>
<ListItemButton
selected={selectedTable?.name === table.name && selectedTable?.schema === table.schema}
onClick={() => setSelectedTable(table)}
>
<ListItemIcon>
<TableIcon color="primary" />
</ListItemIcon>
<ListItemText
primary={table.name}
secondary={table.schema}
/>
</ListItemButton>
</ListItem>
))}
</List>
)}
</Paper>
</Grid>
{/* Data View */}
<Grid item xs={12} md={9}>
<Paper sx={{ height: 600, display: 'flex', flexDirection: 'column' }}>
{!selectedTable ? (
<Box sx={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', height: '100%', gap: 2 }}>
<TableIcon sx={{ fontSize: 64, color: 'text.disabled' }} />
<Typography color="text.secondary">Select a table to view data</Typography>
</Box>
) : (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<Box sx={{ p: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
<SchemaIcon color="action" fontSize="small" />
<Typography variant="h6">{selectedTable.schema}.</Typography>
<Typography variant="h6" color="primary">{selectedTable.name}</Typography>
</Box>
<Divider />
<Box sx={{ flexGrow: 1 }}>
{isLoadingData ? (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<CircularProgress />
</Box>
) : (
<DataGrid
rows={rows || []}
columns={columns}
getRowId={(row) => row.id || row.uuid || JSON.stringify(row)}
pageSizeOptions={[10, 25, 50]}
initialState={{
pagination: {
paginationModel: { pageSize: 10 },
},
}}
disableRowSelectionOnClick
/>
)}
</Box>
</Box>
)}
</Paper>
</Grid>
</Grid>
</Box>
)
}

View File

@@ -0,0 +1,148 @@
import React, { useState } from 'react'
import {
Box,
Typography,
Paper,
Button,
Grid,
Card,
CardContent,
CardActions,
Chip,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
CircularProgress,
IconButton,
} from '@mui/material'
import {
Functions as FunctionsIcon,
Add as AddIcon,
PlayArrow as DeployIcon,
Code as CodeIcon,
Settings as SettingsIcon,
} from '@mui/icons-material'
import { useFunctions } from '../hooks/useFunctions'
export default function Functions() {
const { functions, isLoadingFunctions, deployFunction, isDeploying } = useFunctions()
const [deployOpen, setDeployOpen] = useState(false)
const [newFunction, setNewFunction] = useState({
name: '',
runtime: 'deno',
code: 'export default async (req) => {\n return new Response("Hello from MadBase Edge!");\n};'
})
const handleDeploy = () => {
deployFunction({
name: newFunction.name,
runtime: newFunction.runtime,
code_base64: btoa(newFunction.code)
})
setDeployOpen(false)
}
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Box>
<Typography variant="h4" gutterBottom>
Edge Functions
</Typography>
<Typography variant="body2" color="text.secondary">
Deploy and manage serverless TypeScript functions
</Typography>
</Box>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => setDeployOpen(true)}
>
New Function
</Button>
</Box>
{isLoadingFunctions ? (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 5 }}>
<CircularProgress />
</Box>
) : (
<Grid container spacing={3}>
{functions.map((func) => (
<Grid item xs={12} sm={6} md={4} key={func.name}>
<Card variant="outlined">
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<FunctionsIcon color="primary" />
<Typography variant="h6">{func.name}</Typography>
</Box>
<Chip label={func.runtime} size="small" variant="outlined" />
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Endpoint: /functions/v1/{func.name}
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Chip label="v1.0.0" size="small" />
<Chip label="Active" size="small" color="success" />
</Box>
</CardContent>
<CardActions>
<Button size="small" startIcon={<CodeIcon />}>Edit Code</Button>
<Button size="small" startIcon={<SettingsIcon />}>Settings</Button>
</CardActions>
</Card>
</Grid>
))}
{functions.length === 0 && (
<Grid item xs={12}>
<Paper sx={{ p: 5, textAlign: 'center', border: '2px dashed', borderColor: 'divider', bgcolor: 'transparent' }}>
<FunctionsIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 2 }} />
<Typography color="text.secondary">No functions deployed yet</Typography>
<Button variant="outlined" sx={{ mt: 2 }} onClick={() => setDeployOpen(true)}>
Create your first function
</Button>
</Paper>
</Grid>
)}
</Grid>
)}
{/* Deploy Dialog */}
<Dialog open={deployOpen} onClose={() => setDeployOpen(false)} maxWidth="md" fullWidth>
<DialogTitle>Deploy New Function</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 2 }}>
<TextField
label="Function Name"
fullWidth
value={newFunction.name}
onChange={(e) => setNewFunction({ ...newFunction, name: e.target.value })}
/>
<TextField
label="TypeScript Code"
multiline
rows={10}
fullWidth
value={newFunction.code}
onChange={(e) => setNewFunction({ ...newFunction, code: e.target.value })}
sx={{ '& textarea': { fontFamily: 'monospace' } }}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeployOpen(false)}>Cancel</Button>
<Button
variant="contained"
onClick={handleDeploy}
disabled={isDeploying || !newFunction.name}
>
{isDeploying ? 'Deploying...' : 'Deploy Function'}
</Button>
</DialogActions>
</Dialog>
</Box>
)
}

View File

@@ -0,0 +1,115 @@
import React, { useState } from 'react'
import {
Box,
Typography,
Paper,
TextField,
InputAdornment,
CircularProgress,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Chip,
} from '@mui/material'
import {
Search as SearchIcon,
FilterList as FilterIcon,
} from '@mui/icons-material'
import { useLogs } from '../hooks/useLogs'
export default function Logs() {
const [query, setQuery] = useState('')
const { logs, isLoading, isRefetching } = useLogs(query)
return (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<Box sx={{ mb: 3, display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end' }}>
<Box>
<Typography variant="h4" gutterBottom>
Logs Viewer
</Typography>
<Typography variant="body2" color="text.secondary">
Query and analyze system logs from Loki
</Typography>
</Box>
{isRefetching && <CircularProgress size={20} />}
</Box>
<Paper sx={{ p: 2, mb: 3 }}>
<TextField
fullWidth
size="small"
placeholder='Search logs (e.g. {pillar="worker"} |= "error")...'
value={query}
onChange={(e) => setQuery(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" />
</InputAdornment>
),
endAdornment: (
<InputAdornment position="end">
<FilterIcon fontSize="small" color="action" cursor="pointer" />
</InputAdornment>
)
}}
/>
</Paper>
<TableContainer component={Paper} sx={{ flexGrow: 1, bgcolor: '#0d1117' }}>
<Table stickyHeader size="small">
<TableHead>
<TableRow>
<TableCell sx={{ bgcolor: '#161b22', color: '#8b949e', borderBottom: '1px solid #30363d', width: 200 }}>Timestamp</TableCell>
<TableCell sx={{ bgcolor: '#161b22', color: '#8b949e', borderBottom: '1px solid #30363d', width: 120 }}>Level</TableCell>
<TableCell sx={{ bgcolor: '#161b22', color: '#8b949e', borderBottom: '1px solid #30363d' }}>Message</TableCell>
</TableRow>
</TableHead>
<TableBody>
{logs.map((log: any, index: number) => (
<TableRow key={index} sx={{ '&:hover': { bgcolor: '#161b22' } }}>
<TableCell sx={{ color: '#8b949e', borderBottom: '1px solid #21262d', fontFamily: 'monospace' }}>
{log.timestamp}
</TableCell>
<TableCell sx={{ borderBottom: '1px solid #21262d' }}>
<Chip
label={log.level || 'INFO'}
size="small"
sx={{
height: 20,
fontSize: '0.7rem',
bgcolor: log.level === 'ERROR' ? '#f8514933' : log.level === 'WARN' ? '#d2992233' : '#23863633',
color: log.level === 'ERROR' ? '#f85149' : log.level === 'WARN' ? '#d29922' : '#3fb950',
border: '1px solid currentColor'
}}
/>
</TableCell>
<TableCell sx={{ color: '#e6edf3', borderBottom: '1px solid #21262d', fontFamily: 'monospace' }}>
{log.message}
</TableCell>
</TableRow>
))}
{logs.length === 0 && !isLoading && (
<TableRow>
<TableCell colSpan={3} sx={{ textAlign: 'center', py: 5, color: '#8b949e', borderBottom: 'none' }}>
No logs found for the current query
</TableCell>
</TableRow>
)}
{isLoading && (
<TableRow>
<TableCell colSpan={3} sx={{ textAlign: 'center', py: 5, borderBottom: 'none' }}>
<CircularProgress size={30} />
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
</Box>
)
}

View File

@@ -1,4 +1,4 @@
import React from 'react'
import { useQuery } from '@tanstack/react-query'
import {
Box,
@@ -37,7 +37,7 @@ export default function Providers() {
</Typography>
<Grid container spacing={3} sx={{ mt: 2 }}>
{providersData?.providers?.map((provider: Provider) => (
{providersData?.map((provider: Provider) => (
<Grid item xs={12} md={6} key={provider.provider}>
<Card>
<CardContent>

View File

@@ -0,0 +1,105 @@
import {
Box,
Typography,
Paper,
Chip,
List,
ListItem,
ListItemText,
Divider,
IconButton,
Tooltip,
} from '@mui/material'
import {
Bolt as FlashIcon,
DeleteSweep as ClearIcon,
Circle as CircleIcon,
} from '@mui/icons-material'
import { useRealtime } from '../hooks/useRealtime'
export default function Realtime() {
const { messages, isConnected, clearMessages } = useRealtime()
return (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Box>
<Typography variant="h4" gutterBottom>
Realtime Console
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CircleIcon sx={{ fontSize: 12, color: isConnected ? 'success.main' : 'error.main' }} />
<Typography variant="body2" color="text.secondary">
{isConnected ? 'Connected' : 'Disconnected'}
</Typography>
</Box>
</Box>
<Tooltip title="Clear Console">
<IconButton onClick={clearMessages} color="inherit">
<ClearIcon />
</IconButton>
</Tooltip>
</Box>
<Paper sx={{
flexGrow: 1,
bgcolor: '#0d1117',
color: '#e6edf3',
fontFamily: 'monospace',
overflow: 'auto',
p: 0,
border: '1px solid #30363d'
}}>
<List disablePadding>
{messages.map((msg) => (
<ListItem
key={msg.id}
divider
sx={{
flexDirection: 'column',
alignItems: 'flex-start',
py: 1,
px: 2,
borderBottom: '1px solid #21262d',
'&:hover': { bgcolor: '#161b22' }
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5, width: '100%' }}>
<Typography variant="caption" sx={{ color: '#8b949e', minWidth: 80 }}>
[{new Date(msg.timestamp).toLocaleTimeString()}]
</Typography>
<Chip
label={msg.type}
size="small"
sx={{
height: 18,
fontSize: '0.65rem',
fontWeight: 'bold',
bgcolor: msg.type === 'IN' ? '#23863633' : msg.type === 'OUT' ? '#1f6feb33' : '#8b949e33',
color: msg.type === 'IN' ? '#3fb950' : msg.type === 'OUT' ? '#58a6ff' : '#8b949e',
border: '1px solid currentColor'
}}
/>
</Box>
<Box sx={{ width: '100%', overflowX: 'auto' }}>
<pre style={{ margin: 0, fontSize: '0.85rem' }}>
{typeof msg.payload === 'object'
? JSON.stringify(msg.payload, null, 2)
: String(msg.payload)
}
</pre>
</Box>
</ListItem>
))}
{messages.length === 0 && (
<Box sx={{ p: 5, textAlign: 'center', color: '#8b949e' }}>
<FlashIcon sx={{ fontSize: 48, mb: 2, opacity: 0.3 }} />
<Typography variant="body2">Waiting for events...</Typography>
</Box>
)}
</List>
</Paper>
</Box>
)
}

View File

@@ -18,16 +18,28 @@ import {
Chip,
CircularProgress,
Divider,
useTheme,
alpha,
IconButton,
Tooltip,
} from '@mui/material'
import { TrendingUp as TrendingUpIcon } from '@mui/icons-material'
import {
TrendingUp as TrendingUpIcon,
Storage as StorageIcon,
Dns as ServerIcon,
HelpOutline as HelpIcon,
} from '@mui/icons-material'
import { apiService, ScalingPlan } from '@/services/api'
import ScalingConfirmationDialog from '../components/Scaling/ScalingConfirmationDialog'
export default function Scaling() {
const theme = useTheme()
const [provider, setProvider] = useState('hetzner')
const [selectedPlan, setSelectedPlan] = useState('cx11')
const [region, setRegion] = useState('fsn1')
const [workerCount, setWorkerCount] = useState(3)
const [dbCount, setDbCount] = useState(3)
const [confirmOpen, setConfirmOpen] = useState(false)
const queryClient = useQueryClient()
@@ -45,164 +57,198 @@ export default function Scaling() {
target_db_count: dbCount,
min_ha_nodes: true,
}),
onSuccess: () => setConfirmOpen(true)
})
const executeMutation = useMutation({
mutationFn: (plan: any[]) => apiService.executeScalingPlan(plan),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['servers', 'clusterHealth'] })
queryClient.invalidateQueries({ queryKey: ['servers', 'clusterHealth', 'pillars'] })
setConfirmOpen(false)
},
})
const scalingPlan = createPlanMutation.data?.data
const scalingPlan = createPlanMutation.data?.data as ScalingPlan
return (
<Box>
<Typography variant="h4" gutterBottom>
Cluster Scaling
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
Scale your cluster automatically with cost estimation
</Typography>
<Box sx={{ maxWidth: 1200, mx: 'auto' }}>
<Box sx={{ mb: 4 }}>
<Typography variant="h3" sx={{ fontWeight: 900, letterSpacing: -1, mb: 1 }}>
Cluster Scaling
</Typography>
<Typography variant="body1" color="text.secondary">
Fine-tune your infrastructure capacity with zero-downtime scaling.
</Typography>
</Box>
<Grid container spacing={3} sx={{ mt: 2 }}>
<Grid item xs={12} md={6}>
<Paper sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>
Scaling Configuration
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, mt: 2 }}>
<FormControl fullWidth>
<InputLabel>Provider</InputLabel>
<Select
value={provider}
label="Provider"
onChange={(e) => setProvider(e.target.value)}
>
<MenuItem value="hetzner">Hetzner Cloud</MenuItem>
<MenuItem value="generic">Generic</MenuItem>
</Select>
</FormControl>
<FormControl fullWidth>
<InputLabel>Base Plan</InputLabel>
<Select
value={selectedPlan}
label="Base Plan"
onChange={(e) => setSelectedPlan(e.target.value)}
>
<MenuItem value="cx11">CX11 (3.69/mo)</MenuItem>
<MenuItem value="cx21">CX21 (6.94/mo)</MenuItem>
<MenuItem value="cx31">CX31 (14.21/mo)</MenuItem>
</Select>
</FormControl>
<FormControl fullWidth>
<InputLabel>Region</InputLabel>
<Select
value={region}
label="Region"
onChange={(e) => setRegion(e.target.value)}
>
<MenuItem value="fsn1">Falkenstein (Germany)</MenuItem>
<MenuItem value="nbg1">Nuremberg (Germany)</MenuItem>
<MenuItem value="ash">Ashburn (USA)</MenuItem>
</Select>
</FormControl>
<Box>
<Typography gutterBottom>
Worker Nodes: {workerCount}
</Typography>
<Slider
value={workerCount}
onChange={(_, value) => setWorkerCount(value as number)}
min={1}
max={10}
marks
valueLabelDisplay="auto"
/>
</Box>
<Box>
<Typography gutterBottom>
Database Nodes: {dbCount}
{dbCount % 2 === 0 && (
<Chip
label="Will be adjusted to odd number"
size="small"
sx={{ ml: 1 }}
/>
)}
</Typography>
<Slider
value={dbCount}
onChange={(_, value) => setDbCount(value as number)}
min={1}
max={7}
step={2}
marks
valueLabelDisplay="auto"
/>
</Box>
<Button
variant="contained"
size="large"
fullWidth
onClick={() => createPlanMutation.mutate()}
disabled={createPlanMutation.isPending}
>
{createPlanMutation.isPending ? 'Calculating...' : 'Create Scaling Plan'}
</Button>
<Grid container spacing={4}>
<Grid item xs={12} md={7}>
<Paper sx={{
p: 4,
borderRadius: 3,
bgcolor: alpha(theme.palette.background.paper, 0.6),
backdropFilter: 'blur(10px)',
border: `1px solid ${alpha(theme.palette.divider, 0.1)}`
}}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 'bold', display: 'flex', alignItems: 'center', gap: 1 }}>
<TrendingUpIcon color="primary" /> Capacity Configuration
</Typography>
<Tooltip title="MadBase automatically optimizes node placement for High Availability">
<IconButton size="small"><HelpIcon fontSize="inherit" /></IconButton>
</Tooltip>
</Box>
<Grid container spacing={3}>
<Grid item xs={12} sm={6}>
<FormControl fullWidth size="small">
<InputLabel>Provider</InputLabel>
<Select
value={provider}
label="Provider"
onChange={(e) => setProvider(e.target.value)}
>
<MenuItem value="hetzner">Hetzner Cloud</MenuItem>
<MenuItem value="generic">Bare Metal / Generic</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={6}>
<FormControl fullWidth size="small">
<InputLabel>Region</InputLabel>
<Select
value={region}
label="Region"
onChange={(e) => setRegion(e.target.value)}
>
<MenuItem value="fsn1">Falkenstein (DE)</MenuItem>
<MenuItem value="nbg1">Nuremberg (DE)</MenuItem>
<MenuItem value="hel1">Helsinki (FI)</MenuItem>
<MenuItem value="ash">Ashburn (US)</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={12}>
<FormControl fullWidth size="small">
<InputLabel>Scaling Base Plan</InputLabel>
<Select
value={selectedPlan}
label="Scaling Base Plan"
onChange={(e) => setSelectedPlan(e.target.value)}
>
<MenuItem value="cx11">CX11 (2 vCPU, 2GB RAM) - 3.69/mo</MenuItem>
<MenuItem value="cx21">CX21 (3 vCPU, 4GB RAM) - 6.94/mo</MenuItem>
<MenuItem value="cx31">CX31 (4 vCPU, 8GB RAM) - 14.21/mo</MenuItem>
</Select>
</FormControl>
</Grid>
</Grid>
<Divider sx={{ my: 4 }} />
<Box sx={{ mb: 4 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="subtitle1" component="div" sx={{ fontWeight: 'bold', display: 'flex', alignItems: 'center', gap: 1 }}>
<ServerIcon fontSize="small" color="primary" /> HTTP Edge Workers
</Typography>
<Chip label={`${workerCount} Nodes`} color="primary" size="small" variant="outlined" />
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Handles HTTP requests and executes Edge Functions.
</Typography>
<Slider
value={workerCount}
onChange={(_, value) => setWorkerCount(value as number)}
min={1}
max={20}
marks={[
{ value: 1, label: 'Single' },
{ value: 3, label: 'HA' },
{ value: 10, label: 'Mid' },
{ value: 20, label: 'Peak' },
]}
valueLabelDisplay="auto"
/>
</Box>
<Box sx={{ mb: 4 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="subtitle1" component="div" sx={{ fontWeight: 'bold', display: 'flex', alignItems: 'center', gap: 1 }}>
<StorageIcon fontSize="small" color="secondary" /> Database Pillars
</Typography>
<Chip label={`${dbCount} Nodes`} color="secondary" size="small" variant="outlined" />
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
PostgreSQL primary and replica nodes with logical replication.
</Typography>
<Slider
value={dbCount}
onChange={(_, value) => setDbCount(value as number)}
min={1}
max={7}
step={2}
marks={[
{ value: 1, label: 'Dev' },
{ value: 3, label: 'Stable' },
{ value: 5, label: 'Enterprise' },
]}
valueLabelDisplay="auto"
color="secondary"
/>
</Box>
<Button
variant="contained"
size="large"
fullWidth
onClick={() => createPlanMutation.mutate()}
disabled={createPlanMutation.isPending}
sx={{
mt: 2,
py: 1.5,
fontWeight: 'bold',
boxShadow: `0 8px 16px ${alpha(theme.palette.primary.main, 0.2)}`
}}
>
{createPlanMutation.isPending ? <CircularProgress size={24} color="inherit" /> : 'Analyze & Create Scaling Plan'}
</Button>
</Paper>
</Grid>
<Grid item xs={12} md={6}>
{scalingPlan && (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Scaling Plan
</Typography>
<Alert severity="info" sx={{ mb: 2 }}>
Estimated monthly cost: <strong>{scalingPlan.total_cost_monthly.toFixed(2)}</strong>
</Alert>
<Typography variant="body2" color="text.secondary" paragraph>
Estimated time: {scalingPlan.estimated_time_minutes} minutes
</Typography>
<Divider sx={{ my: 2 }} />
{scalingPlan.scaling_plan?.map((step: any, index: number) => (
<Box key={index} sx={{ mb: 2 }}>
<Typography variant="subtitle2">
{step.action}: {step.count}x {step.template}
</Typography>
<Typography variant="body2" color="text.secondary">
Plan: {step.plan} | Cost: {step.total_cost.toFixed(2)}/mo
</Typography>
</Box>
))}
<Button
variant="contained"
fullWidth
sx={{ mt: 2 }}
onClick={() => executeMutation.mutate(scalingPlan.scaling_plan)}
disabled={executeMutation.isPending}
>
{executeMutation.isPending ? 'Executing...' : 'Execute Scaling Plan'}
</Button>
</CardContent>
</Card>
)}
<Grid item xs={12} md={5}>
<Box sx={{ position: 'sticky', top: 100 }}>
<Alert severity="info" sx={{ mb: 3, border: '1px solid', borderColor: 'info.main' }}>
MadBase uses <strong>zero-downtime rolling updates</strong>. Your cluster remains available during scaling.
</Alert>
<Paper sx={{ p: 3, bgcolor: alpha(theme.palette.background.paper, 0.4), border: '1px dashed', borderColor: 'divider' }}>
<Typography variant="h6" gutterBottom color="text.secondary">Current Cluster State</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, mt: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="body2">Active Workers</Typography>
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>3</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="body2">Database Replicas</Typography>
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>3</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="body2">HA Status</Typography>
<Chip label="ENABLED" color="success" size="small" sx={{ height: 16, fontSize: '0.6rem' }} />
</Box>
</Box>
</Paper>
</Box>
</Grid>
</Grid>
<ScalingConfirmationDialog
open={confirmOpen}
onClose={() => setConfirmOpen(false)}
onConfirm={() => scalingPlan && executeMutation.mutate(scalingPlan.scaling_plan)}
plan={scalingPlan || null}
loading={executeMutation.isPending}
/>
</Box>
)
}

View File

@@ -1,4 +1,4 @@
import React from 'react'
import {
Box,
Paper,

View File

@@ -0,0 +1,185 @@
import React, { useState } from 'react'
import {
Box,
Typography,
Paper,
Grid,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
Divider,
IconButton,
Chip,
CircularProgress,
Alert,
} from '@mui/material'
import {
Folder as FolderIcon,
Description as FileIcon,
Delete as DeleteIcon,
ChevronRight as ChevronRightIcon,
Public as PublicIcon,
Lock as LockIcon,
} from '@mui/icons-material'
import { DataGrid, GridColDef } from '@mui/x-data-grid'
import { useStorage } from '../hooks/useStorage'
import { Bucket, StorageObject } from '../services/api'
export default function Storage() {
const { buckets, isLoadingBuckets, useObjects, deleteObject, isDeletingObject } = useStorage()
const [selectedBucket, setSelectedBucket] = useState<string | null>(null)
const { data: objects, isLoading: isLoadingObjects } = useObjects(selectedBucket)
const columns: GridColDef[] = [
{
field: 'name',
headerName: 'Name',
flex: 1,
renderCell: (params) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<FileIcon color="primary" fontSize="small" />
<Typography variant="body2">{params.value}</Typography>
</Box>
),
},
{
field: 'size',
headerName: 'Size',
width: 120,
valueGetter: (params) => {
const size = params.row.metadata?.size || 0;
if (size === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(size) / Math.log(k));
return parseFloat((size / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
},
{
field: 'mimetype',
headerName: 'Type',
width: 150,
valueGetter: (params) => params.row.metadata?.mimetype || 'unknown',
},
{
field: 'actions',
headerName: 'Actions',
width: 100,
renderCell: (params) => (
<IconButton
size="small"
color="error"
onClick={() => selectedBucket && deleteObject({ bucketId: selectedBucket, name: params.row.name })}
disabled={isDeletingObject}
>
<DeleteIcon fontSize="small" />
</IconButton>
),
},
]
return (
<Box>
<Box sx={{ mb: 3 }}>
<Typography variant="h4" gutterBottom>
Storage Browser
</Typography>
<Typography variant="body2" color="text.secondary">
Manage your S3-compatible object storage buckets and files
</Typography>
</Box>
<Grid container spacing={3}>
{/* Bucket List */}
<Grid item xs={12} md={3}>
<Paper sx={{ height: 600, overflow: 'auto' }}>
<Box sx={{ p: 2, bgcolor: 'background.paper', position: 'sticky', top: 0, zIndex: 1 }}>
<Typography variant="h6">Buckets</Typography>
</Box>
<Divider />
{isLoadingBuckets ? (
<Box sx={{ p: 3, textAlign: 'center' }}>
<CircularProgress size={24} />
</Box>
) : (
<List disablePadding>
{buckets.map((bucket) => (
<ListItem key={bucket.id} disablePadding>
<ListItemButton
selected={selectedBucket === bucket.id}
onClick={() => setSelectedBucket(bucket.id)}
>
<ListItemIcon>
<FolderIcon color="secondary" />
</ListItemIcon>
<ListItemText
primary={bucket.id}
secondary={bucket.public ? 'Public' : 'Private'}
/>
{bucket.public ? <PublicIcon fontSize="inherit" color="action" /> : <LockIcon fontSize="inherit" color="action" />}
</ListItemButton>
</ListItem>
))}
{buckets.length === 0 && (
<Box sx={{ p: 3, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">No buckets found</Typography>
</Box>
)}
</List>
)}
</Paper>
</Grid>
{/* File Browser */}
<Grid item xs={12} md={9}>
<Paper sx={{ height: 600, display: 'flex', flexDirection: 'column' }}>
{!selectedBucket ? (
<Box sx={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', height: '100%', gap: 2 }}>
<FolderIcon sx={{ fontSize: 64, color: 'text.disabled' }} />
<Typography color="text.secondary">Select a bucket to view files</Typography>
</Box>
) : (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<Box sx={{ p: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="h6">{selectedBucket}</Typography>
<Chip
label={buckets.find(b => b.id === selectedBucket)?.public ? 'Public' : 'Private'}
size="small"
variant="outlined"
/>
</Box>
<Button variant="contained" size="small">Upload File</Button>
</Box>
<Divider />
<Box sx={{ flexGrow: 1 }}>
{isLoadingObjects ? (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<CircularProgress />
</Box>
) : (
<DataGrid
rows={objects || []}
columns={columns}
getRowId={(row) => row.name}
pageSizeOptions={[10, 25, 50]}
initialState={{
pagination: {
paginationModel: { pageSize: 10 },
},
}}
disableRowSelectionOnClick
/>
)}
</Box>
</Box>
)}
</Paper>
</Grid>
</Grid>
</Box>
)
}

View File

@@ -1,4 +1,4 @@
import React from 'react'
import { useQuery } from '@tanstack/react-query'
import {
Box,

View File

@@ -12,6 +12,7 @@ export interface Server {
id: string
name: string
template: string
pillar: string
provider: string
ip_address: string
status: 'provisioning' | 'starting' | 'active' | 'draining' | 'stopping' | 'stopped' | 'error'
@@ -19,6 +20,36 @@ export interface Server {
updated_at: string
}
export interface AdminUser {
id: string
email: string
created_at: string
}
export interface Bucket {
id: string
public: boolean
}
export interface StorageObject {
name: string
metadata?: {
size: number
mimetype: string
}
}
export interface DbTable {
schema: string
name: string
}
export interface EdgeFunction {
name: string
runtime: string
code?: string
}
export interface Template {
id: string
name: string
@@ -84,32 +115,60 @@ export interface ScalingStep {
total_cost: number
}
// API Functions
export const apiService = {
// Servers
getServers: () => api.get<{ servers: Server[] }>('/servers'),
getServers: () => api.get<Server[]>('/servers'),
getServer: (id: string) => api.get<Server>(`/servers/${id}`),
addServer: (data: AddServerRequest) => api.post('/servers', data),
getServer: (id: string) => api.get(`/servers/${id}`),
deleteServer: (id: string) => api.delete(`/servers/${id}`),
getServerStatus: (id: string) => api.get(`/servers/${id}/status`),
removeServer: (id: string) => api.delete(`/servers/${id}`),
fortifyServer: (id: string, data: FortifyRequest) => api.post(`/servers/${id}/fortify`, data),
// Templates
getTemplates: () => api.get<{ templates: Template[] }>('/templates'),
getTemplate: (id: string) => api.get(`/templates/${id}`),
validateTemplate: (id: string) => api.post(`/templates/${id}/validate`),
getTemplates: () => api.get<Template[]>('/templates'),
getTemplate: (id: string) => api.get<Template>(`/templates/${id}`),
// Providers
getProviders: () => api.get<{ providers: Provider[] }>('/providers'),
getProviderPlans: (provider: string) => api.get(`/providers/${provider}/plans`),
getProviderRegions: (provider: string) => api.get(`/providers/${provider}/regions`),
getProviders: () => api.get<Provider[]>('/providers'),
getPlans: (provider: string) => api.get<Plan[]>(`/providers/${provider}/plans`),
getRegions: (provider: string) => api.get<any[]>(`/providers/${provider}/regions`),
// Scaling
createScalingPlan: (data: ScalingPlanRequest) => api.post('/cluster/scale-plan', data),
createScalingPlan: (data: ScalingPlanRequest) => api.post<ScalingPlan>('/cluster/scale-plan', data),
executeScalingPlan: (plan: ScalingStep[]) => api.post('/cluster/scale-execute', plan),
// Cluster
getClusterHealth: () => api.get<ClusterHealth>('/cluster/health'),
// Users
getUsers: () => api.get<AdminUser[]>('/users'),
deleteUser: (id: string) => api.delete(`/users/${id}`),
// Projects
getProjects: () => api.get<any[]>('/projects'),
createProject: (data: { name: string; owner_id?: string | null }) => api.post('/projects', data),
deleteProject: (id: string) => api.delete(`/projects/${id}`),
// Storage
getBuckets: () => api.get<Bucket[]>('/storage/buckets'),
getBucketObjects: (bucketId: string) => api.post<StorageObject[]>(`/storage/buckets/${bucketId}/objects`),
deleteObject: (bucketId: string, objectName: string) => api.delete(`/storage/${bucketId}/${objectName}`),
// Database
getTables: () => api.get<DbTable[]>('/db/tables'),
getTableData: (schema: string, name: string) => api.get<any[]>(`/db/tables/${schema}/${name}`),
// Functions
getFunctions: () => api.get<EdgeFunction[]>('/functions'),
getFunction: (name: string) => api.get<EdgeFunction>(`/functions/${name}`),
deployFunction: (data: { name: string; runtime: string; code_base64: string }) => api.post('/functions', data),
// Observability
getPillars: () => api.get<any[]>('/cluster/pillars'),
getLogs: (params: { query: string; limit: number }) => api.get('/logs', { params }),
// Auth/Session
login: (password: string) => api.post('/login', { password }),
logout: () => api.post('/logout'),
getAdminConfig: () => api.get('/admin/config'),
getCsrfToken: () => api.get<{ token: string }>('/csrf-token'),
}
export interface AddServerRequest {

View File

@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import React from 'react'
describe('Layout Component - Enhanced Tests', () => {
it('renders navigation menu with all items', async () => {

View File

@@ -9,7 +9,11 @@ vi.mock('axios', () => ({
post: vi.fn(),
delete: vi.fn(),
put: vi.fn(),
patch: vi.fn()
patch: vi.fn(),
interceptors: {
request: { use: vi.fn(), eject: vi.fn() },
response: { use: vi.fn(), eject: vi.fn() }
}
}))
}
}))
@@ -25,6 +29,7 @@ describe('API Service - Comprehensive Tests', () => {
{
id: 'srv-1',
name: 'worker-01',
pillar: 'worker',
template: 'worker',
provider: 'hetzner',
ip_address: '192.168.1.1',
@@ -34,20 +39,19 @@ describe('API Service - Comprehensive Tests', () => {
}
]
// Mock the implementation
vi.spyOn(apiService, 'getServers').mockResolvedValueOnce({
data: { servers: mockServers } as any
})
data: mockServers as any
} as any)
const response = await apiService.getServers()
expect(response.data).toEqual({ servers: mockServers })
expect(response.data).toEqual(mockServers)
})
it('fetches single server by ID', async () => {
const mockServer: Server = {
id: 'srv-1',
name: 'worker-01',
pillar: 'worker',
template: 'worker',
provider: 'hetzner',
ip_address: '192.168.1.1',
@@ -58,10 +62,9 @@ describe('API Service - Comprehensive Tests', () => {
vi.spyOn(apiService, 'getServer').mockResolvedValueOnce({
data: mockServer as any
})
} as any)
const response = await apiService.getServer('srv-1')
expect(response.data).toEqual(mockServer)
})
@@ -77,6 +80,7 @@ describe('API Service - Comprehensive Tests', () => {
const createdServer: Server = {
id: 'srv-3',
name: newServer.name,
pillar: 'worker',
template: newServer.template,
provider: newServer.provider,
ip_address: '192.168.1.3',
@@ -87,33 +91,20 @@ describe('API Service - Comprehensive Tests', () => {
vi.spyOn(apiService, 'addServer').mockResolvedValueOnce({
data: createdServer as any
})
} as any)
const response = await apiService.addServer(newServer)
expect(response.data).toEqual(createdServer)
})
it('deletes a server', async () => {
vi.spyOn(apiService, 'deleteServer').mockResolvedValueOnce({
it('removes a server', async () => {
vi.spyOn(apiService, 'removeServer').mockResolvedValueOnce({
data: { success: true } as any
})
const response = await apiService.deleteServer('srv-1')
} as any)
const response = await apiService.removeServer('srv-1')
expect(response.data).toEqual({ success: true })
})
it('fetches server status', async () => {
const mockStatus = { status: 'active', uptime: 99.9 }
vi.spyOn(apiService, 'getServerStatus').mockResolvedValueOnce({
data: mockStatus as any
})
const response = await apiService.getServerStatus('srv-1')
expect(response.data).toEqual(mockStatus)
})
})
describe('Templates', () => {
@@ -131,32 +122,11 @@ describe('API Service - Comprehensive Tests', () => {
]
vi.spyOn(apiService, 'getTemplates').mockResolvedValueOnce({
data: { templates: mockTemplates } as any
})
data: mockTemplates as any
} as any)
const response = await apiService.getTemplates()
expect(response.data).toEqual({ templates: mockTemplates })
})
it('fetches template by ID', async () => {
const mockTemplate: Template = {
id: 'tmpl-1',
name: 'Worker',
description: 'Standard worker node',
min_hetzner_plan: 'cx22',
estimated_monthly_cost: 10,
services: [],
requirements: { min_nodes: 1, max_nodes: 10, supports_ha: false }
}
vi.spyOn(apiService, 'getTemplate').mockResolvedValueOnce({
data: mockTemplate as any
})
const response = await apiService.getTemplate('tmpl-1')
expect(response.data).toEqual(mockTemplate)
expect(response.data).toEqual(mockTemplates)
})
})
@@ -173,12 +143,11 @@ describe('API Service - Comprehensive Tests', () => {
]
vi.spyOn(apiService, 'getProviders').mockResolvedValueOnce({
data: { providers: mockProviders } as any
})
data: mockProviders as any
} as any)
const response = await apiService.getProviders()
expect(response.data).toEqual({ providers: mockProviders })
expect(response.data).toEqual(mockProviders)
})
it('fetches provider plans', async () => {
@@ -186,25 +155,13 @@ describe('API Service - Comprehensive Tests', () => {
{ id: 'cx11', name: 'CX11', cpu_cores: 1, memory_gb: 2, disk_gb: 20, monthly_cost: 4 }
]
vi.spyOn(apiService, 'getProviderPlans').mockResolvedValueOnce({
vi.spyOn(apiService, 'getPlans').mockResolvedValueOnce({
data: mockPlans as any
})
const response = await apiService.getProviderPlans('hetzner')
} as any)
const response = await apiService.getPlans('hetzner')
expect(response.data).toEqual(mockPlans)
})
it('fetches provider regions', async () => {
const mockRegions = [{ id: 'fsn1', name: 'Falkenstein DC 1' }]
vi.spyOn(apiService, 'getProviderRegions').mockResolvedValueOnce({
data: mockRegions as any
})
const response = await apiService.getProviderRegions('hetzner')
expect(response.data).toEqual(mockRegions)
})
})
describe('Scaling', () => {
@@ -222,32 +179,11 @@ describe('API Service - Comprehensive Tests', () => {
vi.spyOn(apiService, 'createScalingPlan').mockResolvedValueOnce({
data: mockPlan as any
})
} as any)
const response = await apiService.createScalingPlan(request)
expect(response.data).toEqual(mockPlan)
})
it('executes scaling plan', async () => {
const plan = [{
provider: 'hetzner',
action: 'create',
template: 'worker',
plan: 'cx22',
count: 2,
cost_per_server: 10,
total_cost: 20
}]
vi.spyOn(apiService, 'executeScalingPlan').mockResolvedValueOnce({
data: { success: true } as any
})
const response = await apiService.executeScalingPlan(plan)
expect(response.data).toEqual({ success: true })
})
})
describe('Cluster Health', () => {
@@ -263,32 +199,11 @@ describe('API Service - Comprehensive Tests', () => {
vi.spyOn(apiService, 'getClusterHealth').mockResolvedValueOnce({
data: mockHealth as any
})
} as any)
const response = await apiService.getClusterHealth()
expect(response.data).toEqual(mockHealth)
})
it('handles unhealthy cluster status', async () => {
const mockHealth: ClusterHealth = {
healthy: false,
total_servers: 10,
active_servers: 5,
error_servers: 5,
services_up: 30,
services_down: 20
}
vi.spyOn(apiService, 'getClusterHealth').mockResolvedValueOnce({
data: mockHealth as any
})
const response = await apiService.getClusterHealth()
expect(response.data.healthy).toBe(false)
expect(response.data.error_servers).toBe(5)
})
})
describe('Error Handling', () => {
@@ -298,19 +213,5 @@ describe('API Service - Comprehensive Tests', () => {
await expect(apiService.getServers()).rejects.toThrow('Network error')
})
it('handles 404 errors', async () => {
const error = { response: { status: 404, data: { error: 'Not found' } } }
vi.spyOn(apiService, 'getServer').mockRejectedValueOnce(error as any)
await expect(apiService.getServer('invalid-id')).rejects.toEqual(error)
})
it('handles 500 errors', async () => {
const error = { response: { status: 500, data: { error: 'Internal server error' } } }
vi.spyOn(apiService, 'getTemplates').mockRejectedValueOnce(error as any)
await expect(apiService.getTemplates()).rejects.toEqual(error)
})
})
})

View File

@@ -0,0 +1,103 @@
import { createTheme, alpha } from '@mui/material/styles'
export const darkTheme = createTheme({
palette: {
mode: 'dark',
primary: {
main: '#00d4ff', // Electric Blue
light: '#33dcff',
dark: '#0094b2',
},
secondary: {
main: '#7c4dff', // Deep Purple
light: '#9670ff',
dark: '#5635b2',
},
background: {
default: '#010409', // GitHub Dark Default
paper: '#0d1117',
},
success: {
main: '#238636',
},
warning: {
main: '#d29922',
},
error: {
main: '#f85149',
},
divider: 'rgba(48, 54, 61, 0.5)',
},
shape: {
borderRadius: 8,
},
typography: {
fontFamily: '"Inter", "Inter var", -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
h3: {
fontWeight: 800,
},
h4: {
fontWeight: 800,
},
h6: {
fontWeight: 700,
},
overline: {
fontWeight: 700,
letterSpacing: '0.1em',
},
},
components: {
MuiButton: {
styleOverrides: {
root: {
textTransform: 'none',
fontWeight: 600,
borderRadius: '8px',
},
containedPrimary: {
background: 'linear-gradient(135deg, #00d4ff 0%, #00a3ff 100%)',
boxShadow: '0 4px 14px 0 rgba(0, 212, 255, 0.39)',
'&:hover': {
boxShadow: '0 6px 20px rgba(0, 212, 255, 0.23)',
},
},
},
},
MuiPaper: {
styleOverrides: {
root: {
backgroundImage: 'none',
border: '1px solid rgba(48, 54, 61, 0.5)',
},
},
},
MuiCard: {
styleOverrides: {
root: {
backgroundImage: 'none',
border: '1px solid rgba(48, 54, 61, 0.5)',
borderRadius: '12px',
},
},
},
MuiDrawer: {
styleOverrides: {
paper: {
borderRight: '1px solid rgba(48, 54, 61, 0.5)',
background: '#010409',
},
},
},
MuiAppBar: {
styleOverrides: {
root: {
background: alpha('#010409', 0.8),
backdropFilter: 'blur(8px)',
borderBottom: '1px solid rgba(48, 54, 61, 0.5)',
boxShadow: 'none',
},
},
},
},
})

View File

@@ -18,7 +18,10 @@
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]