From 94bcf9fe9fca276b1a2050129978fc66b55f8748 Mon Sep 17 00:00:00 2001 From: jungwoo choi Date: Tue, 4 Nov 2025 20:51:23 +0900 Subject: [PATCH] feat: Implement Phase 2 Frontend basic structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../news-engine-console/docker-compose.yml | 46 +++ .../news-engine-console/frontend/.env.example | 2 + .../news-engine-console/frontend/.gitignore | 29 ++ .../news-engine-console/frontend/Dockerfile | 29 ++ .../frontend/Dockerfile.dev | 18 ++ .../news-engine-console/frontend/index.html | 14 + .../news-engine-console/frontend/nginx.conf | 34 +++ .../news-engine-console/frontend/package.json | 39 +++ .../news-engine-console/frontend/src/App.tsx | 33 +++ .../frontend/src/api/applications.ts | 66 +++++ .../frontend/src/api/client.ts | 78 ++++++ .../frontend/src/api/index.ts | 7 + .../frontend/src/api/keywords.ts | 72 +++++ .../frontend/src/api/monitoring.ts | 67 +++++ .../frontend/src/api/pipelines.ts | 90 ++++++ .../frontend/src/api/users.ts | 81 ++++++ .../news-engine-console/frontend/src/main.tsx | 55 ++++ .../frontend/src/pages/Dashboard.tsx | 122 ++++++++ .../frontend/src/pages/Login.tsx | 124 ++++++++ .../frontend/src/stores/authStore.ts | 148 ++++++++++ .../frontend/src/types/index.ts | 264 ++++++++++++++++++ .../frontend/tsconfig.json | 36 +++ .../frontend/vite.config.ts | 33 +++ 23 files changed, 1487 insertions(+) create mode 100644 services/news-engine-console/docker-compose.yml create mode 100644 services/news-engine-console/frontend/.env.example create mode 100644 services/news-engine-console/frontend/.gitignore create mode 100644 services/news-engine-console/frontend/Dockerfile create mode 100644 services/news-engine-console/frontend/Dockerfile.dev create mode 100644 services/news-engine-console/frontend/index.html create mode 100644 services/news-engine-console/frontend/nginx.conf create mode 100644 services/news-engine-console/frontend/package.json create mode 100644 services/news-engine-console/frontend/src/App.tsx create mode 100644 services/news-engine-console/frontend/src/api/applications.ts create mode 100644 services/news-engine-console/frontend/src/api/client.ts create mode 100644 services/news-engine-console/frontend/src/api/index.ts create mode 100644 services/news-engine-console/frontend/src/api/keywords.ts create mode 100644 services/news-engine-console/frontend/src/api/monitoring.ts create mode 100644 services/news-engine-console/frontend/src/api/pipelines.ts create mode 100644 services/news-engine-console/frontend/src/api/users.ts create mode 100644 services/news-engine-console/frontend/src/main.tsx create mode 100644 services/news-engine-console/frontend/src/pages/Dashboard.tsx create mode 100644 services/news-engine-console/frontend/src/pages/Login.tsx create mode 100644 services/news-engine-console/frontend/src/stores/authStore.ts create mode 100644 services/news-engine-console/frontend/src/types/index.ts create mode 100644 services/news-engine-console/frontend/tsconfig.json create mode 100644 services/news-engine-console/frontend/vite.config.ts diff --git a/services/news-engine-console/docker-compose.yml b/services/news-engine-console/docker-compose.yml new file mode 100644 index 0000000..89def55 --- /dev/null +++ b/services/news-engine-console/docker-compose.yml @@ -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 diff --git a/services/news-engine-console/frontend/.env.example b/services/news-engine-console/frontend/.env.example new file mode 100644 index 0000000..694f72b --- /dev/null +++ b/services/news-engine-console/frontend/.env.example @@ -0,0 +1,2 @@ +VITE_API_URL=http://localhost:8101 +VITE_APP_TITLE=News Engine Console diff --git a/services/news-engine-console/frontend/.gitignore b/services/news-engine-console/frontend/.gitignore new file mode 100644 index 0000000..e72dae6 --- /dev/null +++ b/services/news-engine-console/frontend/.gitignore @@ -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 diff --git a/services/news-engine-console/frontend/Dockerfile b/services/news-engine-console/frontend/Dockerfile new file mode 100644 index 0000000..7730bc4 --- /dev/null +++ b/services/news-engine-console/frontend/Dockerfile @@ -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;"] diff --git a/services/news-engine-console/frontend/Dockerfile.dev b/services/news-engine-console/frontend/Dockerfile.dev new file mode 100644 index 0000000..004a563 --- /dev/null +++ b/services/news-engine-console/frontend/Dockerfile.dev @@ -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"] diff --git a/services/news-engine-console/frontend/index.html b/services/news-engine-console/frontend/index.html new file mode 100644 index 0000000..e592bd2 --- /dev/null +++ b/services/news-engine-console/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + + News Engine Console + + + +
+ + + diff --git a/services/news-engine-console/frontend/nginx.conf b/services/news-engine-console/frontend/nginx.conf new file mode 100644 index 0000000..6cc696d --- /dev/null +++ b/services/news-engine-console/frontend/nginx.conf @@ -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"; + } +} diff --git a/services/news-engine-console/frontend/package.json b/services/news-engine-console/frontend/package.json new file mode 100644 index 0000000..bac88bf --- /dev/null +++ b/services/news-engine-console/frontend/package.json @@ -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" + } +} diff --git a/services/news-engine-console/frontend/src/App.tsx b/services/news-engine-console/frontend/src/App.tsx new file mode 100644 index 0000000..cf9ddd4 --- /dev/null +++ b/services/news-engine-console/frontend/src/App.tsx @@ -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 ( + + } /> + + : + } + /> + + : + } + /> + + {/* Catch all - redirect to dashboard or login */} + } /> + + ) +} + +export default App diff --git a/services/news-engine-console/frontend/src/api/applications.ts b/services/news-engine-console/frontend/src/api/applications.ts new file mode 100644 index 0000000..1026f37 --- /dev/null +++ b/services/news-engine-console/frontend/src/api/applications.ts @@ -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 => { + const response = await apiClient.get('/api/v1/applications/', { params }) + return response.data +} + +export const getApplication = async (applicationId: string): Promise => { + const response = await apiClient.get( + `/api/v1/applications/${applicationId}` + ) + return response.data +} + +export const createApplication = async ( + applicationData: ApplicationCreate +): Promise => { + const response = await apiClient.post( + '/api/v1/applications/', + applicationData + ) + return response.data +} + +export const updateApplication = async ( + applicationId: string, + applicationData: ApplicationUpdate +): Promise => { + const response = await apiClient.put( + `/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 => { + const response = await apiClient.post( + `/api/v1/applications/${applicationId}/regenerate-secret` + ) + return response.data +} + +export const getMyApplications = async (): Promise => { + const response = await apiClient.get('/api/v1/applications/my-apps') + return response.data +} diff --git a/services/news-engine-console/frontend/src/api/client.ts b/services/news-engine-console/frontend/src/api/client.ts new file mode 100644 index 0000000..b6fd103 --- /dev/null +++ b/services/news-engine-console/frontend/src/api/client.ts @@ -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) => { + 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 diff --git a/services/news-engine-console/frontend/src/api/index.ts b/services/news-engine-console/frontend/src/api/index.ts new file mode 100644 index 0000000..7e3c1ee --- /dev/null +++ b/services/news-engine-console/frontend/src/api/index.ts @@ -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' diff --git a/services/news-engine-console/frontend/src/api/keywords.ts b/services/news-engine-console/frontend/src/api/keywords.ts new file mode 100644 index 0000000..1124abf --- /dev/null +++ b/services/news-engine-console/frontend/src/api/keywords.ts @@ -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 => { + const response = await apiClient.get('/api/v1/keywords/', { params }) + return response.data +} + +export const getKeyword = async (keywordId: string): Promise => { + const response = await apiClient.get(`/api/v1/keywords/${keywordId}`) + return response.data +} + +export const createKeyword = async (keywordData: KeywordCreate): Promise => { + const response = await apiClient.post('/api/v1/keywords/', keywordData) + return response.data +} + +export const updateKeyword = async ( + keywordId: string, + keywordData: KeywordUpdate +): Promise => { + const response = await apiClient.put( + `/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 => { + const response = await apiClient.post(`/api/v1/keywords/${keywordId}/toggle`) + return response.data +} + +export const getKeywordStats = async (keywordId: string): Promise => { + const response = await apiClient.get( + `/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 +} diff --git a/services/news-engine-console/frontend/src/api/monitoring.ts b/services/news-engine-console/frontend/src/api/monitoring.ts new file mode 100644 index 0000000..4838d11 --- /dev/null +++ b/services/news-engine-console/frontend/src/api/monitoring.ts @@ -0,0 +1,67 @@ +import apiClient from './client' +import type { + MonitoringOverview, + SystemStatus, + ServiceStatus, + SystemMetrics, + DatabaseStats, + LogEntry, + PipelineLog, +} from '@/types' + +export const getMonitoringOverview = async (): Promise => { + const response = await apiClient.get('/api/v1/monitoring/') + return response.data +} + +export const getHealthCheck = async (): Promise => { + const response = await apiClient.get('/api/v1/monitoring/health') + return response.data +} + +export const getSystemStatus = async (): Promise => { + const response = await apiClient.get('/api/v1/monitoring/system') + return response.data +} + +export const getServiceStatus = async (): Promise => { + const response = await apiClient.get('/api/v1/monitoring/services') + return response.data +} + +export const getDatabaseStats = async (): Promise => { + const response = await apiClient.get('/api/v1/monitoring/database') + return response.data +} + +export const getRecentLogs = async (params?: { limit?: number }): Promise => { + const response = await apiClient.get('/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 => { + const response = await apiClient.get( + '/api/v1/monitoring/pipelines/activity', + { params } + ) + return response.data +} diff --git a/services/news-engine-console/frontend/src/api/pipelines.ts b/services/news-engine-console/frontend/src/api/pipelines.ts new file mode 100644 index 0000000..46ab088 --- /dev/null +++ b/services/news-engine-console/frontend/src/api/pipelines.ts @@ -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 => { + const response = await apiClient.get('/api/v1/pipelines/', { params }) + return response.data +} + +export const getPipeline = async (pipelineId: string): Promise => { + const response = await apiClient.get(`/api/v1/pipelines/${pipelineId}`) + return response.data +} + +export const createPipeline = async (pipelineData: PipelineCreate): Promise => { + const response = await apiClient.post('/api/v1/pipelines/', pipelineData) + return response.data +} + +export const updatePipeline = async ( + pipelineId: string, + pipelineData: PipelineUpdate +): Promise => { + const response = await apiClient.put( + `/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 => { + const response = await apiClient.post(`/api/v1/pipelines/${pipelineId}/start`) + return response.data +} + +export const stopPipeline = async (pipelineId: string): Promise => { + const response = await apiClient.post(`/api/v1/pipelines/${pipelineId}/stop`) + return response.data +} + +export const restartPipeline = async (pipelineId: string): Promise => { + const response = await apiClient.post( + `/api/v1/pipelines/${pipelineId}/restart` + ) + return response.data +} + +export const getPipelineLogs = async ( + pipelineId: string, + params?: { limit?: number } +): Promise => { + const response = await apiClient.get( + `/api/v1/pipelines/${pipelineId}/logs`, + { params } + ) + return response.data +} + +export const updatePipelineConfig = async ( + pipelineId: string, + config: Record +): Promise => { + const response = await apiClient.put( + `/api/v1/pipelines/${pipelineId}/config`, + config + ) + return response.data +} + +export const getPipelineTypes = async (): Promise => { + const response = await apiClient.get('/api/v1/pipelines/types') + return response.data +} diff --git a/services/news-engine-console/frontend/src/api/users.ts b/services/news-engine-console/frontend/src/api/users.ts new file mode 100644 index 0000000..bab4a6e --- /dev/null +++ b/services/news-engine-console/frontend/src/api/users.ts @@ -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 => { + const formData = new URLSearchParams() + formData.append('username', credentials.username) + formData.append('password', credentials.password) + + const response = await apiClient.post('/api/v1/users/login', formData, { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }) + return response.data +} + +export const register = async (userData: UserCreate): Promise => { + const response = await apiClient.post('/api/v1/users/register', userData) + return response.data +} + +export const refreshToken = async (refreshToken: string): Promise => { + const response = await apiClient.post('/api/v1/users/refresh', { + refresh_token: refreshToken, + }) + return response.data +} + +export const logout = async (): Promise => { + await apiClient.post('/api/v1/users/logout') +} + +// Current user +export const getCurrentUser = async (): Promise => { + const response = await apiClient.get('/api/v1/users/me') + return response.data +} + +export const updateCurrentUser = async (userData: UserUpdate): Promise => { + const response = await apiClient.put('/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 => { + const response = await apiClient.get('/api/v1/users/', { params }) + return response.data +} + +export const getUser = async (userId: string): Promise => { + const response = await apiClient.get(`/api/v1/users/${userId}`) + return response.data +} + +export const createUser = async (userData: UserCreate): Promise => { + const response = await apiClient.post('/api/v1/users/', userData) + return response.data +} + +export const updateUser = async (userId: string, userData: UserUpdate): Promise => { + const response = await apiClient.put(`/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 +} diff --git a/services/news-engine-console/frontend/src/main.tsx b/services/news-engine-console/frontend/src/main.tsx new file mode 100644 index 0000000..7ca8f9a --- /dev/null +++ b/services/news-engine-console/frontend/src/main.tsx @@ -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( + + + + + + + + + + +) diff --git a/services/news-engine-console/frontend/src/pages/Dashboard.tsx b/services/news-engine-console/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..9ad45db --- /dev/null +++ b/services/news-engine-console/frontend/src/pages/Dashboard.tsx @@ -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 ( + + {/* Header */} + + + + + News Engine Console + + + + {user?.full_name} ({user?.role}) + + + + + + + + {/* Main Content */} + + + Dashboard + + + + + + + Keywords + + 0 + + Total keywords + + + + + + + + Pipelines + + 0 + + Active pipelines + + + + + + + + Users + + 1 + + Registered users + + + + + + + + Applications + + 0 + + OAuth apps + + + + + + + + Welcome to News Engine Console + + + This is your central dashboard for managing news pipelines, keywords, users, and applications. + + + Frontend is now up and running! Next steps: + + +
  • Implement sidebar navigation
  • +
  • Create Keywords management page
  • +
  • Create Pipelines management page
  • +
  • Create Users management page
  • +
  • Create Applications management page
  • +
  • Create Monitoring page
  • +
    +
    +
    +
    +
    +
    + ) +} diff --git a/services/news-engine-console/frontend/src/pages/Login.tsx b/services/news-engine-console/frontend/src/pages/Login.tsx new file mode 100644 index 0000000..123d4f1 --- /dev/null +++ b/services/news-engine-console/frontend/src/pages/Login.tsx @@ -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) => { + 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 ( + + + + + + + News Engine Console + + + Sign in to manage your news pipelines + + + + {error && ( + + {error} + + )} + +
    + + + + + + + + + Default credentials: admin / admin123 + + + + + + Forgot password? + + + +
    +
    +
    +
    + ) +} diff --git a/services/news-engine-console/frontend/src/stores/authStore.ts b/services/news-engine-console/frontend/src/stores/authStore.ts new file mode 100644 index 0000000..4c75f71 --- /dev/null +++ b/services/news-engine-console/frontend/src/stores/authStore.ts @@ -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 + logout: () => Promise + register: (userData: any) => Promise + fetchCurrentUser: () => Promise + updateCurrentUser: (userData: any) => Promise + clearError: () => void +} + +export const useAuthStore = create()( + 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, + }), + } + ) +) diff --git a/services/news-engine-console/frontend/src/types/index.ts b/services/news-engine-console/frontend/src/types/index.ts new file mode 100644 index 0000000..9b93b98 --- /dev/null +++ b/services/news-engine-console/frontend/src/types/index.ts @@ -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 + 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 +} + +export interface KeywordUpdate { + keyword?: string + category?: 'people' | 'topics' | 'companies' + status?: 'active' | 'inactive' + pipeline_type?: string + priority?: number + metadata?: Record +} + +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 + 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 + schedule?: string +} + +export interface PipelineUpdate { + name?: string + type?: 'rss_collector' | 'translator' | 'image_generator' + status?: 'running' | 'stopped' | 'error' + config?: Record + schedule?: string +} + +export interface PipelineLog { + timestamp: string + level: 'info' | 'warning' | 'error' + message: string + details?: Record +} + +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 +} + +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 +} + +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 { + 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' diff --git a/services/news-engine-console/frontend/tsconfig.json b/services/news-engine-console/frontend/tsconfig.json new file mode 100644 index 0000000..d1e16d4 --- /dev/null +++ b/services/news-engine-console/frontend/tsconfig.json @@ -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"] +} diff --git a/services/news-engine-console/frontend/vite.config.ts b/services/news-engine-console/frontend/vite.config.ts new file mode 100644 index 0000000..dfeacd8 --- /dev/null +++ b/services/news-engine-console/frontend/vite.config.ts @@ -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, + }, +})