diff --git a/services/news-engine-console/frontend/src/App.tsx b/services/news-engine-console/frontend/src/App.tsx
index 6a9e75b..34148fc 100644
--- a/services/news-engine-console/frontend/src/App.tsx
+++ b/services/news-engine-console/frontend/src/App.tsx
@@ -5,6 +5,9 @@ import Dashboard from './pages/Dashboard'
import Keywords from './pages/Keywords'
import Pipelines from './pages/Pipelines'
import Users from './pages/Users'
+import Applications from './pages/Applications'
+import Articles from './pages/Articles'
+import Monitoring from './pages/Monitoring'
function App() {
const { isAuthenticated } = useAuthStore()
@@ -48,6 +51,27 @@ function App() {
}
/>
+ :
+ }
+ />
+
+ :
+ }
+ />
+
+ :
+ }
+ />
+
{/* Catch all - redirect to dashboard or login */}
} />
diff --git a/services/news-engine-console/frontend/src/api/articles.ts b/services/news-engine-console/frontend/src/api/articles.ts
new file mode 100644
index 0000000..7fe3b55
--- /dev/null
+++ b/services/news-engine-console/frontend/src/api/articles.ts
@@ -0,0 +1,33 @@
+import apiClient from './client'
+import type { Article, ArticleFilter, PaginatedResponse } from '@/types'
+
+export const getArticles = async (
+ filters?: ArticleFilter & { skip?: number; limit?: number }
+): Promise> => {
+ const response = await apiClient.get>('/api/v1/articles/', {
+ params: filters,
+ })
+ return response.data
+}
+
+export const getArticle = async (articleId: string): Promise => {
+ const response = await apiClient.get(`/api/v1/articles/${articleId}`)
+ return response.data
+}
+
+export const deleteArticle = async (articleId: string): Promise<{ message: string }> => {
+ const response = await apiClient.delete<{ message: string }>(`/api/v1/articles/${articleId}`)
+ return response.data
+}
+
+export const retryTranslation = async (articleId: string): Promise => {
+ const response = await apiClient.post(`/api/v1/articles/${articleId}/retry-translation`)
+ return response.data
+}
+
+export const retryImageGeneration = async (articleId: string): Promise => {
+ const response = await apiClient.post(
+ `/api/v1/articles/${articleId}/retry-image-generation`
+ )
+ return response.data
+}
diff --git a/services/news-engine-console/frontend/src/pages/Applications.tsx b/services/news-engine-console/frontend/src/pages/Applications.tsx
new file mode 100644
index 0000000..01b6a80
--- /dev/null
+++ b/services/news-engine-console/frontend/src/pages/Applications.tsx
@@ -0,0 +1,466 @@
+import { useState, useEffect } from 'react'
+import {
+ Box,
+ Button,
+ Paper,
+ Typography,
+ IconButton,
+ Chip,
+ TextField,
+ Dialog,
+ DialogTitle,
+ DialogContent,
+ DialogActions,
+ Alert,
+ Snackbar,
+} from '@mui/material'
+import {
+ Add as AddIcon,
+ Edit as EditIcon,
+ Delete as DeleteIcon,
+ Refresh as RefreshIcon,
+ VpnKey as RegenerateIcon,
+ ContentCopy as CopyIcon,
+} from '@mui/icons-material'
+import { DataGrid, GridColDef, GridRenderCellParams } from '@mui/x-data-grid'
+import MainLayout from '../components/MainLayout'
+import {
+ getApplications,
+ createApplication,
+ updateApplication,
+ deleteApplication,
+ regenerateSecret,
+} from '@/api/applications'
+import type { Application, ApplicationCreate, ApplicationUpdate } from '@/types'
+
+const Applications = () => {
+ const [applications, setApplications] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const [successMessage, setSuccessMessage] = useState(null)
+
+ // Dialog states
+ const [openDialog, setOpenDialog] = useState(false)
+ const [dialogMode, setDialogMode] = useState<'create' | 'edit'>('create')
+ const [selectedApplication, setSelectedApplication] = useState(null)
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
+ const [secretDialogOpen, setSecretDialogOpen] = useState(false)
+ const [newSecret, setNewSecret] = useState(null)
+
+ // Form states
+ const [formData, setFormData] = useState({
+ name: '',
+ description: '',
+ redirect_uris: [],
+ })
+ const [redirectUriInput, setRedirectUriInput] = useState('')
+
+ useEffect(() => {
+ fetchApplications()
+ }, [])
+
+ const fetchApplications = async () => {
+ setLoading(true)
+ setError(null)
+ try {
+ const data = await getApplications()
+ setApplications(data)
+ } catch (err: any) {
+ setError(err.message || 'Failed to fetch applications')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleOpenDialog = (mode: 'create' | 'edit', application?: Application) => {
+ setDialogMode(mode)
+ if (mode === 'edit' && application) {
+ setSelectedApplication(application)
+ setFormData({
+ name: application.name,
+ description: application.description,
+ redirect_uris: application.redirect_uris,
+ })
+ } else {
+ setSelectedApplication(null)
+ setFormData({
+ name: '',
+ description: '',
+ redirect_uris: [],
+ })
+ }
+ setRedirectUriInput('')
+ setOpenDialog(true)
+ }
+
+ const handleCloseDialog = () => {
+ setOpenDialog(false)
+ setSelectedApplication(null)
+ setFormData({
+ name: '',
+ description: '',
+ redirect_uris: [],
+ })
+ setRedirectUriInput('')
+ }
+
+ const handleAddRedirectUri = () => {
+ if (redirectUriInput && !formData.redirect_uris?.includes(redirectUriInput)) {
+ setFormData({
+ ...formData,
+ redirect_uris: [...(formData.redirect_uris || []), redirectUriInput],
+ })
+ setRedirectUriInput('')
+ }
+ }
+
+ const handleRemoveRedirectUri = (uri: string) => {
+ setFormData({
+ ...formData,
+ redirect_uris: formData.redirect_uris?.filter((u) => u !== uri) || [],
+ })
+ }
+
+ const handleSubmit = async () => {
+ try {
+ if (dialogMode === 'create') {
+ const result = await createApplication(formData as ApplicationCreate)
+ setNewSecret(result.client_secret)
+ setSecretDialogOpen(true)
+ } else if (selectedApplication?.id) {
+ await updateApplication(selectedApplication.id, formData as ApplicationUpdate)
+ setSuccessMessage('Application updated successfully')
+ }
+ handleCloseDialog()
+ fetchApplications()
+ } catch (err: any) {
+ setError(err.message || 'Failed to save application')
+ }
+ }
+
+ const handleDelete = async () => {
+ if (!selectedApplication?.id) return
+
+ try {
+ await deleteApplication(selectedApplication.id)
+ setDeleteDialogOpen(false)
+ setSelectedApplication(null)
+ setSuccessMessage('Application deleted successfully')
+ fetchApplications()
+ } catch (err: any) {
+ setError(err.message || 'Failed to delete application')
+ }
+ }
+
+ const handleRegenerateSecret = async (application: Application) => {
+ if (!application.id) return
+ try {
+ const result = await regenerateSecret(application.id)
+ setNewSecret(result.client_secret)
+ setSecretDialogOpen(true)
+ setSuccessMessage('Secret regenerated successfully')
+ } catch (err: any) {
+ setError(err.message || 'Failed to regenerate secret')
+ }
+ }
+
+ const handleCopyToClipboard = (text: string) => {
+ navigator.clipboard.writeText(text)
+ setSuccessMessage('Copied to clipboard')
+ }
+
+ const columns: GridColDef[] = [
+ {
+ field: 'name',
+ headerName: 'Application Name',
+ flex: 1,
+ minWidth: 200,
+ },
+ {
+ field: 'client_id',
+ headerName: 'Client ID',
+ width: 200,
+ renderCell: (params: GridRenderCellParams) => (
+
+
+ {params.value?.substring(0, 16)}...
+
+ handleCopyToClipboard(params.value)}
+ title="Copy Client ID"
+ >
+
+
+
+ ),
+ },
+ {
+ field: 'description',
+ headerName: 'Description',
+ flex: 1,
+ minWidth: 200,
+ },
+ {
+ field: 'redirect_uris',
+ headerName: 'Redirect URIs',
+ width: 100,
+ align: 'center',
+ renderCell: (params: GridRenderCellParams) => (
+
+ ),
+ },
+ {
+ field: 'disabled',
+ headerName: 'Status',
+ width: 100,
+ renderCell: (params: GridRenderCellParams) => (
+
+ ),
+ },
+ {
+ field: 'created_at',
+ headerName: 'Created At',
+ width: 180,
+ valueFormatter: (value) => {
+ return new Date(value).toLocaleString()
+ },
+ },
+ {
+ field: 'actions',
+ headerName: 'Actions',
+ width: 200,
+ sortable: false,
+ renderCell: (params: GridRenderCellParams) => {
+ const application = params.row as Application
+ return (
+
+ handleRegenerateSecret(application)}
+ color="warning"
+ title="Regenerate Secret"
+ >
+
+
+ handleOpenDialog('edit', application)}
+ color="primary"
+ title="Edit"
+ >
+
+
+ {
+ setSelectedApplication(application)
+ setDeleteDialogOpen(true)
+ }}
+ color="error"
+ title="Delete"
+ >
+
+
+
+ )
+ },
+ },
+ ]
+
+ return (
+
+
+ {/* Header */}
+
+
+ Applications Management
+
+ }
+ onClick={() => handleOpenDialog('create')}
+ >
+ Add Application
+
+
+
+ {/* Error Alert */}
+ {error && (
+ setError(null)}>
+ {error}
+
+ )}
+
+ {/* Success Snackbar */}
+ setSuccessMessage(null)}
+ message={successMessage}
+ />
+
+ {/* Filters */}
+
+
+
+
+
+
+
+
+ {/* Data Grid */}
+
+
+
+
+ {/* Create/Edit Dialog */}
+
+
+ {/* Delete Confirmation Dialog */}
+
+
+ {/* Secret Display Dialog */}
+
+
+
+ )
+}
+
+export default Applications
diff --git a/services/news-engine-console/frontend/src/pages/Articles.tsx b/services/news-engine-console/frontend/src/pages/Articles.tsx
new file mode 100644
index 0000000..6578c50
--- /dev/null
+++ b/services/news-engine-console/frontend/src/pages/Articles.tsx
@@ -0,0 +1,460 @@
+import { useState, useEffect } from 'react'
+import {
+ Box,
+ Button,
+ Paper,
+ Typography,
+ IconButton,
+ Chip,
+ TextField,
+ MenuItem,
+ Dialog,
+ DialogTitle,
+ DialogContent,
+ DialogActions,
+ Alert,
+ Link,
+} from '@mui/material'
+import {
+ Delete as DeleteIcon,
+ Refresh as RefreshIcon,
+ OpenInNew as OpenIcon,
+ Translate as TranslateIcon,
+ Image as ImageIcon,
+ Search as SearchIcon,
+} from '@mui/icons-material'
+import { DataGrid, GridColDef, GridRenderCellParams } from '@mui/x-data-grid'
+import MainLayout from '../components/MainLayout'
+import { getArticles, deleteArticle, retryTranslation, retryImageGeneration } from '@/api/articles'
+import type { Article, ArticleFilter } from '@/types'
+
+const Articles = () => {
+ const [articles, setArticles] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const [total, setTotal] = useState(0)
+ const [paginationModel, setPaginationModel] = useState({ page: 0, pageSize: 25 })
+
+ // Filter states
+ const [search, setSearch] = useState('')
+ const [categoryFilter, setCategoryFilter] = useState('all')
+ const [translationStatusFilter, setTranslationStatusFilter] = useState('all')
+ const [imageStatusFilter, setImageStatusFilter] = useState('all')
+
+ // Dialog states
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
+ const [selectedArticle, setSelectedArticle] = useState(null)
+ const [detailDialogOpen, setDetailDialogOpen] = useState(false)
+
+ useEffect(() => {
+ fetchArticles()
+ }, [search, categoryFilter, translationStatusFilter, imageStatusFilter, paginationModel])
+
+ const fetchArticles = async () => {
+ setLoading(true)
+ setError(null)
+ try {
+ const filters: ArticleFilter & { skip?: number; limit?: number } = {
+ skip: paginationModel.page * paginationModel.pageSize,
+ limit: paginationModel.pageSize,
+ }
+
+ if (search) filters.keyword = search
+ if (categoryFilter !== 'all') filters.category = categoryFilter as any
+ if (translationStatusFilter !== 'all') filters.translation_status = translationStatusFilter
+ if (imageStatusFilter !== 'all') filters.image_status = imageStatusFilter
+
+ const data = await getArticles(filters)
+ setArticles(data.items)
+ setTotal(data.total)
+ } catch (err: any) {
+ // If API not implemented yet, show empty state
+ if (err.response?.status === 404) {
+ setArticles([])
+ setTotal(0)
+ } else {
+ setError(err.message || 'Failed to fetch articles')
+ }
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleDelete = async () => {
+ if (!selectedArticle?.id) return
+
+ try {
+ await deleteArticle(selectedArticle.id)
+ setDeleteDialogOpen(false)
+ setSelectedArticle(null)
+ fetchArticles()
+ } catch (err: any) {
+ setError(err.message || 'Failed to delete article')
+ }
+ }
+
+ const handleRetryTranslation = async (article: Article) => {
+ if (!article.id) return
+ try {
+ await retryTranslation(article.id)
+ fetchArticles()
+ } catch (err: any) {
+ setError(err.message || 'Failed to retry translation')
+ }
+ }
+
+ const handleRetryImage = async (article: Article) => {
+ if (!article.id) return
+ try {
+ await retryImageGeneration(article.id)
+ fetchArticles()
+ } catch (err: any) {
+ setError(err.message || 'Failed to retry image generation')
+ }
+ }
+
+ const columns: GridColDef[] = [
+ {
+ field: 'title',
+ headerName: 'Title',
+ flex: 1,
+ minWidth: 300,
+ renderCell: (params: GridRenderCellParams) => (
+ {
+ setSelectedArticle(params.row)
+ setDetailDialogOpen(true)
+ }}
+ >
+
+ {params.value}
+
+
+ ),
+ },
+ {
+ field: 'source',
+ headerName: 'Source',
+ width: 150,
+ },
+ {
+ field: 'category',
+ headerName: 'Category',
+ width: 120,
+ renderCell: (params: GridRenderCellParams) => {
+ if (!params.value) return null
+ const colorMap: Record = {
+ people: 'primary',
+ topics: 'secondary',
+ companies: 'success',
+ }
+ return
+ },
+ },
+ {
+ field: 'language',
+ headerName: 'Language',
+ width: 80,
+ renderCell: (params: GridRenderCellParams) => (
+
+ ),
+ },
+ {
+ field: 'translation_status',
+ headerName: 'Translation',
+ width: 120,
+ renderCell: (params: GridRenderCellParams) => {
+ const colorMap: Record = {
+ pending: 'default',
+ processing: 'warning',
+ completed: 'success',
+ failed: 'error',
+ }
+ return (
+
+ )
+ },
+ },
+ {
+ field: 'image_status',
+ headerName: 'Image',
+ width: 100,
+ renderCell: (params: GridRenderCellParams) => {
+ const colorMap: Record = {
+ pending: 'default',
+ processing: 'warning',
+ completed: 'success',
+ failed: 'error',
+ }
+ return (
+
+ )
+ },
+ },
+ {
+ field: 'published_at',
+ headerName: 'Published',
+ width: 180,
+ valueFormatter: (value) => {
+ return new Date(value).toLocaleString()
+ },
+ },
+ {
+ field: 'actions',
+ headerName: 'Actions',
+ width: 180,
+ sortable: false,
+ renderCell: (params: GridRenderCellParams) => {
+ const article = params.row as Article
+ return (
+
+ {article.url && (
+
+
+
+ )}
+ {article.translation_status === 'failed' && (
+ handleRetryTranslation(article)}
+ color="warning"
+ title="Retry Translation"
+ >
+
+
+ )}
+ {article.image_status === 'failed' && (
+ handleRetryImage(article)}
+ color="info"
+ title="Retry Image"
+ >
+
+
+ )}
+ {
+ setSelectedArticle(article)
+ setDeleteDialogOpen(true)
+ }}
+ color="error"
+ title="Delete"
+ >
+
+
+
+ )
+ },
+ },
+ ]
+
+ return (
+
+
+ {/* Header */}
+
+
+ Articles
+
+
+
+ {/* Error Alert */}
+ {error && (
+ setError(null)}>
+ {error}
+
+ )}
+
+ {/* Filters */}
+
+
+ setSearch(e.target.value)}
+ size="small"
+ sx={{ flexGrow: 1, minWidth: 200 }}
+ InputProps={{
+ startAdornment: ,
+ }}
+ />
+ setCategoryFilter(e.target.value)}
+ size="small"
+ sx={{ minWidth: 150 }}
+ >
+
+
+
+
+
+ setTranslationStatusFilter(e.target.value)}
+ size="small"
+ sx={{ minWidth: 150 }}
+ >
+
+
+
+
+
+
+ setImageStatusFilter(e.target.value)}
+ size="small"
+ sx={{ minWidth: 150 }}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Data Grid */}
+
+
+
+
+ {/* Article Detail Dialog */}
+
+
+ {/* Delete Confirmation Dialog */}
+
+
+
+ )
+}
+
+export default Articles
diff --git a/services/news-engine-console/frontend/src/pages/Monitoring.tsx b/services/news-engine-console/frontend/src/pages/Monitoring.tsx
new file mode 100644
index 0000000..456b814
--- /dev/null
+++ b/services/news-engine-console/frontend/src/pages/Monitoring.tsx
@@ -0,0 +1,400 @@
+import { useState, useEffect } from 'react'
+import {
+ Box,
+ Paper,
+ Typography,
+ Grid,
+ Card,
+ CardContent,
+ Chip,
+ LinearProgress,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ Alert,
+ IconButton,
+} from '@mui/material'
+import {
+ Refresh as RefreshIcon,
+ CheckCircle as HealthyIcon,
+ Error as ErrorIcon,
+ Warning as WarningIcon,
+} from '@mui/icons-material'
+import MainLayout from '../components/MainLayout'
+import {
+ getMonitoringOverview,
+ getHealthCheck,
+ getDatabaseStats,
+ getRecentLogs,
+} from '@/api/monitoring'
+import type { MonitoringOverview, SystemStatus, DatabaseStats, LogEntry } from '@/types'
+
+const Monitoring = () => {
+ const [overview, setOverview] = useState(null)
+ const [health, setHealth] = useState(null)
+ const [dbStats, setDbStats] = useState(null)
+ const [logs, setLogs] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const [autoRefresh, setAutoRefresh] = useState(true)
+
+ useEffect(() => {
+ fetchData()
+ const interval = setInterval(() => {
+ if (autoRefresh) {
+ fetchData()
+ }
+ }, 30000) // Refresh every 30 seconds
+
+ return () => clearInterval(interval)
+ }, [autoRefresh])
+
+ const fetchData = async () => {
+ setLoading(true)
+ setError(null)
+ try {
+ const [overviewData, healthData, dbData, logsData] = await Promise.all([
+ getMonitoringOverview().catch(() => null),
+ getHealthCheck().catch(() => null),
+ getDatabaseStats().catch(() => null),
+ getRecentLogs({ limit: 50 }).catch(() => []),
+ ])
+ setOverview(overviewData)
+ setHealth(healthData)
+ setDbStats(dbData)
+ setLogs(logsData)
+ } catch (err: any) {
+ setError(err.message || 'Failed to fetch monitoring data')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const getStatusColor = (status: string) => {
+ switch (status) {
+ case 'healthy':
+ case 'up':
+ return 'success'
+ case 'degraded':
+ return 'warning'
+ case 'down':
+ return 'error'
+ default:
+ return 'default'
+ }
+ }
+
+ const getStatusIcon = (status: string) => {
+ switch (status) {
+ case 'healthy':
+ case 'up':
+ return
+ case 'degraded':
+ return
+ case 'down':
+ return
+ default:
+ return null
+ }
+ }
+
+ const formatUptime = (seconds: number) => {
+ const days = Math.floor(seconds / 86400)
+ const hours = Math.floor((seconds % 86400) / 3600)
+ const minutes = Math.floor((seconds % 3600) / 60)
+ return `${days}d ${hours}h ${minutes}m`
+ }
+
+ const getLogLevelColor = (level: string): 'default' | 'info' | 'warning' | 'error' => {
+ switch (level) {
+ case 'error':
+ return 'error'
+ case 'warning':
+ return 'warning'
+ case 'info':
+ return 'info'
+ default:
+ return 'default'
+ }
+ }
+
+ return (
+
+
+ {/* Header */}
+
+
+ System Monitoring
+
+
+ setAutoRefresh(!autoRefresh)}
+ size="small"
+ />
+
+
+
+
+
+
+ {/* Error Alert */}
+ {error && (
+ setError(null)}>
+ {error}
+
+ )}
+
+ {loading && !overview && }
+
+ {/* System Status Cards */}
+
+ {/* Health Status */}
+
+
+
+
+ {getStatusIcon(health?.status || overview?.system.status || 'unknown')}
+ System Health
+
+
+ {health?.uptime_seconds && (
+
+ Uptime: {formatUptime(health.uptime_seconds)}
+
+ )}
+
+
+
+
+ {/* CPU Usage */}
+
+
+
+
+ CPU Usage
+
+
+
+ 80
+ ? 'error'
+ : (overview?.metrics.cpu_percent || 0) > 60
+ ? 'warning'
+ : 'primary'
+ }
+ />
+
+
+ {overview?.metrics.cpu_percent?.toFixed(1) || 0}%
+
+
+
+
+
+
+ {/* Memory Usage */}
+
+
+
+
+ Memory Usage
+
+
+
+ 80
+ ? 'error'
+ : (overview?.metrics.memory_percent || 0) > 60
+ ? 'warning'
+ : 'primary'
+ }
+ />
+
+
+ {overview?.metrics.memory_percent?.toFixed(1) || 0}%
+
+
+ {overview?.metrics.memory_used_mb && (
+
+ {(overview.metrics.memory_used_mb / 1024).toFixed(2)} GB /{' '}
+ {(overview.metrics.memory_total_mb / 1024).toFixed(2)} GB
+
+ )}
+
+
+
+
+ {/* Disk Usage */}
+
+
+
+
+ Disk Usage
+
+
+
+ 80
+ ? 'error'
+ : (overview?.metrics.disk_percent || 0) > 60
+ ? 'warning'
+ : 'primary'
+ }
+ />
+
+
+ {overview?.metrics.disk_percent?.toFixed(1) || 0}%
+
+
+ {overview?.metrics.disk_used_gb && (
+
+ {overview.metrics.disk_used_gb.toFixed(2)} GB /{' '}
+ {overview.metrics.disk_total_gb.toFixed(2)} GB
+
+ )}
+
+
+
+
+
+ {/* Database Stats */}
+ {dbStats && (
+
+
+ Database Statistics
+
+
+
+
+ Collections
+
+ {dbStats.collections}
+
+
+
+ Total Documents
+
+ {dbStats.total_documents.toLocaleString()}
+
+
+
+ Total Size
+
+ {dbStats.total_size_mb.toFixed(2)} MB
+
+
+
+ Indexes
+
+ {dbStats.indexes}
+
+
+
+ )}
+
+ {/* Services Status */}
+ {overview?.services && overview.services.length > 0 && (
+
+
+ Services Status
+
+
+ {overview.services.map((service) => (
+
+
+
+ {service.name}
+ {service.response_time_ms && (
+
+ {service.response_time_ms}ms
+
+ )}
+
+
+
+
+ ))}
+
+
+ )}
+
+ {/* Recent Logs */}
+
+
+ Recent Logs
+
+
+
+
+
+ Timestamp
+ Level
+ Source
+ Message
+
+
+
+ {logs.length === 0 ? (
+
+
+
+ No logs available
+
+
+
+ ) : (
+ logs.map((log, index) => (
+
+
+ {new Date(log.timestamp).toLocaleString()}
+
+
+
+
+ {log.source || '-'}
+ {log.message}
+
+ ))
+ )}
+
+
+
+
+
+
+ )
+}
+
+export default Monitoring
diff --git a/services/news-engine-console/frontend/src/types/index.ts b/services/news-engine-console/frontend/src/types/index.ts
index 9b93b98..126523e 100644
--- a/services/news-engine-console/frontend/src/types/index.ts
+++ b/services/news-engine-console/frontend/src/types/index.ts
@@ -233,6 +233,39 @@ export interface MonitoringOverview {
recent_logs: LogEntry[]
}
+// =====================================================
+// Article Types
+// =====================================================
+
+export interface Article {
+ id?: string
+ title: string
+ content?: string
+ summary?: string
+ url: string
+ source: string
+ author?: string
+ published_at: string
+ keywords: string[]
+ category?: Category
+ language: string
+ translation_status?: 'pending' | 'processing' | 'completed' | 'failed'
+ image_status?: 'pending' | 'processing' | 'completed' | 'failed'
+ created_at: string
+ updated_at?: string
+}
+
+export interface ArticleFilter {
+ keyword?: string
+ category?: Category
+ source?: string
+ language?: string
+ translation_status?: string
+ image_status?: string
+ start_date?: string
+ end_date?: string
+}
+
// =====================================================
// API Response Types
// =====================================================