Step 4: Frontend Skeleton - React + Vite + Material-UI dashboard

- Created React application with TypeScript and Material-UI
- Implemented dashboard with service health monitoring
- Added Services and Users management pages
- Configured Nginx as reverse proxy to backend
- Successfully integrated frontend with Console backend

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
jungwoo choi
2025-09-10 16:30:11 +09:00
parent 683305918c
commit 4b2be7ccaf
14 changed files with 762 additions and 0 deletions

View File

@ -0,0 +1,20 @@
# Build stage
FROM node:18-alpine as builder
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build
# Production stage
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@ -0,0 +1,13 @@
<!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>Console - Microservices Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,22 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://console-backend:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

View File

@ -0,0 +1,33 @@
{
"name": "console-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0",
"@mui/material": "^5.15.2",
"@mui/icons-material": "^5.15.2",
"axios": "^1.6.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.1"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.55.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"typescript": "^5.2.2",
"vite": "^5.0.8"
}
}

View File

@ -0,0 +1,19 @@
import { Routes, Route } from 'react-router-dom'
import Layout from './components/Layout'
import Dashboard from './pages/Dashboard'
import Services from './pages/Services'
import Users from './pages/Users'
function App() {
return (
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Dashboard />} />
<Route path="services" element={<Services />} />
<Route path="users" element={<Users />} />
</Route>
</Routes>
)
}
export default App

View File

@ -0,0 +1,102 @@
import { useState } from 'react'
import { Outlet, Link as RouterLink } from 'react-router-dom'
import {
AppBar,
Box,
Drawer,
IconButton,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
Toolbar,
Typography,
} from '@mui/material'
import {
Menu as MenuIcon,
Dashboard as DashboardIcon,
Cloud as CloudIcon,
People as PeopleIcon,
} from '@mui/icons-material'
const drawerWidth = 240
const menuItems = [
{ text: 'Dashboard', icon: <DashboardIcon />, path: '/' },
{ text: 'Services', icon: <CloudIcon />, path: '/services' },
{ text: 'Users', icon: <PeopleIcon />, path: '/users' },
]
function Layout() {
const [open, setOpen] = useState(true)
const handleDrawerToggle = () => {
setOpen(!open)
}
return (
<Box sx={{ display: 'flex' }}>
<AppBar
position="fixed"
sx={{ zIndex: (theme) => theme.zIndex.drawer + 1 }}
>
<Toolbar>
<IconButton
color="inherit"
aria-label="open drawer"
edge="start"
onClick={handleDrawerToggle}
sx={{ mr: 2 }}
>
<MenuIcon />
</IconButton>
<Typography variant="h6" noWrap component="div">
Microservices Console
</Typography>
</Toolbar>
</AppBar>
<Drawer
variant="persistent"
anchor="left"
open={open}
sx={{
width: drawerWidth,
flexShrink: 0,
'& .MuiDrawer-paper': {
width: drawerWidth,
boxSizing: 'border-box',
},
}}
>
<Toolbar />
<Box sx={{ overflow: 'auto' }}>
<List>
{menuItems.map((item) => (
<ListItem key={item.text} disablePadding>
<ListItemButton component={RouterLink} to={item.path}>
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={item.text} />
</ListItemButton>
</ListItem>
))}
</List>
</Box>
</Drawer>
<Box
component="main"
sx={{
flexGrow: 1,
p: 3,
marginLeft: open ? `${drawerWidth}px` : 0,
transition: 'margin 0.3s',
}}
>
<Toolbar />
<Outlet />
</Box>
</Box>
)
}
export default Layout

View File

@ -0,0 +1,29 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { ThemeProvider, createTheme } from '@mui/material/styles'
import CssBaseline from '@mui/material/CssBaseline'
import App from './App'
const theme = createTheme({
palette: {
mode: 'light',
primary: {
main: '#1976d2',
},
secondary: {
main: '#dc004e',
},
},
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<ThemeProvider theme={theme}>
<CssBaseline />
<App />
</ThemeProvider>
</BrowserRouter>
</React.StrictMode>,
)

View File

@ -0,0 +1,153 @@
import { useEffect, useState } from 'react'
import {
Grid,
Paper,
Typography,
Box,
Card,
CardContent,
Chip,
} from '@mui/material'
import {
CheckCircle as CheckCircleIcon,
Error as ErrorIcon
} from '@mui/icons-material'
import axios from 'axios'
interface ServiceStatus {
name: string
status: 'healthy' | 'unhealthy'
endpoint: string
lastChecked: string
}
function Dashboard() {
const [services, setServices] = useState<ServiceStatus[]>([])
const [stats, setStats] = useState({
totalServices: 0,
healthyServices: 0,
unhealthyServices: 0,
})
useEffect(() => {
checkServices()
const interval = setInterval(checkServices, 10000)
return () => clearInterval(interval)
}, [])
const checkServices = async () => {
const serviceChecks = [
{ name: 'Console Backend', endpoint: '/api/health' },
{ name: 'Users Service', endpoint: '/api/users/health' },
]
const results = await Promise.all(
serviceChecks.map(async (service) => {
try {
await axios.get(service.endpoint)
return {
...service,
status: 'healthy' as const,
lastChecked: new Date().toLocaleTimeString(),
}
} catch {
return {
...service,
status: 'unhealthy' as const,
lastChecked: new Date().toLocaleTimeString(),
}
}
})
)
setServices(results)
const healthy = results.filter(s => s.status === 'healthy').length
setStats({
totalServices: results.length,
healthyServices: healthy,
unhealthyServices: results.length - healthy,
})
}
return (
<Box>
<Typography variant="h4" gutterBottom>
Dashboard
</Typography>
<Grid container spacing={3} sx={{ mb: 3 }}>
<Grid item xs={12} md={4}>
<Card>
<CardContent>
<Typography color="textSecondary" gutterBottom>
Total Services
</Typography>
<Typography variant="h3">
{stats.totalServices}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={4}>
<Card>
<CardContent>
<Typography color="textSecondary" gutterBottom>
Healthy Services
</Typography>
<Typography variant="h3" color="success.main">
{stats.healthyServices}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={4}>
<Card>
<CardContent>
<Typography color="textSecondary" gutterBottom>
Unhealthy Services
</Typography>
<Typography variant="h3" color="error.main">
{stats.unhealthyServices}
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
<Paper sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>
Service Status
</Typography>
<Grid container spacing={2}>
{services.map((service) => (
<Grid item xs={12} md={6} key={service.name}>
<Card variant="outlined">
<CardContent>
<Box display="flex" justifyContent="space-between" alignItems="center">
<Box>
<Typography variant="h6">{service.name}</Typography>
<Typography variant="body2" color="textSecondary">
{service.endpoint}
</Typography>
<Typography variant="caption" color="textSecondary">
Last checked: {service.lastChecked}
</Typography>
</Box>
<Chip
label={service.status}
color={service.status === 'healthy' ? 'success' : 'error'}
icon={service.status === 'healthy' ? <CheckCircleIcon /> : <ErrorIcon />}
/>
</Box>
</CardContent>
</Card>
</Grid>
))}
</Grid>
</Paper>
</Box>
)
}
export default Dashboard

View File

@ -0,0 +1,98 @@
import {
Box,
Typography,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Chip,
} from '@mui/material'
const servicesData = [
{
id: 1,
name: 'Console',
type: 'API Gateway',
port: 8011,
status: 'Running',
description: 'Central orchestrator and API gateway',
},
{
id: 2,
name: 'Users',
type: 'Microservice',
port: 8001,
status: 'Running',
description: 'User management service',
},
{
id: 3,
name: 'MongoDB',
type: 'Database',
port: 27017,
status: 'Running',
description: 'Document database for persistence',
},
{
id: 4,
name: 'Redis',
type: 'Cache',
port: 6379,
status: 'Running',
description: 'In-memory cache and pub/sub',
},
]
function Services() {
return (
<Box>
<Typography variant="h4" gutterBottom>
Services
</Typography>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Service Name</TableCell>
<TableCell>Type</TableCell>
<TableCell>Port</TableCell>
<TableCell>Status</TableCell>
<TableCell>Description</TableCell>
</TableRow>
</TableHead>
<TableBody>
{servicesData.map((service) => (
<TableRow key={service.id}>
<TableCell>
<Typography variant="subtitle2">{service.name}</Typography>
</TableCell>
<TableCell>
<Chip
label={service.type}
size="small"
color={service.type === 'API Gateway' ? 'primary' : 'default'}
/>
</TableCell>
<TableCell>{service.port}</TableCell>
<TableCell>
<Chip
label={service.status}
size="small"
color="success"
/>
</TableCell>
<TableCell>{service.description}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Box>
)
}
export default Services

View File

@ -0,0 +1,208 @@
import { useState, useEffect } from 'react'
import {
Box,
Typography,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Button,
IconButton,
TextField,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Stack,
} from '@mui/material'
import {
Add as AddIcon,
Edit as EditIcon,
Delete as DeleteIcon,
} from '@mui/icons-material'
import axios from 'axios'
interface User {
_id: string
username: string
email: string
full_name?: string
created_at: string
}
function Users() {
const [users, setUsers] = useState<User[]>([])
const [openDialog, setOpenDialog] = useState(false)
const [editingUser, setEditingUser] = useState<User | null>(null)
const [formData, setFormData] = useState({
username: '',
email: '',
full_name: '',
})
useEffect(() => {
fetchUsers()
}, [])
const fetchUsers = async () => {
try {
const response = await axios.get('/api/users/')
setUsers(response.data)
} catch (error) {
console.error('Failed to fetch users:', error)
}
}
const handleOpenDialog = (user?: User) => {
if (user) {
setEditingUser(user)
setFormData({
username: user.username,
email: user.email,
full_name: user.full_name || '',
})
} else {
setEditingUser(null)
setFormData({
username: '',
email: '',
full_name: '',
})
}
setOpenDialog(true)
}
const handleCloseDialog = () => {
setOpenDialog(false)
setEditingUser(null)
setFormData({
username: '',
email: '',
full_name: '',
})
}
const handleSubmit = async () => {
try {
if (editingUser) {
await axios.put(`/api/users/${editingUser._id}`, formData)
} else {
await axios.post('/api/users/', formData)
}
fetchUsers()
handleCloseDialog()
} catch (error) {
console.error('Failed to save user:', error)
}
}
const handleDelete = async (id: string) => {
if (confirm('Are you sure you want to delete this user?')) {
try {
await axios.delete(`/api/users/${id}`)
fetchUsers()
} catch (error) {
console.error('Failed to delete user:', error)
}
}
}
return (
<Box>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Typography variant="h4">
Users
</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => handleOpenDialog()}
>
Add User
</Button>
</Box>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Username</TableCell>
<TableCell>Email</TableCell>
<TableCell>Full Name</TableCell>
<TableCell>Created At</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{users.map((user) => (
<TableRow key={user._id}>
<TableCell>{user.username}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>{user.full_name || '-'}</TableCell>
<TableCell>
{new Date(user.created_at).toLocaleDateString()}
</TableCell>
<TableCell align="right">
<IconButton
size="small"
onClick={() => handleOpenDialog(user)}
>
<EditIcon />
</IconButton>
<IconButton
size="small"
onClick={() => handleDelete(user._id)}
>
<DeleteIcon />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Dialog open={openDialog} onClose={handleCloseDialog} maxWidth="sm" fullWidth>
<DialogTitle>
{editingUser ? 'Edit User' : 'Add New User'}
</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField
label="Username"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
fullWidth
required
/>
<TextField
label="Email"
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
fullWidth
required
/>
<TextField
label="Full Name"
value={formData.full_name}
onChange={(e) => setFormData({ ...formData, full_name: e.target.value })}
fullWidth
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDialog}>Cancel</Button>
<Button onClick={handleSubmit} variant="contained">
{editingUser ? 'Update' : 'Create'}
</Button>
</DialogActions>
</Dialog>
</Box>
)
}
export default Users

View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 3000,
proxy: {
'/api': {
target: 'http://console-backend:8000',
changeOrigin: true
}
}
}
})