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:
@ -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() {
|
||||
}
|
||||
/>
|
||||
|
||||
<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 */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
33
services/news-engine-console/frontend/src/api/articles.ts
Normal file
33
services/news-engine-console/frontend/src/api/articles.ts
Normal 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
|
||||
}
|
||||
466
services/news-engine-console/frontend/src/pages/Applications.tsx
Normal file
466
services/news-engine-console/frontend/src/pages/Applications.tsx
Normal 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
|
||||
460
services/news-engine-console/frontend/src/pages/Articles.tsx
Normal file
460
services/news-engine-console/frontend/src/pages/Articles.tsx
Normal 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
|
||||
400
services/news-engine-console/frontend/src/pages/Monitoring.tsx
Normal file
400
services/news-engine-console/frontend/src/pages/Monitoring.tsx
Normal 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
|
||||
@ -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
|
||||
// =====================================================
|
||||
|
||||
Reference in New Issue
Block a user