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
+
+ }
+ onClick={() => handleOpenDialog('create')}
+ >
+ Add Pipeline
+
+
+
+ {/* Error Alert */}
+ {error && (
+ setError(null)}>
+ {error}
+
+ )}
+
+ {/* Filters */}
+
+
+ setTypeFilter(e.target.value)}
+ size="small"
+ sx={{ minWidth: 180 }}
+ >
+
+
+
+
+
+ setStatusFilter(e.target.value)}
+ size="small"
+ sx={{ minWidth: 120 }}
+ >
+
+
+
+
+
+
+
+
+
+
+
+ {/* Data Grid */}
+
+
+
+
+ {/* Create/Edit Dialog */}
+
+
+ {/* Delete Confirmation Dialog */}
+
+
+
+ )
+}
+
+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
+
+ }
+ onClick={() => handleOpenDialog('create')}
+ >
+ Add User
+
+
+
+ {/* Error Alert */}
+ {error && (
+ setError(null)}>
+ {error}
+
+ )}
+
+ {/* Filters */}
+
+
+ setRoleFilter(e.target.value)}
+ size="small"
+ sx={{ minWidth: 120 }}
+ >
+
+
+
+
+
+ setStatusFilter(e.target.value)}
+ size="small"
+ sx={{ minWidth: 120 }}
+ >
+
+
+
+
+
+
+
+
+
+
+ {/* Data Grid */}
+
+
+
+
+ {/* Create/Edit Dialog */}
+
+
+ {/* Delete Confirmation Dialog */}
+
+
+
+ )
+}
+
+export default Users