feat: Implement Phase 2 Frontend basic structure
Frontend Setup:
- Vite + React 18 + TypeScript configuration
- Material-UI v7 integration
- React Query for data fetching
- Zustand for state management
- React Router for routing
Project Configuration:
- package.json with all dependencies (React, MUI, TanStack Query, Zustand, etc.)
- tsconfig.json with path aliases (@/components, @/pages, etc.)
- vite.config.ts with dev server and proxy settings
- Dockerfile and Dockerfile.dev for production and development
- nginx.conf for production deployment
- .env and .gitignore files
- docker-compose.yml for local development
TypeScript Types:
- Complete type definitions for all API models
- User, Keyword, Pipeline, Application types
- Monitoring and system status types
- API response and pagination types
API Client Implementation:
- axios client with interceptors
- Token refresh logic
- Error handling
- Auto token injection
- Complete API service functions:
* users.ts (11 endpoints)
* keywords.ts (8 endpoints)
* pipelines.ts (11 endpoints)
* applications.ts (7 endpoints)
* monitoring.ts (8 endpoints)
State Management:
- authStore with Zustand
- Login/logout functionality
- Token persistence
- Current user management
Pages Implemented:
- Login page with MUI components
- Dashboard page with basic layout
- App.tsx with protected routes
Docker Configuration:
- docker-compose.yml for backend + frontend
- Dockerfile for production build
- Dockerfile.dev for development hot reload
Files Created: 23 files
- Frontend structure: src/{api,pages,stores,types}
- Configuration files: 8 files
- Docker files: 3 files
Next Steps:
- Test frontend in Docker environment
- Implement sidebar navigation
- Create full management pages (Keywords, Pipelines, Users, etc.)
- Connect to backend API and test authentication
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
46
services/news-engine-console/docker-compose.yml
Normal file
46
services/news-engine-console/docker-compose.yml
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: news-engine-console-backend
|
||||||
|
ports:
|
||||||
|
- "8101:8101"
|
||||||
|
environment:
|
||||||
|
- MONGODB_URL=mongodb://host.docker.internal:27017
|
||||||
|
- DB_NAME=news_engine_console_db
|
||||||
|
- JWT_SECRET=your-secret-key-change-this-in-production
|
||||||
|
- JWT_ALGORITHM=HS256
|
||||||
|
- ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||||
|
- REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
command: python main.py
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- news-engine-console-network
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile.dev
|
||||||
|
container_name: news-engine-console-frontend
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- VITE_API_URL=http://localhost:8101
|
||||||
|
volumes:
|
||||||
|
- ./frontend:/app
|
||||||
|
- /app/node_modules
|
||||||
|
command: npm run dev
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- news-engine-console-network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
news-engine-console-network:
|
||||||
|
driver: bridge
|
||||||
2
services/news-engine-console/frontend/.env.example
Normal file
2
services/news-engine-console/frontend/.env.example
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
VITE_API_URL=http://localhost:8101
|
||||||
|
VITE_APP_TITLE=News Engine Console
|
||||||
29
services/news-engine-console/frontend/.gitignore
vendored
Normal file
29
services/news-engine-console/frontend/.gitignore
vendored
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
29
services/news-engine-console/frontend/Dockerfile
Normal file
29
services/news-engine-console/frontend/Dockerfile
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# Multi-stage build for production
|
||||||
|
FROM node:18-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Copy built files from builder
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Copy nginx configuration
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
18
services/news-engine-console/frontend/Dockerfile.dev
Normal file
18
services/news-engine-console/frontend/Dockerfile.dev
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Development Dockerfile for frontend
|
||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Start development server
|
||||||
|
CMD ["npm", "run", "dev"]
|
||||||
14
services/news-engine-console/frontend/index.html
Normal file
14
services/news-engine-console/frontend/index.html
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>News Engine Console</title>
|
||||||
|
<meta name="description" content="News Engine Console - Manage your news pipelines, keywords, and content generation" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
34
services/news-engine-console/frontend/nginx.conf
Normal file
34
services/news-engine-console/frontend/nginx.conf
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Enable gzip compression
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||||
|
|
||||||
|
# SPA routing - fallback to index.html
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy API requests to backend
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://news-engine-console-backend:8101;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
39
services/news-engine-console/frontend/package.json
Normal file
39
services/news-engine-console/frontend/package.json
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "news-engine-console-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "News Engine Console - Frontend (React + TypeScript + MUI v7)",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
|
"type-check": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.26.2",
|
||||||
|
"@mui/material": "^6.1.3",
|
||||||
|
"@mui/icons-material": "^6.1.3",
|
||||||
|
"@emotion/react": "^11.13.3",
|
||||||
|
"@emotion/styled": "^11.13.0",
|
||||||
|
"@tanstack/react-query": "^5.56.2",
|
||||||
|
"axios": "^1.7.7",
|
||||||
|
"recharts": "^2.12.7",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"zustand": "^5.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.10",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.8.0",
|
||||||
|
"@typescript-eslint/parser": "^8.8.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
|
"eslint": "^9.11.1",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.2",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.12",
|
||||||
|
"typescript": "^5.6.2",
|
||||||
|
"vite": "^5.4.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
33
services/news-engine-console/frontend/src/App.tsx
Normal file
33
services/news-engine-console/frontend/src/App.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
|
import { useAuthStore } from './stores/authStore'
|
||||||
|
import Login from './pages/Login'
|
||||||
|
import Dashboard from './pages/Dashboard'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const { isAuthenticated } = useAuthStore()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
isAuthenticated ? <Navigate to="/dashboard" replace /> : <Navigate to="/login" replace />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/dashboard"
|
||||||
|
element={
|
||||||
|
isAuthenticated ? <Dashboard /> : <Navigate to="/login" replace />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Catch all - redirect to dashboard or login */}
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
import apiClient from './client'
|
||||||
|
import type {
|
||||||
|
Application,
|
||||||
|
ApplicationCreate,
|
||||||
|
ApplicationUpdate,
|
||||||
|
ApplicationSecretResponse,
|
||||||
|
} from '@/types'
|
||||||
|
|
||||||
|
export const getApplications = async (params?: {
|
||||||
|
skip?: number
|
||||||
|
limit?: number
|
||||||
|
}): Promise<Application[]> => {
|
||||||
|
const response = await apiClient.get<Application[]>('/api/v1/applications/', { params })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getApplication = async (applicationId: string): Promise<Application> => {
|
||||||
|
const response = await apiClient.get<Application>(
|
||||||
|
`/api/v1/applications/${applicationId}`
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createApplication = async (
|
||||||
|
applicationData: ApplicationCreate
|
||||||
|
): Promise<Application> => {
|
||||||
|
const response = await apiClient.post<Application>(
|
||||||
|
'/api/v1/applications/',
|
||||||
|
applicationData
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateApplication = async (
|
||||||
|
applicationId: string,
|
||||||
|
applicationData: ApplicationUpdate
|
||||||
|
): Promise<Application> => {
|
||||||
|
const response = await apiClient.put<Application>(
|
||||||
|
`/api/v1/applications/${applicationId}`,
|
||||||
|
applicationData
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteApplication = async (
|
||||||
|
applicationId: string
|
||||||
|
): Promise<{ message: string }> => {
|
||||||
|
const response = await apiClient.delete<{ message: string }>(
|
||||||
|
`/api/v1/applications/${applicationId}`
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const regenerateSecret = async (
|
||||||
|
applicationId: string
|
||||||
|
): Promise<ApplicationSecretResponse> => {
|
||||||
|
const response = await apiClient.post<ApplicationSecretResponse>(
|
||||||
|
`/api/v1/applications/${applicationId}/regenerate-secret`
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getMyApplications = async (): Promise<Application[]> => {
|
||||||
|
const response = await apiClient.get<Application[]>('/api/v1/applications/my-apps')
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
78
services/news-engine-console/frontend/src/api/client.ts
Normal file
78
services/news-engine-console/frontend/src/api/client.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import axios, { AxiosError, AxiosInstance, AxiosRequestConfig } from 'axios'
|
||||||
|
import type { ApiError } from '@/types'
|
||||||
|
|
||||||
|
// Create axios instance with default config
|
||||||
|
const apiClient: AxiosInstance = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8101',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
timeout: 30000, // 30 seconds
|
||||||
|
})
|
||||||
|
|
||||||
|
// Request interceptor - add auth token
|
||||||
|
apiClient.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
const token = localStorage.getItem('access_token')
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Response interceptor - handle errors and token refresh
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error: AxiosError<ApiError>) => {
|
||||||
|
const originalRequest = error.config as AxiosRequestConfig & { _retry?: boolean }
|
||||||
|
|
||||||
|
// Handle 401 Unauthorized - token expired
|
||||||
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
|
originalRequest._retry = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const refreshToken = localStorage.getItem('refresh_token')
|
||||||
|
if (!refreshToken) {
|
||||||
|
throw new Error('No refresh token available')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to refresh the token
|
||||||
|
const response = await axios.post(
|
||||||
|
`${import.meta.env.VITE_API_URL || 'http://localhost:8101'}/api/v1/users/refresh`,
|
||||||
|
{ refresh_token: refreshToken }
|
||||||
|
)
|
||||||
|
|
||||||
|
const { access_token, refresh_token: new_refresh_token } = response.data
|
||||||
|
|
||||||
|
// Store new tokens
|
||||||
|
localStorage.setItem('access_token', access_token)
|
||||||
|
if (new_refresh_token) {
|
||||||
|
localStorage.setItem('refresh_token', new_refresh_token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry original request with new token
|
||||||
|
if (originalRequest.headers) {
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${access_token}`
|
||||||
|
}
|
||||||
|
return apiClient(originalRequest)
|
||||||
|
} catch (refreshError) {
|
||||||
|
// Refresh failed - redirect to login
|
||||||
|
localStorage.removeItem('access_token')
|
||||||
|
localStorage.removeItem('refresh_token')
|
||||||
|
localStorage.removeItem('user')
|
||||||
|
window.location.href = '/login'
|
||||||
|
return Promise.reject(refreshError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle other errors
|
||||||
|
const errorMessage = error.response?.data?.detail || error.message || 'An error occurred'
|
||||||
|
return Promise.reject({ message: errorMessage, status: error.response?.status })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default apiClient
|
||||||
7
services/news-engine-console/frontend/src/api/index.ts
Normal file
7
services/news-engine-console/frontend/src/api/index.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
// Export all API modules
|
||||||
|
export * from './users'
|
||||||
|
export * from './keywords'
|
||||||
|
export * from './pipelines'
|
||||||
|
export * from './applications'
|
||||||
|
export * from './monitoring'
|
||||||
|
export { default as apiClient } from './client'
|
||||||
72
services/news-engine-console/frontend/src/api/keywords.ts
Normal file
72
services/news-engine-console/frontend/src/api/keywords.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import apiClient from './client'
|
||||||
|
import type {
|
||||||
|
Keyword,
|
||||||
|
KeywordCreate,
|
||||||
|
KeywordUpdate,
|
||||||
|
KeywordStats,
|
||||||
|
KeywordBulkCreate,
|
||||||
|
} from '@/types'
|
||||||
|
|
||||||
|
export const getKeywords = async (params?: {
|
||||||
|
category?: string
|
||||||
|
status?: string
|
||||||
|
search?: string
|
||||||
|
skip?: number
|
||||||
|
limit?: number
|
||||||
|
sort_by?: string
|
||||||
|
sort_order?: 'asc' | 'desc'
|
||||||
|
}): Promise<Keyword[]> => {
|
||||||
|
const response = await apiClient.get<Keyword[]>('/api/v1/keywords/', { params })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getKeyword = async (keywordId: string): Promise<Keyword> => {
|
||||||
|
const response = await apiClient.get<Keyword>(`/api/v1/keywords/${keywordId}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createKeyword = async (keywordData: KeywordCreate): Promise<Keyword> => {
|
||||||
|
const response = await apiClient.post<Keyword>('/api/v1/keywords/', keywordData)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateKeyword = async (
|
||||||
|
keywordId: string,
|
||||||
|
keywordData: KeywordUpdate
|
||||||
|
): Promise<Keyword> => {
|
||||||
|
const response = await apiClient.put<Keyword>(
|
||||||
|
`/api/v1/keywords/${keywordId}`,
|
||||||
|
keywordData
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteKeyword = async (keywordId: string): Promise<{ message: string }> => {
|
||||||
|
const response = await apiClient.delete<{ message: string }>(
|
||||||
|
`/api/v1/keywords/${keywordId}`
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toggleKeywordStatus = async (keywordId: string): Promise<Keyword> => {
|
||||||
|
const response = await apiClient.post<Keyword>(`/api/v1/keywords/${keywordId}/toggle`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getKeywordStats = async (keywordId: string): Promise<KeywordStats> => {
|
||||||
|
const response = await apiClient.get<KeywordStats>(
|
||||||
|
`/api/v1/keywords/${keywordId}/stats`
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bulkCreateKeywords = async (
|
||||||
|
bulkData: KeywordBulkCreate
|
||||||
|
): Promise<{ created: number; failed: number; errors: string[] }> => {
|
||||||
|
const response = await apiClient.post<{
|
||||||
|
created: number
|
||||||
|
failed: number
|
||||||
|
errors: string[]
|
||||||
|
}>('/api/v1/keywords/bulk', bulkData)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
67
services/news-engine-console/frontend/src/api/monitoring.ts
Normal file
67
services/news-engine-console/frontend/src/api/monitoring.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import apiClient from './client'
|
||||||
|
import type {
|
||||||
|
MonitoringOverview,
|
||||||
|
SystemStatus,
|
||||||
|
ServiceStatus,
|
||||||
|
SystemMetrics,
|
||||||
|
DatabaseStats,
|
||||||
|
LogEntry,
|
||||||
|
PipelineLog,
|
||||||
|
} from '@/types'
|
||||||
|
|
||||||
|
export const getMonitoringOverview = async (): Promise<MonitoringOverview> => {
|
||||||
|
const response = await apiClient.get<MonitoringOverview>('/api/v1/monitoring/')
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getHealthCheck = async (): Promise<SystemStatus> => {
|
||||||
|
const response = await apiClient.get<SystemStatus>('/api/v1/monitoring/health')
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getSystemStatus = async (): Promise<SystemMetrics> => {
|
||||||
|
const response = await apiClient.get<SystemMetrics>('/api/v1/monitoring/system')
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getServiceStatus = async (): Promise<ServiceStatus[]> => {
|
||||||
|
const response = await apiClient.get<ServiceStatus[]>('/api/v1/monitoring/services')
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getDatabaseStats = async (): Promise<DatabaseStats> => {
|
||||||
|
const response = await apiClient.get<DatabaseStats>('/api/v1/monitoring/database')
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getRecentLogs = async (params?: { limit?: number }): Promise<LogEntry[]> => {
|
||||||
|
const response = await apiClient.get<LogEntry[]>('/api/v1/monitoring/logs/recent', {
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getMetrics = async (): Promise<{
|
||||||
|
cpu: number
|
||||||
|
memory: number
|
||||||
|
disk: number
|
||||||
|
timestamp: string
|
||||||
|
}> => {
|
||||||
|
const response = await apiClient.get<{
|
||||||
|
cpu: number
|
||||||
|
memory: number
|
||||||
|
disk: number
|
||||||
|
timestamp: string
|
||||||
|
}>('/api/v1/monitoring/metrics')
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getPipelineActivity = async (params?: {
|
||||||
|
limit?: number
|
||||||
|
}): Promise<PipelineLog[]> => {
|
||||||
|
const response = await apiClient.get<PipelineLog[]>(
|
||||||
|
'/api/v1/monitoring/pipelines/activity',
|
||||||
|
{ params }
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
90
services/news-engine-console/frontend/src/api/pipelines.ts
Normal file
90
services/news-engine-console/frontend/src/api/pipelines.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import apiClient from './client'
|
||||||
|
import type {
|
||||||
|
Pipeline,
|
||||||
|
PipelineCreate,
|
||||||
|
PipelineUpdate,
|
||||||
|
PipelineLog,
|
||||||
|
PipelineType,
|
||||||
|
} from '@/types'
|
||||||
|
|
||||||
|
export const getPipelines = async (params?: {
|
||||||
|
type?: string
|
||||||
|
status?: string
|
||||||
|
skip?: number
|
||||||
|
limit?: number
|
||||||
|
}): Promise<Pipeline[]> => {
|
||||||
|
const response = await apiClient.get<Pipeline[]>('/api/v1/pipelines/', { params })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getPipeline = async (pipelineId: string): Promise<Pipeline> => {
|
||||||
|
const response = await apiClient.get<Pipeline>(`/api/v1/pipelines/${pipelineId}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createPipeline = async (pipelineData: PipelineCreate): Promise<Pipeline> => {
|
||||||
|
const response = await apiClient.post<Pipeline>('/api/v1/pipelines/', pipelineData)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updatePipeline = async (
|
||||||
|
pipelineId: string,
|
||||||
|
pipelineData: PipelineUpdate
|
||||||
|
): Promise<Pipeline> => {
|
||||||
|
const response = await apiClient.put<Pipeline>(
|
||||||
|
`/api/v1/pipelines/${pipelineId}`,
|
||||||
|
pipelineData
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deletePipeline = async (pipelineId: string): Promise<{ message: string }> => {
|
||||||
|
const response = await apiClient.delete<{ message: string }>(
|
||||||
|
`/api/v1/pipelines/${pipelineId}`
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const startPipeline = async (pipelineId: string): Promise<Pipeline> => {
|
||||||
|
const response = await apiClient.post<Pipeline>(`/api/v1/pipelines/${pipelineId}/start`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const stopPipeline = async (pipelineId: string): Promise<Pipeline> => {
|
||||||
|
const response = await apiClient.post<Pipeline>(`/api/v1/pipelines/${pipelineId}/stop`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const restartPipeline = async (pipelineId: string): Promise<Pipeline> => {
|
||||||
|
const response = await apiClient.post<Pipeline>(
|
||||||
|
`/api/v1/pipelines/${pipelineId}/restart`
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getPipelineLogs = async (
|
||||||
|
pipelineId: string,
|
||||||
|
params?: { limit?: number }
|
||||||
|
): Promise<PipelineLog[]> => {
|
||||||
|
const response = await apiClient.get<PipelineLog[]>(
|
||||||
|
`/api/v1/pipelines/${pipelineId}/logs`,
|
||||||
|
{ params }
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updatePipelineConfig = async (
|
||||||
|
pipelineId: string,
|
||||||
|
config: Record<string, any>
|
||||||
|
): Promise<Pipeline> => {
|
||||||
|
const response = await apiClient.put<Pipeline>(
|
||||||
|
`/api/v1/pipelines/${pipelineId}/config`,
|
||||||
|
config
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getPipelineTypes = async (): Promise<PipelineType[]> => {
|
||||||
|
const response = await apiClient.get<PipelineType[]>('/api/v1/pipelines/types')
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
81
services/news-engine-console/frontend/src/api/users.ts
Normal file
81
services/news-engine-console/frontend/src/api/users.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import apiClient from './client'
|
||||||
|
import type {
|
||||||
|
User,
|
||||||
|
UserCreate,
|
||||||
|
UserUpdate,
|
||||||
|
UserLogin,
|
||||||
|
TokenResponse,
|
||||||
|
} from '@/types'
|
||||||
|
|
||||||
|
// Authentication
|
||||||
|
export const login = async (credentials: UserLogin): Promise<TokenResponse> => {
|
||||||
|
const formData = new URLSearchParams()
|
||||||
|
formData.append('username', credentials.username)
|
||||||
|
formData.append('password', credentials.password)
|
||||||
|
|
||||||
|
const response = await apiClient.post<TokenResponse>('/api/v1/users/login', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const register = async (userData: UserCreate): Promise<User> => {
|
||||||
|
const response = await apiClient.post<User>('/api/v1/users/register', userData)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const refreshToken = async (refreshToken: string): Promise<TokenResponse> => {
|
||||||
|
const response = await apiClient.post<TokenResponse>('/api/v1/users/refresh', {
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const logout = async (): Promise<void> => {
|
||||||
|
await apiClient.post('/api/v1/users/logout')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current user
|
||||||
|
export const getCurrentUser = async (): Promise<User> => {
|
||||||
|
const response = await apiClient.get<User>('/api/v1/users/me')
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateCurrentUser = async (userData: UserUpdate): Promise<User> => {
|
||||||
|
const response = await apiClient.put<User>('/api/v1/users/me', userData)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// User management
|
||||||
|
export const getUsers = async (params?: {
|
||||||
|
role?: string
|
||||||
|
disabled?: boolean
|
||||||
|
search?: string
|
||||||
|
skip?: number
|
||||||
|
limit?: number
|
||||||
|
}): Promise<User[]> => {
|
||||||
|
const response = await apiClient.get<User[]>('/api/v1/users/', { params })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getUser = async (userId: string): Promise<User> => {
|
||||||
|
const response = await apiClient.get<User>(`/api/v1/users/${userId}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createUser = async (userData: UserCreate): Promise<User> => {
|
||||||
|
const response = await apiClient.post<User>('/api/v1/users/', userData)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateUser = async (userId: string, userData: UserUpdate): Promise<User> => {
|
||||||
|
const response = await apiClient.put<User>(`/api/v1/users/${userId}`, userData)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteUser = async (userId: string): Promise<{ message: string }> => {
|
||||||
|
const response = await apiClient.delete<{ message: string }>(`/api/v1/users/${userId}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
55
services/news-engine-console/frontend/src/main.tsx
Normal file
55
services/news-engine-console/frontend/src/main.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import CssBaseline from '@mui/material/CssBaseline'
|
||||||
|
import { ThemeProvider, createTheme } from '@mui/material/styles'
|
||||||
|
import App from './App'
|
||||||
|
|
||||||
|
// Create a client for React Query
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create MUI theme
|
||||||
|
const theme = createTheme({
|
||||||
|
palette: {
|
||||||
|
mode: 'light',
|
||||||
|
primary: {
|
||||||
|
main: '#1976d2',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
main: '#dc004e',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
fontFamily: [
|
||||||
|
'-apple-system',
|
||||||
|
'BlinkMacSystemFont',
|
||||||
|
'"Segoe UI"',
|
||||||
|
'Roboto',
|
||||||
|
'"Helvetica Neue"',
|
||||||
|
'Arial',
|
||||||
|
'sans-serif',
|
||||||
|
].join(','),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
<CssBaseline />
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</ThemeProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
)
|
||||||
122
services/news-engine-console/frontend/src/pages/Dashboard.tsx
Normal file
122
services/news-engine-console/frontend/src/pages/Dashboard.tsx
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import { Box, Container, Typography, Button, Paper, Grid } from '@mui/material'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuthStore } from '../stores/authStore'
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { user, logout } = useAuthStore()
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await logout()
|
||||||
|
navigate('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ minHeight: '100vh', bgcolor: '#f5f5f5' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
bgcolor: 'white',
|
||||||
|
borderBottom: 1,
|
||||||
|
borderColor: 'divider',
|
||||||
|
py: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Container maxWidth="xl">
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<Typography variant="h5" component="h1">
|
||||||
|
News Engine Console
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{user?.full_name} ({user?.role})
|
||||||
|
</Typography>
|
||||||
|
<Button variant="outlined" size="small" onClick={handleLogout}>
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||||
|
<Typography variant="h4" gutterBottom>
|
||||||
|
Dashboard
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Grid container spacing={3} sx={{ mt: 2 }}>
|
||||||
|
<Grid item xs={12} md={6} lg={3}>
|
||||||
|
<Paper sx={{ p: 3, textAlign: 'center' }}>
|
||||||
|
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||||
|
Keywords
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h3">0</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||||
|
Total keywords
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} md={6} lg={3}>
|
||||||
|
<Paper sx={{ p: 3, textAlign: 'center' }}>
|
||||||
|
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||||
|
Pipelines
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h3">0</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||||
|
Active pipelines
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} md={6} lg={3}>
|
||||||
|
<Paper sx={{ p: 3, textAlign: 'center' }}>
|
||||||
|
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||||
|
Users
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h3">1</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||||
|
Registered users
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} md={6} lg={3}>
|
||||||
|
<Paper sx={{ p: 3, textAlign: 'center' }}>
|
||||||
|
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||||
|
Applications
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h3">0</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||||
|
OAuth apps
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Paper sx={{ p: 3 }}>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Welcome to News Engine Console
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" color="text.secondary">
|
||||||
|
This is your central dashboard for managing news pipelines, keywords, users, and applications.
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
|
||||||
|
Frontend is now up and running! Next steps:
|
||||||
|
</Typography>
|
||||||
|
<Box component="ul" sx={{ mt: 1 }}>
|
||||||
|
<li>Implement sidebar navigation</li>
|
||||||
|
<li>Create Keywords management page</li>
|
||||||
|
<li>Create Pipelines management page</li>
|
||||||
|
<li>Create Users management page</li>
|
||||||
|
<li>Create Applications management page</li>
|
||||||
|
<li>Create Monitoring page</li>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
124
services/news-engine-console/frontend/src/pages/Login.tsx
Normal file
124
services/news-engine-console/frontend/src/pages/Login.tsx
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
Typography,
|
||||||
|
Alert,
|
||||||
|
Container,
|
||||||
|
Link,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { useAuthStore } from '../stores/authStore'
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { login, isLoading, error, clearError } = useAuthStore()
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
[e.target.name]: e.target.value,
|
||||||
|
})
|
||||||
|
clearError()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login(formData)
|
||||||
|
navigate('/dashboard')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="sm">
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card sx={{ width: '100%', maxWidth: 450 }}>
|
||||||
|
<CardContent sx={{ p: 4 }}>
|
||||||
|
<Box sx={{ textAlign: 'center', mb: 4 }}>
|
||||||
|
<Typography variant="h4" component="h1" gutterBottom>
|
||||||
|
News Engine Console
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Sign in to manage your news pipelines
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 3 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Username"
|
||||||
|
name="username"
|
||||||
|
value={formData.username}
|
||||||
|
onChange={handleChange}
|
||||||
|
margin="normal"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
autoComplete="username"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={handleChange}
|
||||||
|
margin="normal"
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
fullWidth
|
||||||
|
variant="contained"
|
||||||
|
size="large"
|
||||||
|
disabled={isLoading}
|
||||||
|
sx={{ mt: 3, mb: 2 }}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Signing in...' : 'Sign In'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Box sx={{ textAlign: 'center', mt: 2 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Default credentials: admin / admin123
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ textAlign: 'center', mt: 2 }}>
|
||||||
|
<Link href="#" variant="body2" underline="hover">
|
||||||
|
Forgot password?
|
||||||
|
</Link>
|
||||||
|
</Box>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
148
services/news-engine-console/frontend/src/stores/authStore.ts
Normal file
148
services/news-engine-console/frontend/src/stores/authStore.ts
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import { persist } from 'zustand/middleware'
|
||||||
|
import type { User, UserLogin } from '@/types'
|
||||||
|
import * as authApi from '@/api/users'
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: User | null
|
||||||
|
accessToken: string | null
|
||||||
|
refreshToken: string | null
|
||||||
|
isAuthenticated: boolean
|
||||||
|
isLoading: boolean
|
||||||
|
error: string | null
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
login: (credentials: UserLogin) => Promise<void>
|
||||||
|
logout: () => Promise<void>
|
||||||
|
register: (userData: any) => Promise<void>
|
||||||
|
fetchCurrentUser: () => Promise<void>
|
||||||
|
updateCurrentUser: (userData: any) => Promise<void>
|
||||||
|
clearError: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
user: null,
|
||||||
|
accessToken: null,
|
||||||
|
refreshToken: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
login: async (credentials: UserLogin) => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null })
|
||||||
|
|
||||||
|
const tokenResponse = await authApi.login(credentials)
|
||||||
|
|
||||||
|
// Store tokens in localStorage
|
||||||
|
localStorage.setItem('access_token', tokenResponse.access_token)
|
||||||
|
localStorage.setItem('refresh_token', tokenResponse.refresh_token)
|
||||||
|
|
||||||
|
// Fetch user details
|
||||||
|
const user = await authApi.getCurrentUser()
|
||||||
|
|
||||||
|
set({
|
||||||
|
user,
|
||||||
|
accessToken: tokenResponse.access_token,
|
||||||
|
refreshToken: tokenResponse.refresh_token,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
set({
|
||||||
|
error: error.message || 'Login failed',
|
||||||
|
isLoading: false,
|
||||||
|
isAuthenticated: false,
|
||||||
|
})
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: async () => {
|
||||||
|
try {
|
||||||
|
await authApi.logout()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout error:', error)
|
||||||
|
} finally {
|
||||||
|
// Clear tokens from localStorage
|
||||||
|
localStorage.removeItem('access_token')
|
||||||
|
localStorage.removeItem('refresh_token')
|
||||||
|
localStorage.removeItem('user')
|
||||||
|
|
||||||
|
set({
|
||||||
|
user: null,
|
||||||
|
accessToken: null,
|
||||||
|
refreshToken: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
register: async (userData: any) => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null })
|
||||||
|
await authApi.register(userData)
|
||||||
|
set({ isLoading: false })
|
||||||
|
} catch (error: any) {
|
||||||
|
set({
|
||||||
|
error: error.message || 'Registration failed',
|
||||||
|
isLoading: false,
|
||||||
|
})
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchCurrentUser: async () => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null })
|
||||||
|
const user = await authApi.getCurrentUser()
|
||||||
|
set({
|
||||||
|
user,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
set({
|
||||||
|
error: error.message || 'Failed to fetch user',
|
||||||
|
isLoading: false,
|
||||||
|
isAuthenticated: false,
|
||||||
|
})
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateCurrentUser: async (userData: any) => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null })
|
||||||
|
const updatedUser = await authApi.updateCurrentUser(userData)
|
||||||
|
set({
|
||||||
|
user: updatedUser,
|
||||||
|
isLoading: false,
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
set({
|
||||||
|
error: error.message || 'Failed to update user',
|
||||||
|
isLoading: false,
|
||||||
|
})
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearError: () => {
|
||||||
|
set({ error: null })
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'auth-storage',
|
||||||
|
partialize: (state) => ({
|
||||||
|
user: state.user,
|
||||||
|
accessToken: state.accessToken,
|
||||||
|
refreshToken: state.refreshToken,
|
||||||
|
isAuthenticated: state.isAuthenticated,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
264
services/news-engine-console/frontend/src/types/index.ts
Normal file
264
services/news-engine-console/frontend/src/types/index.ts
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
// =====================================================
|
||||||
|
// User Types
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id?: string
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
full_name: string
|
||||||
|
role: 'admin' | 'editor' | 'viewer'
|
||||||
|
disabled: boolean
|
||||||
|
created_at: string
|
||||||
|
last_login?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserCreate {
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
full_name: string
|
||||||
|
role?: 'admin' | 'editor' | 'viewer'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserUpdate {
|
||||||
|
email?: string
|
||||||
|
password?: string
|
||||||
|
full_name?: string
|
||||||
|
role?: 'admin' | 'editor' | 'viewer'
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserLogin {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenResponse {
|
||||||
|
access_token: string
|
||||||
|
refresh_token: string
|
||||||
|
token_type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// Keyword Types
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
export interface Keyword {
|
||||||
|
id?: string
|
||||||
|
keyword: string
|
||||||
|
category: 'people' | 'topics' | 'companies'
|
||||||
|
status: 'active' | 'inactive'
|
||||||
|
pipeline_type: string
|
||||||
|
priority: number
|
||||||
|
metadata: Record<string, any>
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
created_by?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeywordCreate {
|
||||||
|
keyword: string
|
||||||
|
category: 'people' | 'topics' | 'companies'
|
||||||
|
status?: 'active' | 'inactive'
|
||||||
|
pipeline_type?: string
|
||||||
|
priority?: number
|
||||||
|
metadata?: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeywordUpdate {
|
||||||
|
keyword?: string
|
||||||
|
category?: 'people' | 'topics' | 'companies'
|
||||||
|
status?: 'active' | 'inactive'
|
||||||
|
pipeline_type?: string
|
||||||
|
priority?: number
|
||||||
|
metadata?: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeywordStats {
|
||||||
|
keyword_id: string
|
||||||
|
keyword: string
|
||||||
|
total_articles: number
|
||||||
|
total_translations: number
|
||||||
|
total_images: number
|
||||||
|
last_processed: string | null
|
||||||
|
success_rate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeywordBulkCreate {
|
||||||
|
keywords: KeywordCreate[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// Pipeline Types
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
export interface PipelineStats {
|
||||||
|
total_runs: number
|
||||||
|
successful_runs: number
|
||||||
|
failed_runs: number
|
||||||
|
last_success: string | null
|
||||||
|
last_failure: string | null
|
||||||
|
avg_duration_seconds: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Pipeline {
|
||||||
|
id?: string
|
||||||
|
name: string
|
||||||
|
type: 'rss_collector' | 'translator' | 'image_generator'
|
||||||
|
status: 'running' | 'stopped' | 'error'
|
||||||
|
config: Record<string, any>
|
||||||
|
schedule?: string
|
||||||
|
stats: PipelineStats
|
||||||
|
last_run?: string
|
||||||
|
next_run?: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
created_by?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PipelineCreate {
|
||||||
|
name: string
|
||||||
|
type: 'rss_collector' | 'translator' | 'image_generator'
|
||||||
|
config?: Record<string, any>
|
||||||
|
schedule?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PipelineUpdate {
|
||||||
|
name?: string
|
||||||
|
type?: 'rss_collector' | 'translator' | 'image_generator'
|
||||||
|
status?: 'running' | 'stopped' | 'error'
|
||||||
|
config?: Record<string, any>
|
||||||
|
schedule?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PipelineLog {
|
||||||
|
timestamp: string
|
||||||
|
level: 'info' | 'warning' | 'error'
|
||||||
|
message: string
|
||||||
|
details?: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PipelineType {
|
||||||
|
type: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// Application Types (OAuth2)
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
export interface Application {
|
||||||
|
id?: string
|
||||||
|
name: string
|
||||||
|
client_id: string
|
||||||
|
client_secret?: string
|
||||||
|
redirect_uris: string[]
|
||||||
|
grant_types: string[]
|
||||||
|
scopes: string[]
|
||||||
|
owner_id: string
|
||||||
|
created_at: string
|
||||||
|
updated_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApplicationCreate {
|
||||||
|
name: string
|
||||||
|
redirect_uris?: string[]
|
||||||
|
grant_types?: string[]
|
||||||
|
scopes?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApplicationUpdate {
|
||||||
|
name?: string
|
||||||
|
redirect_uris?: string[]
|
||||||
|
grant_types?: string[]
|
||||||
|
scopes?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApplicationSecretResponse {
|
||||||
|
client_id: string
|
||||||
|
client_secret: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// Monitoring Types
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
export interface SystemStatus {
|
||||||
|
status: 'healthy' | 'degraded' | 'down'
|
||||||
|
timestamp: string
|
||||||
|
uptime_seconds: number
|
||||||
|
version: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceStatus {
|
||||||
|
name: string
|
||||||
|
status: 'up' | 'down'
|
||||||
|
response_time_ms?: number
|
||||||
|
last_check: string
|
||||||
|
details?: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DatabaseStats {
|
||||||
|
collections: number
|
||||||
|
total_documents: number
|
||||||
|
total_size_mb: number
|
||||||
|
indexes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemMetrics {
|
||||||
|
cpu_percent: number
|
||||||
|
memory_percent: number
|
||||||
|
memory_used_mb: number
|
||||||
|
memory_total_mb: number
|
||||||
|
disk_percent: number
|
||||||
|
disk_used_gb: number
|
||||||
|
disk_total_gb: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogEntry {
|
||||||
|
timestamp: string
|
||||||
|
level: 'info' | 'warning' | 'error' | 'debug'
|
||||||
|
message: string
|
||||||
|
source?: string
|
||||||
|
details?: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonitoringOverview {
|
||||||
|
system: SystemStatus
|
||||||
|
services: ServiceStatus[]
|
||||||
|
metrics: SystemMetrics
|
||||||
|
recent_logs: LogEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// API Response Types
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
export interface ApiError {
|
||||||
|
detail: string
|
||||||
|
status_code?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginationParams {
|
||||||
|
skip?: number
|
||||||
|
limit?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
items: T[]
|
||||||
|
total: number
|
||||||
|
skip: number
|
||||||
|
limit: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// Common Types
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
export type Status = 'active' | 'inactive' | 'running' | 'stopped' | 'error'
|
||||||
|
export type Role = 'admin' | 'editor' | 'viewer'
|
||||||
|
export type Category = 'people' | 'topics' | 'companies'
|
||||||
|
export type PipelineTypeEnum = 'rss_collector' | 'translator' | 'image_generator'
|
||||||
36
services/news-engine-console/frontend/tsconfig.json
Normal file
36
services/news-engine-console/frontend/tsconfig.json
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
|
||||||
|
/* Path aliases */
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"],
|
||||||
|
"@/components/*": ["./src/components/*"],
|
||||||
|
"@/pages/*": ["./src/pages/*"],
|
||||||
|
"@/api/*": ["./src/api/*"],
|
||||||
|
"@/hooks/*": ["./src/hooks/*"],
|
||||||
|
"@/types/*": ["./src/types/*"],
|
||||||
|
"@/utils/*": ["./src/utils/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
33
services/news-engine-console/frontend/vite.config.ts
Normal file
33
services/news-engine-console/frontend/vite.config.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
'@/components': path.resolve(__dirname, './src/components'),
|
||||||
|
'@/pages': path.resolve(__dirname, './src/pages'),
|
||||||
|
'@/api': path.resolve(__dirname, './src/api'),
|
||||||
|
'@/hooks': path.resolve(__dirname, './src/hooks'),
|
||||||
|
'@/types': path.resolve(__dirname, './src/types'),
|
||||||
|
'@/utils': path.resolve(__dirname, './src/utils'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 3000,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: process.env.VITE_API_URL || 'http://localhost:8101',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
sourcemap: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user