From e60e531cdc448f5f3fadd9d3ad82b41c9f4bc040 Mon Sep 17 00:00:00 2001 From: jungwoo choi Date: Tue, 28 Oct 2025 16:44:33 +0900 Subject: [PATCH] feat: Phase 2 - Service Management CRUD API (Backend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend Implementation: - Service model with comprehensive fields (name, url, type, status, health_endpoint) - Complete CRUD API endpoints for service management - Health check mechanism with httpx and response time tracking - Service status tracking (healthy/unhealthy/unknown) - Service type categorization (backend, frontend, database, cache, etc.) API Endpoints: - GET /api/services - Get all services - POST /api/services - Create new service - GET /api/services/{id} - Get service by ID - PUT /api/services/{id} - Update service - DELETE /api/services/{id} - Delete service - POST /api/services/{id}/health-check - Check specific service health - POST /api/services/health-check/all - Check all services health Frontend Preparation: - TypeScript type definitions for Service - Service API client with full CRUD methods - Health check client methods Files Added: - backend/app/models/service.py - Service data model - backend/app/schemas/service.py - Request/response schemas - backend/app/services/service_service.py - Business logic - backend/app/routes/services.py - API route handlers - frontend/src/types/service.ts - TypeScript types - frontend/src/api/service.ts - API client Updated: - backend/app/main.py - Added services router - docs/PROGRESS.md - Added Phase 2 status Next: Frontend UI implementation (Services list page, Add/Edit modal, Health monitoring) 🤖 Generated with Claude Code Co-Authored-By: Claude --- docs/PROGRESS.md | 42 +++- services/console/backend/app/main.py | 3 +- .../console/backend/app/models/service.py | 81 +++++++ .../console/backend/app/routes/services.py | 113 ++++++++++ .../console/backend/app/schemas/service.py | 93 ++++++++ .../backend/app/services/service_service.py | 212 ++++++++++++++++++ services/console/frontend/src/api/service.ts | 45 ++++ .../console/frontend/src/types/service.ts | 56 +++++ 8 files changed, 642 insertions(+), 3 deletions(-) create mode 100644 services/console/backend/app/models/service.py create mode 100644 services/console/backend/app/routes/services.py create mode 100644 services/console/backend/app/schemas/service.py create mode 100644 services/console/backend/app/services/service_service.py create mode 100644 services/console/frontend/src/api/service.ts create mode 100644 services/console/frontend/src/types/service.ts diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index e4269bd..d266013 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -6,8 +6,8 @@ ## Current Status - **Date Started**: 2025-09-09 - **Last Updated**: 2025-10-28 -- **Current Phase**: Phase 1 Complete ✅ (Authentication System) -- **Next Action**: Phase 2 - Service Management CRUD +- **Current Phase**: Phase 2 In Progress 🔄 (Service Management CRUD - Backend Complete) +- **Next Action**: Phase 2 - Frontend UI Implementation ## Completed Checkpoints @@ -92,6 +92,44 @@ All authentication endpoints tested and working: - ✅ Duplicate email prevention - ✅ Unauthorized access blocking +### Phase 2: Service Management CRUD 🔄 +**Started Date**: 2025-10-28 +**Status**: Backend Complete, Frontend In Progress + +#### Backend (FastAPI + MongoDB) ✅ +✅ Service model with comprehensive fields +✅ Service CRUD API endpoints (Create, Read, Update, Delete) +✅ Health check mechanism with httpx +✅ Response time measurement +✅ Status tracking (healthy/unhealthy/unknown) +✅ Service type categorization (backend, frontend, database, etc.) + +**API Endpoints**: +- GET `/api/services` - Get all services +- POST `/api/services` - Create new service +- GET `/api/services/{id}` - Get service by ID +- PUT `/api/services/{id}` - Update service +- DELETE `/api/services/{id}` - Delete service +- POST `/api/services/{id}/health-check` - Check specific service health +- POST `/api/services/health-check/all` - Check all services health + +**Files Created**: +- `/services/console/backend/app/models/service.py` - Service model +- `/services/console/backend/app/schemas/service.py` - Service schemas +- `/services/console/backend/app/services/service_service.py` - Business logic +- `/services/console/backend/app/routes/services.py` - API routes + +#### Frontend (React + TypeScript) 🔄 +✅ TypeScript type definitions +✅ Service API client +⏳ Services list page (pending) +⏳ Add/Edit service modal (pending) +⏳ Health status display (pending) + +**Files Created**: +- `/services/console/frontend/src/types/service.ts` - TypeScript types +- `/services/console/frontend/src/api/service.ts` - API client + ### Earlier Checkpoints ✅ Project structure planning (CLAUDE.md) ✅ Implementation plan created (docs/PLAN.md) diff --git a/services/console/backend/app/main.py b/services/console/backend/app/main.py index 8e6e983..797e05e 100644 --- a/services/console/backend/app/main.py +++ b/services/console/backend/app/main.py @@ -5,7 +5,7 @@ import logging from .core.config import settings from .db.mongodb import MongoDB -from .routes import auth +from .routes import auth, services # Configure logging logging.basicConfig( @@ -56,6 +56,7 @@ app.add_middleware( # Include routers app.include_router(auth.router) +app.include_router(services.router) # Health check endpoints diff --git a/services/console/backend/app/models/service.py b/services/console/backend/app/models/service.py new file mode 100644 index 0000000..75dfb09 --- /dev/null +++ b/services/console/backend/app/models/service.py @@ -0,0 +1,81 @@ +from datetime import datetime +from typing import Optional, Dict, Any +from pydantic import BaseModel, Field, ConfigDict +from bson import ObjectId +from pydantic_core import core_schema + + +class PyObjectId(str): + """Custom ObjectId type for Pydantic v2""" + + @classmethod + def __get_pydantic_core_schema__(cls, source_type, handler): + return core_schema.union_schema([ + core_schema.is_instance_schema(ObjectId), + core_schema.chain_schema([ + core_schema.str_schema(), + core_schema.no_info_plain_validator_function(cls.validate), + ]) + ], + serialization=core_schema.plain_serializer_function_ser_schema( + lambda x: str(x) + )) + + @classmethod + def validate(cls, v): + if not ObjectId.is_valid(v): + raise ValueError("Invalid ObjectId") + return ObjectId(v) + + +class ServiceStatus: + """Service status constants""" + HEALTHY = "healthy" + UNHEALTHY = "unhealthy" + UNKNOWN = "unknown" + + +class ServiceType: + """Service type constants""" + BACKEND = "backend" + FRONTEND = "frontend" + DATABASE = "database" + CACHE = "cache" + MESSAGE_QUEUE = "message_queue" + OTHER = "other" + + +class Service(BaseModel): + """Service model for MongoDB""" + id: Optional[PyObjectId] = Field(alias="_id", default=None) + name: str = Field(..., min_length=1, max_length=100) + description: Optional[str] = Field(default=None, max_length=500) + service_type: str = Field(default=ServiceType.BACKEND) + url: str = Field(..., min_length=1) + health_endpoint: Optional[str] = Field(default="/health") + status: str = Field(default=ServiceStatus.UNKNOWN) + last_health_check: Optional[datetime] = None + response_time_ms: Optional[float] = None + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) + metadata: Dict[str, Any] = Field(default_factory=dict) + + model_config = ConfigDict( + populate_by_name=True, + arbitrary_types_allowed=True, + json_encoders={ObjectId: str}, + json_schema_extra={ + "example": { + "name": "News API", + "description": "News generation and management API", + "service_type": "backend", + "url": "http://news-api:8050", + "health_endpoint": "/health", + "status": "healthy", + "metadata": { + "version": "1.0.0", + "port": 8050 + } + } + } + ) diff --git a/services/console/backend/app/routes/services.py b/services/console/backend/app/routes/services.py new file mode 100644 index 0000000..28133b1 --- /dev/null +++ b/services/console/backend/app/routes/services.py @@ -0,0 +1,113 @@ +from typing import List +from fastapi import APIRouter, Depends, HTTPException, status + +from app.models.service import Service +from app.models.user import User +from app.schemas.service import ( + ServiceCreate, + ServiceUpdate, + ServiceResponse, + ServiceHealthCheck +) +from app.services.service_service import ServiceService +from app.core.security import get_current_user + + +router = APIRouter(prefix="/api/services", tags=["services"]) + + +@router.post("", response_model=ServiceResponse, status_code=status.HTTP_201_CREATED) +async def create_service( + service_data: ServiceCreate, + current_user: User = Depends(get_current_user) +): + """ + Create a new service + + Requires authentication. + """ + service = await ServiceService.create_service(service_data) + return service.model_dump(by_alias=True) + + +@router.get("", response_model=List[ServiceResponse]) +async def get_all_services( + current_user: User = Depends(get_current_user) +): + """ + Get all services + + Requires authentication. + """ + services = await ServiceService.get_all_services() + return [service.model_dump(by_alias=True) for service in services] + + +@router.get("/{service_id}", response_model=ServiceResponse) +async def get_service( + service_id: str, + current_user: User = Depends(get_current_user) +): + """ + Get a service by ID + + Requires authentication. + """ + service = await ServiceService.get_service(service_id) + return service.model_dump(by_alias=True) + + +@router.put("/{service_id}", response_model=ServiceResponse) +async def update_service( + service_id: str, + service_data: ServiceUpdate, + current_user: User = Depends(get_current_user) +): + """ + Update a service + + Requires authentication. + """ + service = await ServiceService.update_service(service_id, service_data) + return service.model_dump(by_alias=True) + + +@router.delete("/{service_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_service( + service_id: str, + current_user: User = Depends(get_current_user) +): + """ + Delete a service + + Requires authentication. + """ + await ServiceService.delete_service(service_id) + return None + + +@router.post("/{service_id}/health-check", response_model=ServiceHealthCheck) +async def check_service_health( + service_id: str, + current_user: User = Depends(get_current_user) +): + """ + Check health of a specific service + + Requires authentication. + """ + result = await ServiceService.check_service_health(service_id) + return result + + +@router.post("/health-check/all", response_model=List[ServiceHealthCheck]) +async def check_all_services_health( + current_user: User = Depends(get_current_user) +): + """ + Check health of all services + + Requires authentication. + """ + results = await ServiceService.check_all_services_health() + return results diff --git a/services/console/backend/app/schemas/service.py b/services/console/backend/app/schemas/service.py new file mode 100644 index 0000000..4c61f64 --- /dev/null +++ b/services/console/backend/app/schemas/service.py @@ -0,0 +1,93 @@ +from datetime import datetime +from typing import Optional, Dict, Any +from pydantic import BaseModel, Field, ConfigDict + + +class ServiceCreate(BaseModel): + """Schema for creating a new service""" + name: str = Field(..., min_length=1, max_length=100) + description: Optional[str] = Field(default=None, max_length=500) + service_type: str = Field(default="backend") + url: str = Field(..., min_length=1) + health_endpoint: Optional[str] = Field(default="/health") + metadata: Dict[str, Any] = Field(default_factory=dict) + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "name": "News API", + "description": "News generation and management API", + "service_type": "backend", + "url": "http://news-api:8050", + "health_endpoint": "/health", + "metadata": { + "version": "1.0.0", + "port": 8050 + } + } + } + ) + + +class ServiceUpdate(BaseModel): + """Schema for updating a service""" + name: Optional[str] = Field(default=None, min_length=1, max_length=100) + description: Optional[str] = Field(default=None, max_length=500) + service_type: Optional[str] = None + url: Optional[str] = Field(default=None, min_length=1) + health_endpoint: Optional[str] = None + metadata: Optional[Dict[str, Any]] = None + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "description": "Updated description", + "metadata": { + "version": "1.1.0" + } + } + } + ) + + +class ServiceResponse(BaseModel): + """Schema for service response""" + id: str = Field(alias="_id") + name: str + description: Optional[str] = None + service_type: str + url: str + health_endpoint: Optional[str] = None + status: str + last_health_check: Optional[datetime] = None + response_time_ms: Optional[float] = None + created_at: datetime + updated_at: datetime + metadata: Dict[str, Any] = Field(default_factory=dict) + + model_config = ConfigDict( + populate_by_name=True, + from_attributes=True + ) + + +class ServiceHealthCheck(BaseModel): + """Schema for health check result""" + service_id: str + service_name: str + status: str + response_time_ms: Optional[float] = None + checked_at: datetime + error_message: Optional[str] = None + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "service_id": "507f1f77bcf86cd799439011", + "service_name": "News API", + "status": "healthy", + "response_time_ms": 45.2, + "checked_at": "2025-10-28T10:00:00Z" + } + } + ) diff --git a/services/console/backend/app/services/service_service.py b/services/console/backend/app/services/service_service.py new file mode 100644 index 0000000..16e279f --- /dev/null +++ b/services/console/backend/app/services/service_service.py @@ -0,0 +1,212 @@ +from datetime import datetime +from typing import List, Optional +import time +import httpx +from bson import ObjectId +from fastapi import HTTPException, status + +from app.db.mongodb import MongoDB +from app.models.service import Service, ServiceStatus +from app.schemas.service import ServiceCreate, ServiceUpdate, ServiceHealthCheck + + +class ServiceService: + """Service management business logic""" + + @staticmethod + async def create_service(service_data: ServiceCreate) -> Service: + """Create a new service""" + db = MongoDB.db + + # Check if service with same name already exists + existing = await db.services.find_one({"name": service_data.name}) + if existing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Service with this name already exists" + ) + + # Create service document + service = Service( + **service_data.model_dump(), + status=ServiceStatus.UNKNOWN, + created_at=datetime.utcnow(), + updated_at=datetime.utcnow() + ) + + # Insert into database + result = await db.services.insert_one(service.model_dump(by_alias=True, exclude={"id"})) + service.id = str(result.inserted_id) + + return service + + @staticmethod + async def get_service(service_id: str) -> Service: + """Get service by ID""" + db = MongoDB.db + + if not ObjectId.is_valid(service_id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid service ID" + ) + + service_doc = await db.services.find_one({"_id": ObjectId(service_id)}) + if not service_doc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Service not found" + ) + + return Service(**service_doc) + + @staticmethod + async def get_all_services() -> List[Service]: + """Get all services""" + db = MongoDB.db + + cursor = db.services.find() + services = [] + async for doc in cursor: + services.append(Service(**doc)) + + return services + + @staticmethod + async def update_service(service_id: str, service_data: ServiceUpdate) -> Service: + """Update a service""" + db = MongoDB.db + + if not ObjectId.is_valid(service_id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid service ID" + ) + + # Get existing service + existing = await db.services.find_one({"_id": ObjectId(service_id)}) + if not existing: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Service not found" + ) + + # Update only provided fields + update_data = service_data.model_dump(exclude_unset=True) + if update_data: + update_data["updated_at"] = datetime.utcnow() + + # Check for name conflict if name is being updated + if "name" in update_data: + name_conflict = await db.services.find_one({ + "name": update_data["name"], + "_id": {"$ne": ObjectId(service_id)} + }) + if name_conflict: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Service with this name already exists" + ) + + await db.services.update_one( + {"_id": ObjectId(service_id)}, + {"$set": update_data} + ) + + # Return updated service + updated_doc = await db.services.find_one({"_id": ObjectId(service_id)}) + return Service(**updated_doc) + + @staticmethod + async def delete_service(service_id: str) -> bool: + """Delete a service""" + db = MongoDB.db + + if not ObjectId.is_valid(service_id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid service ID" + ) + + result = await db.services.delete_one({"_id": ObjectId(service_id)}) + if result.deleted_count == 0: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Service not found" + ) + + return True + + @staticmethod + async def check_service_health(service_id: str) -> ServiceHealthCheck: + """Check health of a specific service""" + db = MongoDB.db + + # Get service + service = await ServiceService.get_service(service_id) + + # Perform health check + start_time = time.time() + status_result = ServiceStatus.UNKNOWN + error_message = None + + try: + health_url = f"{service.url.rstrip('/')}{service.health_endpoint or '/health'}" + + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.get(health_url) + + if response.status_code == 200: + status_result = ServiceStatus.HEALTHY + else: + status_result = ServiceStatus.UNHEALTHY + error_message = f"HTTP {response.status_code}" + + except httpx.TimeoutException: + status_result = ServiceStatus.UNHEALTHY + error_message = "Request timeout" + + except httpx.RequestError as e: + status_result = ServiceStatus.UNHEALTHY + error_message = f"Connection error: {str(e)}" + + except Exception as e: + status_result = ServiceStatus.UNHEALTHY + error_message = f"Error: {str(e)}" + + response_time = (time.time() - start_time) * 1000 # Convert to ms + checked_at = datetime.utcnow() + + # Update service status in database + await db.services.update_one( + {"_id": ObjectId(service_id)}, + { + "$set": { + "status": status_result, + "last_health_check": checked_at, + "response_time_ms": response_time if status_result == ServiceStatus.HEALTHY else None, + "updated_at": checked_at + } + } + ) + + return ServiceHealthCheck( + service_id=service_id, + service_name=service.name, + status=status_result, + response_time_ms=response_time if status_result == ServiceStatus.HEALTHY else None, + checked_at=checked_at, + error_message=error_message + ) + + @staticmethod + async def check_all_services_health() -> List[ServiceHealthCheck]: + """Check health of all services""" + services = await ServiceService.get_all_services() + results = [] + + for service in services: + result = await ServiceService.check_service_health(str(service.id)) + results.append(result) + + return results diff --git a/services/console/frontend/src/api/service.ts b/services/console/frontend/src/api/service.ts new file mode 100644 index 0000000..3844b54 --- /dev/null +++ b/services/console/frontend/src/api/service.ts @@ -0,0 +1,45 @@ +import api from './auth'; +import type { Service, ServiceCreate, ServiceUpdate, ServiceHealthCheck } from '../types/service'; + +export const serviceAPI = { + // Get all services + getAll: async (): Promise => { + const { data } = await api.get('/api/services'); + return data; + }, + + // Get service by ID + getById: async (id: string): Promise => { + const { data } = await api.get(`/api/services/${id}`); + return data; + }, + + // Create new service + create: async (serviceData: ServiceCreate): Promise => { + const { data } = await api.post('/api/services', serviceData); + return data; + }, + + // Update service + update: async (id: string, serviceData: ServiceUpdate): Promise => { + const { data} = await api.put(`/api/services/${id}`, serviceData); + return data; + }, + + // Delete service + delete: async (id: string): Promise => { + await api.delete(`/api/services/${id}`); + }, + + // Check service health + checkHealth: async (id: string): Promise => { + const { data } = await api.post(`/api/services/${id}/health-check`); + return data; + }, + + // Check all services health + checkAllHealth: async (): Promise => { + const { data } = await api.post('/api/services/health-check/all'); + return data; + }, +}; diff --git a/services/console/frontend/src/types/service.ts b/services/console/frontend/src/types/service.ts new file mode 100644 index 0000000..fb2a81b --- /dev/null +++ b/services/console/frontend/src/types/service.ts @@ -0,0 +1,56 @@ +export interface Service { + _id: string; + name: string; + description?: string; + service_type: ServiceType; + url: string; + health_endpoint?: string; + status: ServiceStatus; + last_health_check?: string; + response_time_ms?: number; + created_at: string; + updated_at: string; + metadata: Record; +} + +export enum ServiceType { + BACKEND = 'backend', + FRONTEND = 'frontend', + DATABASE = 'database', + CACHE = 'cache', + MESSAGE_QUEUE = 'message_queue', + OTHER = 'other', +} + +export enum ServiceStatus { + HEALTHY = 'healthy', + UNHEALTHY = 'unhealthy', + UNKNOWN = 'unknown', +} + +export interface ServiceCreate { + name: string; + description?: string; + service_type: ServiceType; + url: string; + health_endpoint?: string; + metadata?: Record; +} + +export interface ServiceUpdate { + name?: string; + description?: string; + service_type?: ServiceType; + url?: string; + health_endpoint?: string; + metadata?: Record; +} + +export interface ServiceHealthCheck { + service_id: string; + service_name: string; + status: ServiceStatus; + response_time_ms?: number; + checked_at: string; + error_message?: string; +}