feat: Add Pipelines and Users management pages

Frontend Phase 2 - Additional Management Pages:
- Implement Pipelines management with Start/Stop/Restart controls
- Implement Users management with role assignment and enable/disable
- Add routing for Pipelines and Users pages

Pipelines Page Features:
- DataGrid table with pipeline list
- Type filter (RSS Collector, Translator, Image Generator)
- Status filter (Running, Stopped, Error)
- Pipeline controls (Start, Stop, Restart)
- Add/Edit pipeline dialog with JSON config editor
- Delete confirmation dialog
- Success rate display
- Cron schedule management

Users Page Features:
- DataGrid table with user list
- Role filter (Admin, Editor, Viewer)
- Status filter (Active, Disabled)
- Enable/Disable user toggle
- Add/Edit user dialog with role selection
- Delete confirmation dialog
- Password management for new users

Progress:
 Keywords Management
 Pipelines Management
 Users Management
 Applications Management (pending)
 Articles List (pending)
 Monitoring Page (pending)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
jungwoo choi
2025-11-04 21:50:52 +09:00
parent 30fe4d0368
commit a9024ef9a1
3 changed files with 900 additions and 0 deletions

View File

@ -3,6 +3,8 @@ import { useAuthStore } from './stores/authStore'
import Login from './pages/Login' import Login from './pages/Login'
import Dashboard from './pages/Dashboard' import Dashboard from './pages/Dashboard'
import Keywords from './pages/Keywords' import Keywords from './pages/Keywords'
import Pipelines from './pages/Pipelines'
import Users from './pages/Users'
function App() { function App() {
const { isAuthenticated } = useAuthStore() const { isAuthenticated } = useAuthStore()
@ -32,6 +34,20 @@ function App() {
} }
/> />
<Route
path="/pipelines"
element={
isAuthenticated ? <Pipelines /> : <Navigate to="/login" replace />
}
/>
<Route
path="/users"
element={
isAuthenticated ? <Users /> : <Navigate to="/login" replace />
}
/>
{/* Catch all - redirect to dashboard or login */} {/* Catch all - redirect to dashboard or login */}
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>

View File

@ -0,0 +1,459 @@
import { useState, useEffect } from 'react'
import {
Box,
Button,
Paper,
Typography,
IconButton,
Chip,
TextField,
MenuItem,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Alert,
} from '@mui/material'
import {
Add as AddIcon,
Edit as EditIcon,
Delete as DeleteIcon,
PlayArrow as StartIcon,
Stop as StopIcon,
Refresh as RefreshIcon,
RestartAlt as RestartIcon,
} from '@mui/icons-material'
import { DataGrid, GridColDef, GridRenderCellParams } from '@mui/x-data-grid'
import MainLayout from '../components/MainLayout'
import {
getPipelines,
createPipeline,
updatePipeline,
deletePipeline,
startPipeline,
stopPipeline,
restartPipeline,
} from '@/api/pipelines'
import type { Pipeline, PipelineCreate, PipelineUpdate } from '@/types'
const Pipelines = () => {
const [pipelines, setPipelines] = useState<Pipeline[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [typeFilter, setTypeFilter] = useState<string>('all')
const [statusFilter, setStatusFilter] = useState<string>('all')
// Dialog states
const [openDialog, setOpenDialog] = useState(false)
const [dialogMode, setDialogMode] = useState<'create' | 'edit'>('create')
const [selectedPipeline, setSelectedPipeline] = useState<Pipeline | null>(null)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
// Form states
const [formData, setFormData] = useState<PipelineCreate>({
name: '',
type: 'rss_collector',
config: {},
schedule: '',
})
useEffect(() => {
fetchPipelines()
}, [typeFilter, statusFilter])
const fetchPipelines = async () => {
setLoading(true)
setError(null)
try {
const params: any = {}
if (typeFilter !== 'all') params.type = typeFilter
if (statusFilter !== 'all') params.status = statusFilter
const data = await getPipelines(params)
setPipelines(data)
} catch (err: any) {
setError(err.message || 'Failed to fetch pipelines')
} finally {
setLoading(false)
}
}
const handleOpenDialog = (mode: 'create' | 'edit', pipeline?: Pipeline) => {
setDialogMode(mode)
if (mode === 'edit' && pipeline) {
setSelectedPipeline(pipeline)
setFormData({
name: pipeline.name,
type: pipeline.type,
config: pipeline.config,
schedule: pipeline.schedule || '',
})
} else {
setSelectedPipeline(null)
setFormData({
name: '',
type: 'rss_collector',
config: {},
schedule: '',
})
}
setOpenDialog(true)
}
const handleCloseDialog = () => {
setOpenDialog(false)
setSelectedPipeline(null)
setFormData({
name: '',
type: 'rss_collector',
config: {},
schedule: '',
})
}
const handleSubmit = async () => {
try {
if (dialogMode === 'create') {
await createPipeline(formData)
} else if (selectedPipeline?.id) {
await updatePipeline(selectedPipeline.id, formData as PipelineUpdate)
}
handleCloseDialog()
fetchPipelines()
} catch (err: any) {
setError(err.message || 'Failed to save pipeline')
}
}
const handleDelete = async () => {
if (!selectedPipeline?.id) return
try {
await deletePipeline(selectedPipeline.id)
setDeleteDialogOpen(false)
setSelectedPipeline(null)
fetchPipelines()
} catch (err: any) {
setError(err.message || 'Failed to delete pipeline')
}
}
const handleStart = async (pipeline: Pipeline) => {
if (!pipeline.id) return
try {
await startPipeline(pipeline.id)
fetchPipelines()
} catch (err: any) {
setError(err.message || 'Failed to start pipeline')
}
}
const handleStop = async (pipeline: Pipeline) => {
if (!pipeline.id) return
try {
await stopPipeline(pipeline.id)
fetchPipelines()
} catch (err: any) {
setError(err.message || 'Failed to stop pipeline')
}
}
const handleRestart = async (pipeline: Pipeline) => {
if (!pipeline.id) return
try {
await restartPipeline(pipeline.id)
fetchPipelines()
} catch (err: any) {
setError(err.message || 'Failed to restart pipeline')
}
}
const columns: GridColDef[] = [
{
field: 'name',
headerName: 'Pipeline Name',
flex: 1,
minWidth: 200,
},
{
field: 'type',
headerName: 'Type',
width: 150,
renderCell: (params: GridRenderCellParams) => {
const colorMap: Record<string, 'primary' | 'secondary' | 'success'> = {
rss_collector: 'primary',
translator: 'secondary',
image_generator: 'success',
}
return (
<Chip
label={params.value.replace('_', ' ')}
color={colorMap[params.value] || 'default'}
size="small"
/>
)
},
},
{
field: 'status',
headerName: 'Status',
width: 100,
renderCell: (params: GridRenderCellParams) => {
const colorMap: Record<string, 'success' | 'error' | 'default'> = {
running: 'success',
stopped: 'default',
error: 'error',
}
return (
<Chip
label={params.value}
color={colorMap[params.value] || 'default'}
size="small"
/>
)
},
},
{
field: 'stats',
headerName: 'Success Rate',
width: 120,
valueGetter: (value) => {
if (!value) return 'N/A'
const total = value.total_runs
const successful = value.successful_runs
if (total === 0) return '0%'
return `${Math.round((successful / total) * 100)}%`
},
},
{
field: 'last_run',
headerName: 'Last Run',
width: 180,
valueFormatter: (value) => {
return value ? new Date(value).toLocaleString() : 'Never'
},
},
{
field: 'actions',
headerName: 'Actions',
width: 200,
sortable: false,
renderCell: (params: GridRenderCellParams) => {
const pipeline = params.row as Pipeline
return (
<Box>
{pipeline.status !== 'running' && (
<IconButton
size="small"
onClick={() => handleStart(pipeline)}
color="success"
title="Start"
>
<StartIcon fontSize="small" />
</IconButton>
)}
{pipeline.status === 'running' && (
<IconButton
size="small"
onClick={() => handleStop(pipeline)}
color="error"
title="Stop"
>
<StopIcon fontSize="small" />
</IconButton>
)}
<IconButton
size="small"
onClick={() => handleRestart(pipeline)}
color="info"
title="Restart"
>
<RestartIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={() => handleOpenDialog('edit', pipeline)}
color="primary"
title="Edit"
>
<EditIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={() => {
setSelectedPipeline(pipeline)
setDeleteDialogOpen(true)
}}
color="error"
title="Delete"
>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
)
},
},
]
return (
<MainLayout>
<Box>
{/* Header */}
<Box sx={{ mb: 3, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h4" component="h1">
Pipelines Management
</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => handleOpenDialog('create')}
>
Add Pipeline
</Button>
</Box>
{/* Error Alert */}
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
{/* Filters */}
<Paper sx={{ p: 2, mb: 2 }}>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<TextField
select
label="Type"
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
size="small"
sx={{ minWidth: 180 }}
>
<MenuItem value="all">All Types</MenuItem>
<MenuItem value="rss_collector">RSS Collector</MenuItem>
<MenuItem value="translator">Translator</MenuItem>
<MenuItem value="image_generator">Image Generator</MenuItem>
</TextField>
<TextField
select
label="Status"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
size="small"
sx={{ minWidth: 120 }}
>
<MenuItem value="all">All Status</MenuItem>
<MenuItem value="running">Running</MenuItem>
<MenuItem value="stopped">Stopped</MenuItem>
<MenuItem value="error">Error</MenuItem>
</TextField>
<IconButton onClick={fetchPipelines} color="primary">
<RefreshIcon />
</IconButton>
</Box>
</Paper>
{/* Data Grid */}
<Paper sx={{ height: 600, width: '100%' }}>
<DataGrid
rows={pipelines}
columns={columns}
loading={loading}
pageSizeOptions={[10, 25, 50, 100]}
initialState={{
pagination: {
paginationModel: { pageSize: 25 },
},
}}
disableRowSelectionOnClick
sx={{
'& .MuiDataGrid-cell:focus': {
outline: 'none',
},
}}
/>
</Paper>
{/* Create/Edit Dialog */}
<Dialog open={openDialog} onClose={handleCloseDialog} maxWidth="sm" fullWidth>
<DialogTitle>
{dialogMode === 'create' ? 'Add New Pipeline' : 'Edit Pipeline'}
</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 2 }}>
<TextField
label="Pipeline Name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
fullWidth
required
/>
<TextField
select
label="Type"
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value as any })}
fullWidth
required
>
<MenuItem value="rss_collector">RSS Collector</MenuItem>
<MenuItem value="translator">Translator</MenuItem>
<MenuItem value="image_generator">Image Generator</MenuItem>
</TextField>
<TextField
label="Schedule (Cron)"
value={formData.schedule}
onChange={(e) => setFormData({ ...formData, schedule: e.target.value })}
fullWidth
placeholder="0 */6 * * *"
helperText="Cron format: minute hour day month weekday"
/>
<TextField
label="Configuration (JSON)"
value={JSON.stringify(formData.config, null, 2)}
onChange={(e) => {
try {
const config = JSON.parse(e.target.value)
setFormData({ ...formData, config })
} catch (err) {
// Invalid JSON, ignore
}
}}
fullWidth
multiline
rows={4}
placeholder='{"key": "value"}'
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDialog}>Cancel</Button>
<Button onClick={handleSubmit} variant="contained" disabled={!formData.name}>
{dialogMode === 'create' ? 'Create' : 'Update'}
</Button>
</DialogActions>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}>
<DialogTitle>Confirm Delete</DialogTitle>
<DialogContent>
<Typography>
Are you sure you want to delete the pipeline "{selectedPipeline?.name}"?
This action cannot be undone.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
<Button onClick={handleDelete} variant="contained" color="error">
Delete
</Button>
</DialogActions>
</Dialog>
</Box>
</MainLayout>
)
}
export default Pipelines

View File

@ -0,0 +1,425 @@
import { useState, useEffect } from 'react'
import {
Box,
Button,
Paper,
Typography,
IconButton,
Chip,
TextField,
MenuItem,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Alert,
} from '@mui/material'
import {
Add as AddIcon,
Edit as EditIcon,
Delete as DeleteIcon,
Refresh as RefreshIcon,
Block as BlockIcon,
CheckCircle as EnableIcon,
} from '@mui/icons-material'
import { DataGrid, GridColDef, GridRenderCellParams } from '@mui/x-data-grid'
import MainLayout from '../components/MainLayout'
import {
getUsers,
createUser,
updateUser,
deleteUser,
toggleUserStatus,
} from '@/api/users'
import type { User, UserCreate, UserUpdate } from '@/types'
const Users = () => {
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [roleFilter, setRoleFilter] = useState<string>('all')
const [statusFilter, setStatusFilter] = useState<string>('all')
// Dialog states
const [openDialog, setOpenDialog] = useState(false)
const [dialogMode, setDialogMode] = useState<'create' | 'edit'>('create')
const [selectedUser, setSelectedUser] = useState<User | null>(null)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
// Form states
const [formData, setFormData] = useState<UserCreate | UserUpdate>({
username: '',
email: '',
password: '',
full_name: '',
role: 'viewer',
})
useEffect(() => {
fetchUsers()
}, [roleFilter, statusFilter])
const fetchUsers = async () => {
setLoading(true)
setError(null)
try {
const params: any = {}
if (roleFilter !== 'all') params.role = roleFilter
if (statusFilter === 'disabled') params.disabled = true
if (statusFilter === 'enabled') params.disabled = false
const data = await getUsers(params)
setUsers(data)
} catch (err: any) {
setError(err.message || 'Failed to fetch users')
} finally {
setLoading(false)
}
}
const handleOpenDialog = (mode: 'create' | 'edit', user?: User) => {
setDialogMode(mode)
if (mode === 'edit' && user) {
setSelectedUser(user)
setFormData({
email: user.email,
full_name: user.full_name,
role: user.role,
})
} else {
setSelectedUser(null)
setFormData({
username: '',
email: '',
password: '',
full_name: '',
role: 'viewer',
})
}
setOpenDialog(true)
}
const handleCloseDialog = () => {
setOpenDialog(false)
setSelectedUser(null)
setFormData({
username: '',
email: '',
password: '',
full_name: '',
role: 'viewer',
})
}
const handleSubmit = async () => {
try {
if (dialogMode === 'create') {
await createUser(formData as UserCreate)
} else if (selectedUser?.id) {
await updateUser(selectedUser.id, formData as UserUpdate)
}
handleCloseDialog()
fetchUsers()
} catch (err: any) {
setError(err.message || 'Failed to save user')
}
}
const handleDelete = async () => {
if (!selectedUser?.id) return
try {
await deleteUser(selectedUser.id)
setDeleteDialogOpen(false)
setSelectedUser(null)
fetchUsers()
} catch (err: any) {
setError(err.message || 'Failed to delete user')
}
}
const handleToggleStatus = async (user: User) => {
if (!user.id) return
try {
await toggleUserStatus(user.id)
fetchUsers()
} catch (err: any) {
setError(err.message || 'Failed to toggle user status')
}
}
const columns: GridColDef[] = [
{
field: 'username',
headerName: 'Username',
flex: 1,
minWidth: 150,
},
{
field: 'full_name',
headerName: 'Full Name',
flex: 1,
minWidth: 150,
},
{
field: 'email',
headerName: 'Email',
flex: 1,
minWidth: 200,
},
{
field: 'role',
headerName: 'Role',
width: 100,
renderCell: (params: GridRenderCellParams) => {
const colorMap: Record<string, 'error' | 'warning' | 'success'> = {
admin: 'error',
editor: 'warning',
viewer: 'success',
}
return (
<Chip
label={params.value}
color={colorMap[params.value] || 'default'}
size="small"
/>
)
},
},
{
field: 'disabled',
headerName: 'Status',
width: 100,
renderCell: (params: GridRenderCellParams) => {
return (
<Chip
label={params.value ? 'Disabled' : 'Active'}
color={params.value ? 'default' : 'success'}
size="small"
/>
)
},
},
{
field: 'created_at',
headerName: 'Created At',
width: 180,
valueFormatter: (value) => {
return new Date(value).toLocaleString()
},
},
{
field: 'actions',
headerName: 'Actions',
width: 180,
sortable: false,
renderCell: (params: GridRenderCellParams) => {
const user = params.row as User
return (
<Box>
<IconButton
size="small"
onClick={() => handleToggleStatus(user)}
color={user.disabled ? 'success' : 'warning'}
title={user.disabled ? 'Enable' : 'Disable'}
>
{user.disabled ? <EnableIcon fontSize="small" /> : <BlockIcon fontSize="small" />}
</IconButton>
<IconButton
size="small"
onClick={() => handleOpenDialog('edit', user)}
color="primary"
title="Edit"
>
<EditIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={() => {
setSelectedUser(user)
setDeleteDialogOpen(true)
}}
color="error"
title="Delete"
>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
)
},
},
]
return (
<MainLayout>
<Box>
{/* Header */}
<Box sx={{ mb: 3, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h4" component="h1">
Users Management
</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => handleOpenDialog('create')}
>
Add User
</Button>
</Box>
{/* Error Alert */}
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
{/* Filters */}
<Paper sx={{ p: 2, mb: 2 }}>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<TextField
select
label="Role"
value={roleFilter}
onChange={(e) => setRoleFilter(e.target.value)}
size="small"
sx={{ minWidth: 120 }}
>
<MenuItem value="all">All Roles</MenuItem>
<MenuItem value="admin">Admin</MenuItem>
<MenuItem value="editor">Editor</MenuItem>
<MenuItem value="viewer">Viewer</MenuItem>
</TextField>
<TextField
select
label="Status"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
size="small"
sx={{ minWidth: 120 }}
>
<MenuItem value="all">All Status</MenuItem>
<MenuItem value="enabled">Active</MenuItem>
<MenuItem value="disabled">Disabled</MenuItem>
</TextField>
<IconButton onClick={fetchUsers} color="primary">
<RefreshIcon />
</IconButton>
</Box>
</Paper>
{/* Data Grid */}
<Paper sx={{ height: 600, width: '100%' }}>
<DataGrid
rows={users}
columns={columns}
loading={loading}
pageSizeOptions={[10, 25, 50, 100]}
initialState={{
pagination: {
paginationModel: { pageSize: 25 },
},
}}
disableRowSelectionOnClick
sx={{
'& .MuiDataGrid-cell:focus': {
outline: 'none',
},
}}
/>
</Paper>
{/* Create/Edit Dialog */}
<Dialog open={openDialog} onClose={handleCloseDialog} maxWidth="sm" fullWidth>
<DialogTitle>
{dialogMode === 'create' ? 'Add New User' : 'Edit User'}
</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 2 }}>
{dialogMode === 'create' && (
<TextField
label="Username"
value={(formData as UserCreate).username || ''}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
fullWidth
required
/>
)}
<TextField
label="Full Name"
value={formData.full_name}
onChange={(e) => setFormData({ ...formData, full_name: e.target.value })}
fullWidth
required
/>
<TextField
label="Email"
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
fullWidth
required
/>
{dialogMode === 'create' && (
<TextField
label="Password"
type="password"
value={(formData as UserCreate).password || ''}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
fullWidth
required
/>
)}
<TextField
select
label="Role"
value={formData.role}
onChange={(e) => setFormData({ ...formData, role: e.target.value as any })}
fullWidth
required
>
<MenuItem value="admin">Admin</MenuItem>
<MenuItem value="editor">Editor</MenuItem>
<MenuItem value="viewer">Viewer</MenuItem>
</TextField>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDialog}>Cancel</Button>
<Button
onClick={handleSubmit}
variant="contained"
disabled={
!formData.full_name ||
!formData.email ||
(dialogMode === 'create' && !(formData as UserCreate).username) ||
(dialogMode === 'create' && !(formData as UserCreate).password)
}
>
{dialogMode === 'create' ? 'Create' : 'Update'}
</Button>
</DialogActions>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}>
<DialogTitle>Confirm Delete</DialogTitle>
<DialogContent>
<Typography>
Are you sure you want to delete the user "{selectedUser?.username}"?
This action cannot be undone.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
<Button onClick={handleDelete} variant="contained" color="error">
Delete
</Button>
</DialogActions>
</Dialog>
</Box>
</MainLayout>
)
}
export default Users