From a9024ef9a1b8e77d4e442662ea80316a61f93642 Mon Sep 17 00:00:00 2001 From: jungwoo choi Date: Tue, 4 Nov 2025 21:50:52 +0900 Subject: [PATCH] feat: Add Pipelines and Users management pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../news-engine-console/frontend/src/App.tsx | 16 + .../frontend/src/pages/Pipelines.tsx | 459 ++++++++++++++++++ .../frontend/src/pages/Users.tsx | 425 ++++++++++++++++ 3 files changed, 900 insertions(+) create mode 100644 services/news-engine-console/frontend/src/pages/Pipelines.tsx create mode 100644 services/news-engine-console/frontend/src/pages/Users.tsx diff --git a/services/news-engine-console/frontend/src/App.tsx b/services/news-engine-console/frontend/src/App.tsx index d133eda..6a9e75b 100644 --- a/services/news-engine-console/frontend/src/App.tsx +++ b/services/news-engine-console/frontend/src/App.tsx @@ -3,6 +3,8 @@ import { useAuthStore } from './stores/authStore' import Login from './pages/Login' import Dashboard from './pages/Dashboard' import Keywords from './pages/Keywords' +import Pipelines from './pages/Pipelines' +import Users from './pages/Users' function App() { const { isAuthenticated } = useAuthStore() @@ -32,6 +34,20 @@ function App() { } /> + : + } + /> + + : + } + /> + {/* Catch all - redirect to dashboard or login */} } /> diff --git a/services/news-engine-console/frontend/src/pages/Pipelines.tsx b/services/news-engine-console/frontend/src/pages/Pipelines.tsx new file mode 100644 index 0000000..78ee37c --- /dev/null +++ b/services/news-engine-console/frontend/src/pages/Pipelines.tsx @@ -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([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [typeFilter, setTypeFilter] = useState('all') + const [statusFilter, setStatusFilter] = useState('all') + + // Dialog states + const [openDialog, setOpenDialog] = useState(false) + const [dialogMode, setDialogMode] = useState<'create' | 'edit'>('create') + const [selectedPipeline, setSelectedPipeline] = useState(null) + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + + // Form states + const [formData, setFormData] = useState({ + 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 = { + rss_collector: 'primary', + translator: 'secondary', + image_generator: 'success', + } + return ( + + ) + }, + }, + { + field: 'status', + headerName: 'Status', + width: 100, + renderCell: (params: GridRenderCellParams) => { + const colorMap: Record = { + running: 'success', + stopped: 'default', + error: 'error', + } + return ( + + ) + }, + }, + { + 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 ( + + {pipeline.status !== 'running' && ( + handleStart(pipeline)} + color="success" + title="Start" + > + + + )} + {pipeline.status === 'running' && ( + handleStop(pipeline)} + color="error" + title="Stop" + > + + + )} + handleRestart(pipeline)} + color="info" + title="Restart" + > + + + handleOpenDialog('edit', pipeline)} + color="primary" + title="Edit" + > + + + { + setSelectedPipeline(pipeline) + setDeleteDialogOpen(true) + }} + color="error" + title="Delete" + > + + + + ) + }, + }, + ] + + return ( + + + {/* Header */} + + + Pipelines Management + + + + + {/* Error Alert */} + {error && ( + setError(null)}> + {error} + + )} + + {/* Filters */} + + + setTypeFilter(e.target.value)} + size="small" + sx={{ minWidth: 180 }} + > + All Types + RSS Collector + Translator + Image Generator + + setStatusFilter(e.target.value)} + size="small" + sx={{ minWidth: 120 }} + > + All Status + Running + Stopped + Error + + + + + + + + {/* Data Grid */} + + + + + {/* Create/Edit Dialog */} + + + {dialogMode === 'create' ? 'Add New Pipeline' : 'Edit Pipeline'} + + + + setFormData({ ...formData, name: e.target.value })} + fullWidth + required + /> + setFormData({ ...formData, type: e.target.value as any })} + fullWidth + required + > + RSS Collector + Translator + Image Generator + + setFormData({ ...formData, schedule: e.target.value })} + fullWidth + placeholder="0 */6 * * *" + helperText="Cron format: minute hour day month weekday" + /> + { + try { + const config = JSON.parse(e.target.value) + setFormData({ ...formData, config }) + } catch (err) { + // Invalid JSON, ignore + } + }} + fullWidth + multiline + rows={4} + placeholder='{"key": "value"}' + /> + + + + + + + + + {/* Delete Confirmation Dialog */} + setDeleteDialogOpen(false)}> + Confirm Delete + + + Are you sure you want to delete the pipeline "{selectedPipeline?.name}"? + This action cannot be undone. + + + + + + + + + + ) +} + +export default Pipelines diff --git a/services/news-engine-console/frontend/src/pages/Users.tsx b/services/news-engine-console/frontend/src/pages/Users.tsx new file mode 100644 index 0000000..81479e4 --- /dev/null +++ b/services/news-engine-console/frontend/src/pages/Users.tsx @@ -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([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [roleFilter, setRoleFilter] = useState('all') + const [statusFilter, setStatusFilter] = useState('all') + + // Dialog states + const [openDialog, setOpenDialog] = useState(false) + const [dialogMode, setDialogMode] = useState<'create' | 'edit'>('create') + const [selectedUser, setSelectedUser] = useState(null) + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + + // Form states + const [formData, setFormData] = useState({ + 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 = { + admin: 'error', + editor: 'warning', + viewer: 'success', + } + return ( + + ) + }, + }, + { + field: 'disabled', + headerName: 'Status', + width: 100, + renderCell: (params: GridRenderCellParams) => { + return ( + + ) + }, + }, + { + 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 ( + + handleToggleStatus(user)} + color={user.disabled ? 'success' : 'warning'} + title={user.disabled ? 'Enable' : 'Disable'} + > + {user.disabled ? : } + + handleOpenDialog('edit', user)} + color="primary" + title="Edit" + > + + + { + setSelectedUser(user) + setDeleteDialogOpen(true) + }} + color="error" + title="Delete" + > + + + + ) + }, + }, + ] + + return ( + + + {/* Header */} + + + Users Management + + + + + {/* Error Alert */} + {error && ( + setError(null)}> + {error} + + )} + + {/* Filters */} + + + setRoleFilter(e.target.value)} + size="small" + sx={{ minWidth: 120 }} + > + All Roles + Admin + Editor + Viewer + + setStatusFilter(e.target.value)} + size="small" + sx={{ minWidth: 120 }} + > + All Status + Active + Disabled + + + + + + + + {/* Data Grid */} + + + + + {/* Create/Edit Dialog */} + + + {dialogMode === 'create' ? 'Add New User' : 'Edit User'} + + + + {dialogMode === 'create' && ( + setFormData({ ...formData, username: e.target.value })} + fullWidth + required + /> + )} + setFormData({ ...formData, full_name: e.target.value })} + fullWidth + required + /> + setFormData({ ...formData, email: e.target.value })} + fullWidth + required + /> + {dialogMode === 'create' && ( + setFormData({ ...formData, password: e.target.value })} + fullWidth + required + /> + )} + setFormData({ ...formData, role: e.target.value as any })} + fullWidth + required + > + Admin + Editor + Viewer + + + + + + + + + + {/* Delete Confirmation Dialog */} + setDeleteDialogOpen(false)}> + Confirm Delete + + + Are you sure you want to delete the user "{selectedUser?.username}"? + This action cannot be undone. + + + + + + + + + + ) +} + +export default Users