feat: Complete remaining management pages (Applications, Articles, Monitoring)

Frontend Implementation:
- Applications page: OAuth app management with client ID/secret
  * Client secret regeneration with secure display
  * Redirect URI management with chip interface
  * Copy-to-clipboard for credentials

- Articles page: News articles browser with filters
  * Category, translation, and image status filters
  * Article detail modal with full content
  * Retry controls for failed translations/images
  * Server-side pagination support

- Monitoring page: System health and metrics dashboard
  * Real-time CPU, memory, and disk usage
  * Database statistics display
  * Services status monitoring
  * Recent logs table with level filtering
  * Auto-refresh toggle (30s interval)

All pages follow the established DataGrid + MainLayout pattern with:
- Consistent UI/UX across all management pages
- Material-UI components and styling
- Error handling and loading states
- Full API integration with backend endpoints

🤖 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 22:00:13 +09:00
parent a9024ef9a1
commit d6ae03f42b
6 changed files with 1416 additions and 0 deletions

View File

@ -5,6 +5,9 @@ import Dashboard from './pages/Dashboard'
import Keywords from './pages/Keywords' import Keywords from './pages/Keywords'
import Pipelines from './pages/Pipelines' import Pipelines from './pages/Pipelines'
import Users from './pages/Users' import Users from './pages/Users'
import Applications from './pages/Applications'
import Articles from './pages/Articles'
import Monitoring from './pages/Monitoring'
function App() { function App() {
const { isAuthenticated } = useAuthStore() const { isAuthenticated } = useAuthStore()
@ -48,6 +51,27 @@ function App() {
} }
/> />
<Route
path="/applications"
element={
isAuthenticated ? <Applications /> : <Navigate to="/login" replace />
}
/>
<Route
path="/articles"
element={
isAuthenticated ? <Articles /> : <Navigate to="/login" replace />
}
/>
<Route
path="/monitoring"
element={
isAuthenticated ? <Monitoring /> : <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,33 @@
import apiClient from './client'
import type { Article, ArticleFilter, PaginatedResponse } from '@/types'
export const getArticles = async (
filters?: ArticleFilter & { skip?: number; limit?: number }
): Promise<PaginatedResponse<Article>> => {
const response = await apiClient.get<PaginatedResponse<Article>>('/api/v1/articles/', {
params: filters,
})
return response.data
}
export const getArticle = async (articleId: string): Promise<Article> => {
const response = await apiClient.get<Article>(`/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<Article> => {
const response = await apiClient.post<Article>(`/api/v1/articles/${articleId}/retry-translation`)
return response.data
}
export const retryImageGeneration = async (articleId: string): Promise<Article> => {
const response = await apiClient.post<Article>(
`/api/v1/articles/${articleId}/retry-image-generation`
)
return response.data
}

View File

@ -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<Application[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [successMessage, setSuccessMessage] = useState<string | null>(null)
// Dialog states
const [openDialog, setOpenDialog] = useState(false)
const [dialogMode, setDialogMode] = useState<'create' | 'edit'>('create')
const [selectedApplication, setSelectedApplication] = useState<Application | null>(null)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [secretDialogOpen, setSecretDialogOpen] = useState(false)
const [newSecret, setNewSecret] = useState<string | null>(null)
// Form states
const [formData, setFormData] = useState<ApplicationCreate | ApplicationUpdate>({
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) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: '0.75rem' }}>
{params.value?.substring(0, 16)}...
</Typography>
<IconButton
size="small"
onClick={() => handleCopyToClipboard(params.value)}
title="Copy Client ID"
>
<CopyIcon fontSize="small" />
</IconButton>
</Box>
),
},
{
field: 'description',
headerName: 'Description',
flex: 1,
minWidth: 200,
},
{
field: 'redirect_uris',
headerName: 'Redirect URIs',
width: 100,
align: 'center',
renderCell: (params: GridRenderCellParams) => (
<Chip label={params.value?.length || 0} size="small" color="primary" />
),
},
{
field: 'disabled',
headerName: 'Status',
width: 100,
renderCell: (params: GridRenderCellParams) => (
<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: 200,
sortable: false,
renderCell: (params: GridRenderCellParams) => {
const application = params.row as Application
return (
<Box>
<IconButton
size="small"
onClick={() => handleRegenerateSecret(application)}
color="warning"
title="Regenerate Secret"
>
<RegenerateIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={() => handleOpenDialog('edit', application)}
color="primary"
title="Edit"
>
<EditIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={() => {
setSelectedApplication(application)
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">
Applications Management
</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => handleOpenDialog('create')}
>
Add Application
</Button>
</Box>
{/* Error Alert */}
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
{/* Success Snackbar */}
<Snackbar
open={!!successMessage}
autoHideDuration={3000}
onClose={() => setSuccessMessage(null)}
message={successMessage}
/>
{/* Filters */}
<Paper sx={{ p: 2, mb: 2 }}>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<IconButton onClick={fetchApplications} color="primary">
<RefreshIcon />
</IconButton>
</Box>
</Paper>
{/* Data Grid */}
<Paper sx={{ height: 600, width: '100%' }}>
<DataGrid
rows={applications}
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 Application' : 'Edit Application'}
</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 2 }}>
<TextField
label="Application Name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
fullWidth
required
/>
<TextField
label="Description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
fullWidth
multiline
rows={3}
/>
<Box>
<Typography variant="subtitle2" gutterBottom>
Redirect URIs
</Typography>
<Box sx={{ display: 'flex', gap: 1, mb: 1 }}>
<TextField
label="Add Redirect URI"
value={redirectUriInput}
onChange={(e) => setRedirectUriInput(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAddRedirectUri()
}
}}
size="small"
fullWidth
placeholder="https://example.com/callback"
/>
<Button onClick={handleAddRedirectUri} variant="outlined" size="small">
Add
</Button>
</Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{formData.redirect_uris?.map((uri) => (
<Chip
key={uri}
label={uri}
onDelete={() => handleRemoveRedirectUri(uri)}
size="small"
/>
))}
</Box>
</Box>
</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 application "{selectedApplication?.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>
{/* Secret Display Dialog */}
<Dialog
open={secretDialogOpen}
onClose={() => {
setSecretDialogOpen(false)
setNewSecret(null)
}}
maxWidth="sm"
fullWidth
>
<DialogTitle>Client Secret</DialogTitle>
<DialogContent>
<Alert severity="warning" sx={{ mb: 2 }}>
This is the only time you will see this secret. Please copy it and store it securely.
</Alert>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<TextField
value={newSecret || ''}
fullWidth
InputProps={{
readOnly: true,
style: { fontFamily: 'monospace' },
}}
/>
<IconButton onClick={() => handleCopyToClipboard(newSecret || '')} color="primary">
<CopyIcon />
</IconButton>
</Box>
</DialogContent>
<DialogActions>
<Button
onClick={() => {
setSecretDialogOpen(false)
setNewSecret(null)
}}
variant="contained"
>
I've Saved It
</Button>
</DialogActions>
</Dialog>
</Box>
</MainLayout>
)
}
export default Applications

View File

@ -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<Article[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [total, setTotal] = useState(0)
const [paginationModel, setPaginationModel] = useState({ page: 0, pageSize: 25 })
// Filter states
const [search, setSearch] = useState('')
const [categoryFilter, setCategoryFilter] = useState<string>('all')
const [translationStatusFilter, setTranslationStatusFilter] = useState<string>('all')
const [imageStatusFilter, setImageStatusFilter] = useState<string>('all')
// Dialog states
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [selectedArticle, setSelectedArticle] = useState<Article | null>(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) => (
<Box
sx={{ cursor: 'pointer' }}
onClick={() => {
setSelectedArticle(params.row)
setDetailDialogOpen(true)
}}
>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{params.value}
</Typography>
</Box>
),
},
{
field: 'source',
headerName: 'Source',
width: 150,
},
{
field: 'category',
headerName: 'Category',
width: 120,
renderCell: (params: GridRenderCellParams) => {
if (!params.value) return null
const colorMap: Record<string, 'primary' | 'secondary' | 'success'> = {
people: 'primary',
topics: 'secondary',
companies: 'success',
}
return <Chip label={params.value} color={colorMap[params.value] || 'default'} size="small" />
},
},
{
field: 'language',
headerName: 'Language',
width: 80,
renderCell: (params: GridRenderCellParams) => (
<Chip label={params.value?.toUpperCase()} size="small" variant="outlined" />
),
},
{
field: 'translation_status',
headerName: 'Translation',
width: 120,
renderCell: (params: GridRenderCellParams) => {
const colorMap: Record<string, 'default' | 'warning' | 'success' | 'error'> = {
pending: 'default',
processing: 'warning',
completed: 'success',
failed: 'error',
}
return (
<Chip
label={params.value || 'N/A'}
color={colorMap[params.value] || 'default'}
size="small"
/>
)
},
},
{
field: 'image_status',
headerName: 'Image',
width: 100,
renderCell: (params: GridRenderCellParams) => {
const colorMap: Record<string, 'default' | 'warning' | 'success' | 'error'> = {
pending: 'default',
processing: 'warning',
completed: 'success',
failed: 'error',
}
return (
<Chip
label={params.value || 'N/A'}
color={colorMap[params.value] || 'default'}
size="small"
/>
)
},
},
{
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 (
<Box>
{article.url && (
<IconButton
size="small"
component="a"
href={article.url}
target="_blank"
rel="noopener noreferrer"
color="primary"
title="Open Article"
>
<OpenIcon fontSize="small" />
</IconButton>
)}
{article.translation_status === 'failed' && (
<IconButton
size="small"
onClick={() => handleRetryTranslation(article)}
color="warning"
title="Retry Translation"
>
<TranslateIcon fontSize="small" />
</IconButton>
)}
{article.image_status === 'failed' && (
<IconButton
size="small"
onClick={() => handleRetryImage(article)}
color="info"
title="Retry Image"
>
<ImageIcon fontSize="small" />
</IconButton>
)}
<IconButton
size="small"
onClick={() => {
setSelectedArticle(article)
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">
Articles
</Typography>
</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', flexWrap: 'wrap' }}>
<TextField
placeholder="Search by keyword..."
value={search}
onChange={(e) => setSearch(e.target.value)}
size="small"
sx={{ flexGrow: 1, minWidth: 200 }}
InputProps={{
startAdornment: <SearchIcon sx={{ color: 'action.active', mr: 1 }} />,
}}
/>
<TextField
select
label="Category"
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
size="small"
sx={{ minWidth: 150 }}
>
<MenuItem value="all">All Categories</MenuItem>
<MenuItem value="people">People</MenuItem>
<MenuItem value="topics">Topics</MenuItem>
<MenuItem value="companies">Companies</MenuItem>
</TextField>
<TextField
select
label="Translation"
value={translationStatusFilter}
onChange={(e) => setTranslationStatusFilter(e.target.value)}
size="small"
sx={{ minWidth: 150 }}
>
<MenuItem value="all">All Status</MenuItem>
<MenuItem value="pending">Pending</MenuItem>
<MenuItem value="processing">Processing</MenuItem>
<MenuItem value="completed">Completed</MenuItem>
<MenuItem value="failed">Failed</MenuItem>
</TextField>
<TextField
select
label="Image"
value={imageStatusFilter}
onChange={(e) => setImageStatusFilter(e.target.value)}
size="small"
sx={{ minWidth: 150 }}
>
<MenuItem value="all">All Status</MenuItem>
<MenuItem value="pending">Pending</MenuItem>
<MenuItem value="processing">Processing</MenuItem>
<MenuItem value="completed">Completed</MenuItem>
<MenuItem value="failed">Failed</MenuItem>
</TextField>
<IconButton onClick={fetchArticles} color="primary">
<RefreshIcon />
</IconButton>
</Box>
</Paper>
{/* Data Grid */}
<Paper sx={{ height: 600, width: '100%' }}>
<DataGrid
rows={articles}
columns={columns}
loading={loading}
pageSizeOptions={[10, 25, 50, 100]}
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
paginationMode="server"
rowCount={total}
disableRowSelectionOnClick
sx={{
'& .MuiDataGrid-cell:focus': {
outline: 'none',
},
}}
/>
</Paper>
{/* Article Detail Dialog */}
<Dialog
open={detailDialogOpen}
onClose={() => setDetailDialogOpen(false)}
maxWidth="md"
fullWidth
>
<DialogTitle>{selectedArticle?.title}</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
<Box>
<Typography variant="subtitle2" color="text.secondary">
Source
</Typography>
<Typography>{selectedArticle?.source}</Typography>
</Box>
{selectedArticle?.author && (
<Box>
<Typography variant="subtitle2" color="text.secondary">
Author
</Typography>
<Typography>{selectedArticle.author}</Typography>
</Box>
)}
{selectedArticle?.url && (
<Box>
<Typography variant="subtitle2" color="text.secondary">
URL
</Typography>
<Link href={selectedArticle.url} target="_blank" rel="noopener noreferrer">
{selectedArticle.url}
</Link>
</Box>
)}
{selectedArticle?.keywords && selectedArticle.keywords.length > 0 && (
<Box>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Keywords
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{selectedArticle.keywords.map((keyword) => (
<Chip key={keyword} label={keyword} size="small" />
))}
</Box>
</Box>
)}
{selectedArticle?.summary && (
<Box>
<Typography variant="subtitle2" color="text.secondary">
Summary
</Typography>
<Typography>{selectedArticle.summary}</Typography>
</Box>
)}
{selectedArticle?.content && (
<Box>
<Typography variant="subtitle2" color="text.secondary">
Content
</Typography>
<Typography sx={{ whiteSpace: 'pre-wrap' }}>
{selectedArticle.content.substring(0, 1000)}
{selectedArticle.content.length > 1000 && '...'}
</Typography>
</Box>
)}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setDetailDialogOpen(false)}>Close</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 article "{selectedArticle?.title}"? 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 Articles

View File

@ -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<MonitoringOverview | null>(null)
const [health, setHealth] = useState<SystemStatus | null>(null)
const [dbStats, setDbStats] = useState<DatabaseStats | null>(null)
const [logs, setLogs] = useState<LogEntry[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(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 <HealthyIcon color="success" />
case 'degraded':
return <WarningIcon color="warning" />
case 'down':
return <ErrorIcon color="error" />
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 (
<MainLayout>
<Box>
{/* Header */}
<Box sx={{ mb: 3, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h4" component="h1">
System Monitoring
</Typography>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<Chip
label={autoRefresh ? 'Auto-refresh ON' : 'Auto-refresh OFF'}
color={autoRefresh ? 'success' : 'default'}
onClick={() => setAutoRefresh(!autoRefresh)}
size="small"
/>
<IconButton onClick={fetchData} color="primary" disabled={loading}>
<RefreshIcon />
</IconButton>
</Box>
</Box>
{/* Error Alert */}
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
{loading && !overview && <LinearProgress sx={{ mb: 2 }} />}
{/* System Status Cards */}
<Grid container spacing={3} sx={{ mb: 3 }}>
{/* Health Status */}
<Grid item xs={12} md={3}>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
{getStatusIcon(health?.status || overview?.system.status || 'unknown')}
<Typography variant="h6">System Health</Typography>
</Box>
<Chip
label={health?.status || overview?.system.status || 'Unknown'}
color={getStatusColor(health?.status || overview?.system.status || 'unknown')}
size="small"
/>
{health?.uptime_seconds && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
Uptime: {formatUptime(health.uptime_seconds)}
</Typography>
)}
</CardContent>
</Card>
</Grid>
{/* CPU Usage */}
<Grid item xs={12} md={3}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
CPU Usage
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ flexGrow: 1 }}>
<LinearProgress
variant="determinate"
value={overview?.metrics.cpu_percent || 0}
color={
(overview?.metrics.cpu_percent || 0) > 80
? 'error'
: (overview?.metrics.cpu_percent || 0) > 60
? 'warning'
: 'primary'
}
/>
</Box>
<Typography variant="body2">
{overview?.metrics.cpu_percent?.toFixed(1) || 0}%
</Typography>
</Box>
</CardContent>
</Card>
</Grid>
{/* Memory Usage */}
<Grid item xs={12} md={3}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Memory Usage
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ flexGrow: 1 }}>
<LinearProgress
variant="determinate"
value={overview?.metrics.memory_percent || 0}
color={
(overview?.metrics.memory_percent || 0) > 80
? 'error'
: (overview?.metrics.memory_percent || 0) > 60
? 'warning'
: 'primary'
}
/>
</Box>
<Typography variant="body2">
{overview?.metrics.memory_percent?.toFixed(1) || 0}%
</Typography>
</Box>
{overview?.metrics.memory_used_mb && (
<Typography variant="caption" color="text.secondary">
{(overview.metrics.memory_used_mb / 1024).toFixed(2)} GB /{' '}
{(overview.metrics.memory_total_mb / 1024).toFixed(2)} GB
</Typography>
)}
</CardContent>
</Card>
</Grid>
{/* Disk Usage */}
<Grid item xs={12} md={3}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Disk Usage
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ flexGrow: 1 }}>
<LinearProgress
variant="determinate"
value={overview?.metrics.disk_percent || 0}
color={
(overview?.metrics.disk_percent || 0) > 80
? 'error'
: (overview?.metrics.disk_percent || 0) > 60
? 'warning'
: 'primary'
}
/>
</Box>
<Typography variant="body2">
{overview?.metrics.disk_percent?.toFixed(1) || 0}%
</Typography>
</Box>
{overview?.metrics.disk_used_gb && (
<Typography variant="caption" color="text.secondary">
{overview.metrics.disk_used_gb.toFixed(2)} GB /{' '}
{overview.metrics.disk_total_gb.toFixed(2)} GB
</Typography>
)}
</CardContent>
</Card>
</Grid>
</Grid>
{/* Database Stats */}
{dbStats && (
<Paper sx={{ p: 2, mb: 3 }}>
<Typography variant="h6" gutterBottom>
Database Statistics
</Typography>
<Grid container spacing={2}>
<Grid item xs={3}>
<Typography variant="body2" color="text.secondary">
Collections
</Typography>
<Typography variant="h5">{dbStats.collections}</Typography>
</Grid>
<Grid item xs={3}>
<Typography variant="body2" color="text.secondary">
Total Documents
</Typography>
<Typography variant="h5">{dbStats.total_documents.toLocaleString()}</Typography>
</Grid>
<Grid item xs={3}>
<Typography variant="body2" color="text.secondary">
Total Size
</Typography>
<Typography variant="h5">{dbStats.total_size_mb.toFixed(2)} MB</Typography>
</Grid>
<Grid item xs={3}>
<Typography variant="body2" color="text.secondary">
Indexes
</Typography>
<Typography variant="h5">{dbStats.indexes}</Typography>
</Grid>
</Grid>
</Paper>
)}
{/* Services Status */}
{overview?.services && overview.services.length > 0 && (
<Paper sx={{ p: 2, mb: 3 }}>
<Typography variant="h6" gutterBottom>
Services Status
</Typography>
<Grid container spacing={2}>
{overview.services.map((service) => (
<Grid item xs={12} sm={6} md={4} key={service.name}>
<Box
sx={{
p: 2,
border: 1,
borderColor: 'divider',
borderRadius: 1,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Box>
<Typography variant="body1">{service.name}</Typography>
{service.response_time_ms && (
<Typography variant="caption" color="text.secondary">
{service.response_time_ms}ms
</Typography>
)}
</Box>
<Chip
label={service.status}
color={getStatusColor(service.status)}
size="small"
/>
</Box>
</Grid>
))}
</Grid>
</Paper>
)}
{/* Recent Logs */}
<Paper sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>
Recent Logs
</Typography>
<TableContainer sx={{ maxHeight: 400 }}>
<Table stickyHeader size="small">
<TableHead>
<TableRow>
<TableCell>Timestamp</TableCell>
<TableCell>Level</TableCell>
<TableCell>Source</TableCell>
<TableCell>Message</TableCell>
</TableRow>
</TableHead>
<TableBody>
{logs.length === 0 ? (
<TableRow>
<TableCell colSpan={4} align="center">
<Typography variant="body2" color="text.secondary">
No logs available
</Typography>
</TableCell>
</TableRow>
) : (
logs.map((log, index) => (
<TableRow key={index}>
<TableCell sx={{ whiteSpace: 'nowrap' }}>
{new Date(log.timestamp).toLocaleString()}
</TableCell>
<TableCell>
<Chip label={log.level} color={getLogLevelColor(log.level)} size="small" />
</TableCell>
<TableCell>{log.source || '-'}</TableCell>
<TableCell>{log.message}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</Paper>
</Box>
</MainLayout>
)
}
export default Monitoring

View File

@ -233,6 +233,39 @@ export interface MonitoringOverview {
recent_logs: LogEntry[] 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 // API Response Types
// ===================================================== // =====================================================