From 8326c84be9f6bbb715c8220923eeece86351dfb7 Mon Sep 17 00:00:00 2001 From: jungwoo choi Date: Fri, 13 Feb 2026 19:15:27 +0900 Subject: [PATCH] feat: 3-mode inspection with tabbed UI + batch upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add batch inspection backend (multipart upload, SSE streaming, MongoDB) - Add tabbed UI (single page / site crawling / batch upload) on home and history pages - Add batch inspection progress, result pages with 2-panel layout - Rename "사이트 전체" to "사이트 크롤링" across codebase - Add python-multipart dependency for file upload - Consolidate nginx SSE location for all inspection types Co-Authored-By: Claude Opus 4.6 --- backend/app/core/config.py | 4 + backend/app/core/database.py | 5 + backend/app/main.py | 3 +- backend/app/models/batch_schemas.py | 86 +++ backend/app/routers/batch_inspections.py | 286 ++++++++++ .../app/services/batch_inspection_service.py | 537 ++++++++++++++++++ backend/requirements.txt | 3 + .../src/app/batch-inspections/[id]/page.tsx | 481 ++++++++++++++++ .../batch-inspections/[id]/progress/page.tsx | 33 ++ frontend/src/app/history/page.tsx | 331 +++++++++-- frontend/src/app/page.tsx | 28 +- .../site-inspections/[id]/progress/page.tsx | 2 +- .../batch-inspection/BatchProgress.tsx | 218 +++++++ .../batch-inspection/BatchUrlList.tsx | 180 ++++++ .../components/history/SiteHistoryTable.tsx | 155 +++++ .../components/inspection/BatchUploadForm.tsx | 315 ++++++++++ .../components/inspection/InspectionTabs.tsx | 39 ++ .../inspection/RecentBatchInspections.tsx | 93 +++ .../inspection/RecentSiteInspections.tsx | 95 ++++ .../components/inspection/SinglePageForm.tsx | 104 ++++ .../components/inspection/SiteCrawlForm.tsx | 206 +++++++ .../site-inspection/AggregateScorePanel.tsx | 6 +- .../components/site-inspection/PageTree.tsx | 4 +- .../site-inspection/SiteCrawlProgress.tsx | 2 +- frontend/src/hooks/useBatchInspectionSSE.ts | 123 ++++ frontend/src/hooks/useSiteInspectionSSE.ts | 4 +- frontend/src/lib/api.ts | 80 ++- frontend/src/lib/queries.ts | 48 +- .../src/stores/useBatchInspectionStore.ts | 143 +++++ frontend/src/types/batch-inspection.ts | 117 ++++ frontend/src/types/site-inspection.ts | 24 +- nginx/nginx.conf | 6 +- 32 files changed, 3700 insertions(+), 61 deletions(-) create mode 100644 backend/app/models/batch_schemas.py create mode 100644 backend/app/routers/batch_inspections.py create mode 100644 backend/app/services/batch_inspection_service.py create mode 100644 frontend/src/app/batch-inspections/[id]/page.tsx create mode 100644 frontend/src/app/batch-inspections/[id]/progress/page.tsx create mode 100644 frontend/src/components/batch-inspection/BatchProgress.tsx create mode 100644 frontend/src/components/batch-inspection/BatchUrlList.tsx create mode 100644 frontend/src/components/history/SiteHistoryTable.tsx create mode 100644 frontend/src/components/inspection/BatchUploadForm.tsx create mode 100644 frontend/src/components/inspection/InspectionTabs.tsx create mode 100644 frontend/src/components/inspection/RecentBatchInspections.tsx create mode 100644 frontend/src/components/inspection/RecentSiteInspections.tsx create mode 100644 frontend/src/components/inspection/SinglePageForm.tsx create mode 100644 frontend/src/components/inspection/SiteCrawlForm.tsx create mode 100644 frontend/src/hooks/useBatchInspectionSSE.ts create mode 100644 frontend/src/stores/useBatchInspectionStore.ts create mode 100644 frontend/src/types/batch-inspection.ts diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 8724e0e..4d35a0a 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -26,6 +26,10 @@ class Settings(BaseSettings): SITE_MAX_DEPTH: int = 2 SITE_CONCURRENCY: int = 8 + # Batch inspection + BATCH_MAX_URLS: int = 200 + BATCH_CONCURRENCY: int = 8 + # Application PROJECT_NAME: str = "Web Inspector API" diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 048dd5d..77efc55 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -29,6 +29,11 @@ async def connect_db() -> None: await _db.site_inspections.create_index([("domain", 1), ("created_at", -1)]) await _db.site_inspections.create_index([("created_at", -1)]) + # Create indexes - batch_inspections + await _db.batch_inspections.create_index("batch_inspection_id", unique=True) + await _db.batch_inspections.create_index([("name", 1), ("created_at", -1)]) + await _db.batch_inspections.create_index([("created_at", -1)]) + # Verify connection await _client.admin.command("ping") logger.info("MongoDB connected successfully: %s", settings.DB_NAME) diff --git a/backend/app/main.py b/backend/app/main.py index 48ba6d8..b19472e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -10,7 +10,7 @@ from fastapi.middleware.cors import CORSMiddleware from app.core.database import connect_db, close_db from app.core.redis import connect_redis, close_redis -from app.routers import health, inspections, reports, site_inspections +from app.routers import health, inspections, reports, site_inspections, batch_inspections # Configure logging logging.basicConfig( @@ -57,3 +57,4 @@ app.include_router(health.router, prefix="/api", tags=["Health"]) app.include_router(inspections.router, prefix="/api", tags=["Inspections"]) app.include_router(reports.router, prefix="/api", tags=["Reports"]) app.include_router(site_inspections.router, prefix="/api", tags=["Site Inspections"]) +app.include_router(batch_inspections.router, prefix="/api", tags=["Batch Inspections"]) diff --git a/backend/app/models/batch_schemas.py b/backend/app/models/batch_schemas.py new file mode 100644 index 0000000..9fc828e --- /dev/null +++ b/backend/app/models/batch_schemas.py @@ -0,0 +1,86 @@ +""" +Pydantic models for batch inspection request/response validation. + +Batch inspection allows inspecting multiple URLs from a file upload +without any crawling phase - URLs are inspected directly. +""" + +from pydantic import BaseModel, Field +from typing import Optional +from datetime import datetime +from enum import Enum + +from app.models.site_schemas import AggregateScores, PageStatus + + +# --- Enums --- + +class BatchInspectionStatus(str, Enum): + """Batch inspection status (no crawling phase).""" + INSPECTING = "inspecting" + COMPLETED = "completed" + ERROR = "error" + + +# --- Core Data Models --- + +class BatchPage(BaseModel): + """배치 검사 개별 페이지 (크롤링 없이 직접 지정된 URL).""" + url: str + depth: int = 0 + parent_url: Optional[str] = None + inspection_id: Optional[str] = None + status: PageStatus = PageStatus.PENDING + title: Optional[str] = None + overall_score: Optional[int] = None + grade: Optional[str] = None + + +class BatchInspectionConfig(BaseModel): + """배치 검사 설정.""" + concurrency: int = 4 + + +# --- Response Models --- + +class StartBatchInspectionResponse(BaseModel): + """배치 검사 시작 응답.""" + batch_inspection_id: str + status: str = "inspecting" + name: str + total_urls: int + stream_url: str + + +class BatchInspectionResult(BaseModel): + """배치 검사 전체 결과.""" + batch_inspection_id: str + name: str + status: BatchInspectionStatus + created_at: datetime + completed_at: Optional[datetime] = None + config: BatchInspectionConfig + source_urls: list[str] = [] + discovered_pages: list[BatchPage] = [] + aggregate_scores: Optional[AggregateScores] = None + + +class BatchInspectionListItem(BaseModel): + """배치 검사 목록 항목 (요약).""" + batch_inspection_id: str + name: str + status: BatchInspectionStatus + created_at: datetime + total_urls: int = 0 + pages_inspected: int = 0 + overall_score: Optional[int] = None + grade: Optional[str] = None + + +class BatchInspectionPaginatedResponse(BaseModel): + """배치 검사 목록 페이지네이션 응답.""" + items: list[BatchInspectionListItem] + total: int + page: int + limit: int + total_pages: int diff --git a/backend/app/routers/batch_inspections.py b/backend/app/routers/batch_inspections.py new file mode 100644 index 0000000..ee5f913 --- /dev/null +++ b/backend/app/routers/batch_inspections.py @@ -0,0 +1,286 @@ +""" +Batch inspections router. +Handles batch inspection lifecycle: + - Start batch inspection (multipart file upload + inspect all URLs) + - SSE stream for real-time progress + - Get batch inspection result + - List batch inspections (history) + +IMPORTANT: Static paths (/batch-inspections) must be registered BEFORE +dynamic paths (/batch-inspections/{id}) to avoid routing conflicts. +""" + +import json +import logging +from urllib.parse import urlparse + +from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile +from sse_starlette.sse import EventSourceResponse + +from app.core.config import get_settings +from app.core.database import get_db +from app.core.redis import get_redis +from app.models.batch_schemas import StartBatchInspectionResponse +from app.services.batch_inspection_service import BatchInspectionService + +logger = logging.getLogger(__name__) + +router = APIRouter() + +# Maximum upload file size: 1MB +MAX_FILE_SIZE = 1 * 1024 * 1024 # 1MB + + +def _get_service() -> BatchInspectionService: + """Get BatchInspectionService instance.""" + db = get_db() + redis = get_redis() + return BatchInspectionService(db=db, redis=redis) + + +def _validate_url(url: str) -> bool: + """Validate that a URL has http or https scheme.""" + try: + parsed = urlparse(url) + return parsed.scheme in ("http", "https") and bool(parsed.netloc) + except Exception: + return False + + +def _parse_url_file(content: str) -> list[str]: + """ + Parse URL file content into a list of valid URLs. + - One URL per line + - Skip empty lines + - Skip lines starting with # (comments) + - Strip whitespace + """ + urls = [] + for line in content.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + urls.append(line) + return urls + + +# ============================================================ +# POST /api/batch-inspections -- Start batch inspection +# ============================================================ + +@router.post("/batch-inspections", status_code=202) +async def start_batch_inspection( + file: UploadFile = File(...), + name: str = Form(...), + concurrency: int = Form(default=4), +): + """ + Start a new batch inspection from an uploaded URL file. + Returns 202 Accepted with batch_inspection_id immediately. + Inspection runs asynchronously in the background. + + File format: .txt, one URL per line, max 1MB, max 200 URLs. + Lines starting with # are treated as comments and ignored. + """ + settings = get_settings() + + # Validate file extension + if file.filename and not file.filename.lower().endswith(".txt"): + raise HTTPException( + status_code=422, + detail="텍스트 파일(.txt)만 업로드 가능합니다", + ) + + # Validate file size + content_bytes = await file.read() + if len(content_bytes) > MAX_FILE_SIZE: + raise HTTPException( + status_code=422, + detail="파일 크기가 1MB를 초과합니다", + ) + + # Parse file content + try: + content = content_bytes.decode("utf-8") + except UnicodeDecodeError: + raise HTTPException( + status_code=422, + detail="파일 인코딩이 올바르지 않습니다 (UTF-8만 지원)", + ) + + urls = _parse_url_file(content) + + if not urls: + raise HTTPException( + status_code=422, + detail="파일에 유효한 URL이 없습니다", + ) + + # Validate URL count + if len(urls) > settings.BATCH_MAX_URLS: + raise HTTPException( + status_code=422, + detail=f"URL 수가 최대 허용치({settings.BATCH_MAX_URLS}개)를 초과합니다 (입력: {len(urls)}개)", + ) + + # Validate each URL + invalid_urls = [url for url in urls if not _validate_url(url)] + if invalid_urls: + # Show first 5 invalid URLs + sample = invalid_urls[:5] + detail = f"유효하지 않은 URL이 포함되어 있습니다: {', '.join(sample)}" + if len(invalid_urls) > 5: + detail += f" 외 {len(invalid_urls) - 5}건" + raise HTTPException( + status_code=422, + detail=detail, + ) + + # Validate concurrency + concurrency = max(1, min(concurrency, settings.BATCH_CONCURRENCY)) + + # Validate name + name = name.strip() + if not name: + raise HTTPException( + status_code=422, + detail="배치 검사 이름을 입력해주세요", + ) + + service = _get_service() + + try: + batch_inspection_id = await service.start_batch_inspection( + name=name, + urls=urls, + concurrency=concurrency, + ) + except Exception as e: + logger.error("Failed to start batch inspection: %s", str(e)) + raise HTTPException( + status_code=400, + detail="배치 검사를 시작할 수 없습니다", + ) + + return StartBatchInspectionResponse( + batch_inspection_id=batch_inspection_id, + status="inspecting", + name=name, + total_urls=len(urls), + stream_url=f"/api/batch-inspections/{batch_inspection_id}/stream", + ) + + +# ============================================================ +# GET /api/batch-inspections -- List batch inspections (history) +# IMPORTANT: This MUST be before /{batch_inspection_id} routes +# ============================================================ + +@router.get("/batch-inspections") +async def list_batch_inspections( + page: int = Query(default=1, ge=1), + limit: int = Query(default=20, ge=1, le=100), + name: str = Query(default=None, description="이름 검색 필터"), +): + """Get paginated batch inspection history.""" + service = _get_service() + result = await service.get_batch_inspection_list( + page=page, + limit=limit, + name_filter=name, + ) + return result + + +# ============================================================ +# GET /api/batch-inspections/{batch_inspection_id}/stream -- SSE +# ============================================================ + +@router.get("/batch-inspections/{batch_inspection_id}/stream") +async def batch_inspection_stream(batch_inspection_id: str): + """ + Stream batch inspection progress via Server-Sent Events. + + Events: + - connected: { batch_inspection_id, message } + - page_start: { batch_inspection_id, page_url, page_index } + - page_complete: { batch_inspection_id, page_url, inspection_id, overall_score, grade } + - aggregate_update: { batch_inspection_id, pages_inspected, pages_total, overall_score, grade } + - complete: { batch_inspection_id, status: "completed", aggregate_scores: {...} } + - error: { batch_inspection_id, message } + """ + + async def event_generator(): + redis = get_redis() + pubsub = redis.pubsub() + channel = f"batch-inspection:{batch_inspection_id}:events" + + await pubsub.subscribe(channel) + + try: + # Send initial connected event + yield { + "event": "connected", + "data": json.dumps({ + "batch_inspection_id": batch_inspection_id, + "message": "SSE 연결 완료", + }, ensure_ascii=False), + } + + # Listen for Pub/Sub messages + async for message in pubsub.listen(): + if message["type"] == "message": + event_data = json.loads(message["data"]) + event_type = event_data.pop("event_type", "progress") + + yield { + "event": event_type, + "data": json.dumps(event_data, ensure_ascii=False), + } + + # End stream on complete or error + if event_type in ("complete", "error"): + break + + except Exception as e: + logger.error( + "SSE stream error for batch %s: %s", + batch_inspection_id, str(e), + ) + yield { + "event": "error", + "data": json.dumps({ + "batch_inspection_id": batch_inspection_id, + "status": "error", + "message": "스트리밍 중 오류가 발생했습니다", + }, ensure_ascii=False), + } + finally: + await pubsub.unsubscribe(channel) + await pubsub.aclose() + + return EventSourceResponse( + event_generator(), + media_type="text/event-stream", + ) + + +# ============================================================ +# GET /api/batch-inspections/{batch_inspection_id} -- Get result +# ============================================================ + +@router.get("/batch-inspections/{batch_inspection_id}") +async def get_batch_inspection(batch_inspection_id: str): + """Get batch inspection result by ID.""" + service = _get_service() + result = await service.get_batch_inspection(batch_inspection_id) + + if result is None: + raise HTTPException( + status_code=404, + detail="배치 검사 결과를 찾을 수 없습니다", + ) + + # Remove MongoDB _id field if present + result.pop("_id", None) + return result diff --git a/backend/app/services/batch_inspection_service.py b/backend/app/services/batch_inspection_service.py new file mode 100644 index 0000000..fd656d1 --- /dev/null +++ b/backend/app/services/batch_inspection_service.py @@ -0,0 +1,537 @@ +""" +Batch inspection orchestration service. + +Manages batch inspection lifecycle without crawling: + 1. Accept a list of URLs (from uploaded file) + 2. Parallel inspection of each URL (semaphore-controlled) + 3. Aggregate score computation + 4. Progress tracking via Redis Pub/Sub (SSE events) + 5. Result storage in MongoDB (batch_inspections collection) +""" + +import asyncio +import json +import logging +import uuid +from datetime import datetime, timezone +from typing import Optional + +from motor.motor_asyncio import AsyncIOMotorDatabase +from redis.asyncio import Redis + +from app.core.config import get_settings +from app.models.schemas import calculate_grade +from app.services.inspection_service import InspectionService + +logger = logging.getLogger(__name__) + +# Redis key TTLs +BATCH_RESULT_CACHE_TTL = 3600 # 1 hour + + +class BatchInspectionService: + """Batch inspection orchestration service.""" + + def __init__(self, db: AsyncIOMotorDatabase, redis: Redis): + self.db = db + self.redis = redis + self.inspection_service = InspectionService(db=db, redis=redis) + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + async def start_batch_inspection( + self, + name: str, + urls: list[str], + concurrency: int = 4, + ) -> str: + """ + Start a batch inspection. + + 1. Generate batch_inspection_id + 2. Create initial MongoDB document with status "inspecting" + 3. Build discovered_pages array (depth=0, parent_url=None) + 4. Launch background inspect-all task + 5. Return batch_inspection_id + """ + settings = get_settings() + + # Clamp concurrency to server-side limit + concurrency = min(concurrency, settings.BATCH_CONCURRENCY) + + batch_inspection_id = str(uuid.uuid4()) + + # Build discovered_pages documents + discovered_pages = [] + for url in urls: + discovered_pages.append({ + "url": url, + "depth": 0, + "parent_url": None, + "inspection_id": None, + "status": "pending", + "title": None, + "overall_score": None, + "grade": None, + }) + + # Create initial document + doc = { + "batch_inspection_id": batch_inspection_id, + "name": name, + "status": "inspecting", + "created_at": datetime.now(timezone.utc), + "completed_at": None, + "config": { + "concurrency": concurrency, + }, + "source_urls": urls, + "discovered_pages": discovered_pages, + "aggregate_scores": None, + } + await self.db.batch_inspections.insert_one(doc) + + logger.info( + "Batch inspection started: id=%s, name=%s, total_urls=%d, concurrency=%d", + batch_inspection_id, name, len(urls), concurrency, + ) + + # Launch background task + asyncio.create_task( + self._inspect_all(batch_inspection_id, urls, concurrency) + ) + + return batch_inspection_id + + async def get_batch_inspection(self, batch_inspection_id: str) -> Optional[dict]: + """Get batch inspection result by ID (cache-first).""" + # Try Redis cache first + cache_key = f"batch-inspection:result:{batch_inspection_id}" + cached = await self.redis.get(cache_key) + if cached: + return json.loads(cached) + + # Fetch from MongoDB + doc = await self.db.batch_inspections.find_one( + {"batch_inspection_id": batch_inspection_id}, + {"_id": 0}, + ) + if doc: + # Only cache completed results + if doc.get("status") in ("completed", "error"): + await self.redis.set( + cache_key, + json.dumps(doc, ensure_ascii=False, default=str), + ex=BATCH_RESULT_CACHE_TTL, + ) + return doc + return None + + async def get_batch_inspection_list( + self, + page: int = 1, + limit: int = 20, + name_filter: Optional[str] = None, + ) -> dict: + """Get paginated list of batch inspections.""" + limit = min(limit, 100) + skip = (page - 1) * limit + + # Build query + query = {} + if name_filter: + query["name"] = {"$regex": name_filter, "$options": "i"} + + total = await self.db.batch_inspections.count_documents(query) + + cursor = self.db.batch_inspections.find( + query, + { + "_id": 0, + "batch_inspection_id": 1, + "name": 1, + "status": 1, + "created_at": 1, + "discovered_pages": 1, + "aggregate_scores": 1, + "source_urls": 1, + }, + ).sort("created_at", -1).skip(skip).limit(limit) + + items = [] + async for doc in cursor: + pages = doc.get("discovered_pages", []) + total_urls = len(doc.get("source_urls", [])) + pages_inspected = sum( + 1 for p in pages if p.get("status") == "completed" + ) + agg = doc.get("aggregate_scores") + + items.append({ + "batch_inspection_id": doc.get("batch_inspection_id"), + "name": doc.get("name"), + "status": doc.get("status"), + "created_at": doc.get("created_at"), + "total_urls": total_urls, + "pages_inspected": pages_inspected, + "overall_score": agg.get("overall_score") if agg else None, + "grade": agg.get("grade") if agg else None, + }) + + total_pages = max(1, -(-total // limit)) + + return { + "items": items, + "total": total, + "page": page, + "limit": limit, + "total_pages": total_pages, + } + + # ------------------------------------------------------------------ + # Background task: Inspect All URLs + # ------------------------------------------------------------------ + + async def _inspect_all( + self, + batch_inspection_id: str, + urls: list[str], + concurrency: int = 4, + ) -> None: + """ + Background task that inspects all URLs in parallel. + No crawling phase - URLs are inspected directly. + """ + try: + logger.info( + "Batch inspection started: %s, urls=%d", + batch_inspection_id, len(urls), + ) + + semaphore = asyncio.Semaphore(concurrency) + + tasks = [ + self._inspect_page_with_semaphore( + semaphore=semaphore, + batch_inspection_id=batch_inspection_id, + page_url=url, + page_index=idx, + total_pages=len(urls), + ) + for idx, url in enumerate(urls) + ] + + await asyncio.gather(*tasks, return_exceptions=True) + + # ============================== + # Finalize: Compute aggregates + # ============================== + aggregate_scores = await self._compute_and_store_aggregates(batch_inspection_id) + + # Mark as completed + await self.db.batch_inspections.update_one( + {"batch_inspection_id": batch_inspection_id}, + { + "$set": { + "status": "completed", + "completed_at": datetime.now(timezone.utc), + } + }, + ) + + # Publish complete event + await self._publish_batch_event(batch_inspection_id, { + "event_type": "complete", + "batch_inspection_id": batch_inspection_id, + "status": "completed", + "aggregate_scores": aggregate_scores, + }) + + logger.info("Batch inspection completed: %s", batch_inspection_id) + + except Exception as e: + logger.error( + "Batch inspection %s failed: %s", + batch_inspection_id, str(e), exc_info=True, + ) + + await self.db.batch_inspections.update_one( + {"batch_inspection_id": batch_inspection_id}, + { + "$set": { + "status": "error", + "completed_at": datetime.now(timezone.utc), + } + }, + ) + + await self._publish_batch_event(batch_inspection_id, { + "event_type": "error", + "batch_inspection_id": batch_inspection_id, + "status": "error", + "message": f"배치 검사 중 오류가 발생했습니다: {str(e)[:200]}", + }) + + async def _inspect_page_with_semaphore( + self, + semaphore: asyncio.Semaphore, + batch_inspection_id: str, + page_url: str, + page_index: int, + total_pages: int, + ) -> None: + """Inspect a single page with semaphore-controlled concurrency.""" + async with semaphore: + await self._inspect_single_page( + batch_inspection_id=batch_inspection_id, + page_url=page_url, + page_index=page_index, + total_pages=total_pages, + ) + + async def _inspect_single_page( + self, + batch_inspection_id: str, + page_url: str, + page_index: int, + total_pages: int, + ) -> None: + """Run inspection for a single page in the batch.""" + inspection_id = str(uuid.uuid4()) + + # Publish page_start event + await self._publish_batch_event(batch_inspection_id, { + "event_type": "page_start", + "batch_inspection_id": batch_inspection_id, + "page_url": page_url, + "page_index": page_index, + }) + + # Mark page as inspecting in MongoDB + await self.db.batch_inspections.update_one( + { + "batch_inspection_id": batch_inspection_id, + "discovered_pages.url": page_url, + }, + { + "$set": { + "discovered_pages.$.status": "inspecting", + "discovered_pages.$.inspection_id": inspection_id, + } + }, + ) + + try: + # Progress callback for per-page SSE updates + async def page_progress_callback(category: str, progress: int, current_step: str): + await self._publish_batch_event(batch_inspection_id, { + "event_type": "page_progress", + "batch_inspection_id": batch_inspection_id, + "page_url": page_url, + "page_index": page_index, + "category": category, + "progress": progress, + "current_step": current_step, + }) + + # Run the inspection + _, result = await self.inspection_service.run_inspection_inline( + url=page_url, + inspection_id=inspection_id, + progress_callback=page_progress_callback, + ) + + overall_score = result.get("overall_score", 0) + grade = result.get("grade", "F") + + # Update page status in MongoDB + await self.db.batch_inspections.update_one( + { + "batch_inspection_id": batch_inspection_id, + "discovered_pages.url": page_url, + }, + { + "$set": { + "discovered_pages.$.status": "completed", + "discovered_pages.$.overall_score": overall_score, + "discovered_pages.$.grade": grade, + } + }, + ) + + # Publish page_complete event + await self._publish_batch_event(batch_inspection_id, { + "event_type": "page_complete", + "batch_inspection_id": batch_inspection_id, + "page_url": page_url, + "inspection_id": inspection_id, + "overall_score": overall_score, + "grade": grade, + }) + + # Compute and publish aggregate update + aggregate_scores = await self._compute_and_store_aggregates(batch_inspection_id) + await self._publish_batch_event(batch_inspection_id, { + "event_type": "aggregate_update", + "batch_inspection_id": batch_inspection_id, + "pages_inspected": aggregate_scores.get("pages_inspected", 0), + "pages_total": aggregate_scores.get("pages_total", total_pages), + "overall_score": aggregate_scores.get("overall_score", 0), + "grade": aggregate_scores.get("grade", "F"), + }) + + logger.info( + "Page inspection completed: batch=%s, page=%s, score=%d", + batch_inspection_id, page_url, overall_score, + ) + + except Exception as e: + logger.error( + "Page inspection failed: batch=%s, page=%s, error=%s", + batch_inspection_id, page_url, str(e), + ) + + # Mark page as error + await self.db.batch_inspections.update_one( + { + "batch_inspection_id": batch_inspection_id, + "discovered_pages.url": page_url, + }, + { + "$set": { + "discovered_pages.$.status": "error", + } + }, + ) + + # Publish page error (non-fatal, continue with other pages) + await self._publish_batch_event(batch_inspection_id, { + "event_type": "page_complete", + "batch_inspection_id": batch_inspection_id, + "page_url": page_url, + "inspection_id": None, + "overall_score": 0, + "grade": "F", + "error": str(e)[:200], + }) + + # ------------------------------------------------------------------ + # Aggregate computation + # ------------------------------------------------------------------ + + async def _compute_and_store_aggregates(self, batch_inspection_id: str) -> dict: + """ + Compute aggregate scores from all completed page inspections. + + Fetches each completed page's full inspection result from the + inspections collection, averages category scores, and stores + the aggregate in the batch_inspections document. + + Returns the aggregate_scores dict. + """ + doc = await self.db.batch_inspections.find_one( + {"batch_inspection_id": batch_inspection_id}, + ) + if not doc: + return {} + + pages = doc.get("discovered_pages", []) + total_pages = len(pages) + + # Collect inspection IDs for completed pages + completed_ids = [ + p["inspection_id"] + for p in pages + if p.get("status") == "completed" and p.get("inspection_id") + ] + + if not completed_ids: + aggregate = { + "overall_score": 0, + "grade": "F", + "html_css": 0, + "accessibility": 0, + "seo": 0, + "performance_security": 0, + "total_issues": 0, + "pages_inspected": 0, + "pages_total": total_pages, + } + await self._store_aggregates(batch_inspection_id, aggregate) + return aggregate + + # Fetch all completed inspection results + cursor = self.db.inspections.find( + {"inspection_id": {"$in": completed_ids}}, + { + "_id": 0, + "overall_score": 1, + "categories.html_css.score": 1, + "categories.accessibility.score": 1, + "categories.seo.score": 1, + "categories.performance_security.score": 1, + "summary.total_issues": 1, + }, + ) + + scores_overall = [] + scores_html_css = [] + scores_accessibility = [] + scores_seo = [] + scores_perf = [] + total_issues = 0 + + async for insp in cursor: + scores_overall.append(insp.get("overall_score", 0)) + + cats = insp.get("categories", {}) + scores_html_css.append(cats.get("html_css", {}).get("score", 0)) + scores_accessibility.append(cats.get("accessibility", {}).get("score", 0)) + scores_seo.append(cats.get("seo", {}).get("score", 0)) + scores_perf.append(cats.get("performance_security", {}).get("score", 0)) + + total_issues += insp.get("summary", {}).get("total_issues", 0) + + pages_inspected = len(scores_overall) + + def safe_avg(values: list[int]) -> int: + return round(sum(values) / len(values)) if values else 0 + + overall_score = safe_avg(scores_overall) + grade = calculate_grade(overall_score) + + aggregate = { + "overall_score": overall_score, + "grade": grade, + "html_css": safe_avg(scores_html_css), + "accessibility": safe_avg(scores_accessibility), + "seo": safe_avg(scores_seo), + "performance_security": safe_avg(scores_perf), + "total_issues": total_issues, + "pages_inspected": pages_inspected, + "pages_total": total_pages, + } + + await self._store_aggregates(batch_inspection_id, aggregate) + return aggregate + + async def _store_aggregates(self, batch_inspection_id: str, aggregate: dict) -> None: + """Store aggregate scores in MongoDB.""" + await self.db.batch_inspections.update_one( + {"batch_inspection_id": batch_inspection_id}, + {"$set": {"aggregate_scores": aggregate}}, + ) + + # ------------------------------------------------------------------ + # SSE event publishing + # ------------------------------------------------------------------ + + async def _publish_batch_event(self, batch_inspection_id: str, event_data: dict) -> None: + """Publish an SSE event for batch inspection via Redis Pub/Sub.""" + channel = f"batch-inspection:{batch_inspection_id}:events" + await self.redis.publish( + channel, + json.dumps(event_data, ensure_ascii=False, default=str), + ) diff --git a/backend/requirements.txt b/backend/requirements.txt index 9812ae1..5a2bc7b 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -33,5 +33,8 @@ Jinja2>=3.1.0 # Rules (YAML) PyYAML>=6.0.0 +# Multipart (file upload) +python-multipart>=0.0.7 + # Utilities python-slugify>=8.0.0 diff --git a/frontend/src/app/batch-inspections/[id]/page.tsx b/frontend/src/app/batch-inspections/[id]/page.tsx new file mode 100644 index 0000000..3ae209d --- /dev/null +++ b/frontend/src/app/batch-inspections/[id]/page.tsx @@ -0,0 +1,481 @@ +"use client"; + +import { use, useState, useCallback } from "react"; +import { useBatchInspectionResult, useInspectionResult, useInspectionIssues } from "@/lib/queries"; +import { BatchUrlList } from "@/components/batch-inspection/BatchUrlList"; +import { OverallScoreGauge } from "@/components/dashboard/OverallScoreGauge"; +import { CategoryScoreCard } from "@/components/dashboard/CategoryScoreCard"; +import { IssueSummaryBar } from "@/components/dashboard/IssueSummaryBar"; +import { InspectionMeta } from "@/components/dashboard/InspectionMeta"; +import { FilterBar } from "@/components/issues/FilterBar"; +import { IssueList } from "@/components/issues/IssueList"; +import { LoadingSpinner } from "@/components/common/LoadingSpinner"; +import { ErrorState } from "@/components/common/ErrorState"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { CATEGORY_LABELS, CATEGORY_KEYS, getScoreTailwindColor } from "@/lib/constants"; +import { cn } from "@/lib/utils"; +import { ApiError } from "@/lib/api"; +import { Clock, Menu, X, ArrowLeft, FileText } from "lucide-react"; +import type { CategoryKey } from "@/types/inspection"; + +export default function BatchInspectionResultPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = use(params); + const [selectedPageUrl, setSelectedPageUrl] = useState(null); + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + + const { + data: batchResult, + isLoading, + isError, + error, + refetch, + } = useBatchInspectionResult(id); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError || !batchResult) { + return ( +
+ refetch()} + /> +
+ ); + } + + // 선택된 페이지의 inspection_id 찾기 + const selectedPage = selectedPageUrl + ? batchResult.discovered_pages.find((p) => p.url === selectedPageUrl) + : null; + + const handleSelectPage = (url: string | null) => { + setSelectedPageUrl(url); + // 모바일: 사이드바 닫기 + setIsSidebarOpen(false); + }; + + return ( +
+ {/* 배치 이름 헤더 */} +
+ + {batchResult.name} +
+ + {/* 모바일 사이드바 토글 */} +
+ + {selectedPageUrl && ( + + {selectedPageUrl} + + )} +
+ +
+ {/* 왼쪽 사이드바: URL 목록 */} + + + {/* 모바일 오버레이 */} + {isSidebarOpen && ( +
setIsSidebarOpen(false)} + /> + )} + + {/* 오른쪽 패널: 결과 표시 */} +
+ {selectedPageUrl === null ? ( + // 전체 집계 보기 + batchResult.aggregate_scores ? ( + + ) : ( +
+ +

아직 집계 결과가 없습니다

+
+ ) + ) : selectedPage?.inspection_id ? ( + // 개별 페이지 결과 (대시보드 재사용) + + ) : ( + // 검사 대기 중인 페이지 +
+ +

검사 대기 중

+

+ 이 페이지는 아직 검사가 완료되지 않았습니다 +

+
+ )} +
+
+
+ ); +} + +/** + * 배치 검사 전체 집계 패널. + * AggregateScorePanel을 배치 검사용으로 래핑한다 (rootUrl 대신 name 표시). + */ +function BatchAggregatePanel({ + aggregateScores, + name, +}: { + aggregateScores: NonNullable< + import("@/types/batch-inspection").BatchInspectionResult["aggregate_scores"] + >; + name: string; +}) { + // AggregateScorePanel은 rootUrl을 받지만, 배치에서는 name을 표시하도록 + // 직접 구현하여 "배치 검사 결과"로 제목 변경 + const categoryItems = [ + { + key: "html_css" as const, + label: CATEGORY_LABELS.html_css, + score: aggregateScores.html_css, + }, + { + key: "accessibility" as const, + label: CATEGORY_LABELS.accessibility, + score: aggregateScores.accessibility, + }, + { + key: "seo" as const, + label: CATEGORY_LABELS.seo, + score: aggregateScores.seo, + }, + { + key: "performance_security" as const, + label: CATEGORY_LABELS.performance_security, + score: aggregateScores.performance_security, + }, + ]; + + return ( +
+ {/* 상단 배치 이름 및 검사 요약 */} +
+

배치 검사 결과

+

{name}

+

+ 검사 완료: {aggregateScores.pages_inspected}/ + {aggregateScores.pages_total} 페이지 +

+
+ + {/* 종합 점수 게이지 */} + + + 배치 종합 점수 + + + + + + + {/* 카테고리별 평균 점수 카드 (4개) */} +
+ {categoryItems.map((item) => ( + + +

+ {item.label} +

+
+ {item.score} +
+

+ 페이지 평균 점수 +

+
+
+ ))} +
+ + {/* 총 이슈 수 */} + + +
+ + 배치 전체 이슈 + + + 총 {aggregateScores.total_issues}건 + +
+
+
+
+ ); +} + +/** + * 개별 페이지 대시보드 컴포넌트. + * 카테고리 클릭 시 이슈 목록을 인라인으로 표시하여 사이드바를 유지한다. + */ +function PageDashboard({ + inspectionId, + pageUrl, +}: { + inspectionId: string; + pageUrl: string; +}) { + // 이슈 보기 모드 상태 + const [issueView, setIssueView] = useState<{ + showing: boolean; + initialCategory: string; + }>({ showing: false, initialCategory: "all" }); + + const { + data: result, + isLoading, + isError, + error, + refetch, + } = useInspectionResult(inspectionId); + + const handleCategoryClick = useCallback( + (category: CategoryKey) => { + setIssueView({ showing: true, initialCategory: category }); + }, + [] + ); + + const handleViewIssues = useCallback(() => { + setIssueView({ showing: true, initialCategory: "all" }); + }, []); + + const handleBackToDashboard = useCallback(() => { + setIssueView({ showing: false, initialCategory: "all" }); + }, []); + + // inspectionId 변경 시 이슈 뷰 초기화 + const [prevId, setPrevId] = useState(inspectionId); + if (inspectionId !== prevId) { + setPrevId(inspectionId); + setIssueView({ showing: false, initialCategory: "all" }); + } + + if (isLoading) { + return ; + } + + if (isError || !result) { + return ( + refetch()} + /> + ); + } + + // 이슈 목록 인라인 보기 + if (issueView.showing) { + return ( + + ); + } + + return ( +
+ {/* 페이지 URL 표시 */} +
+

페이지 검사 결과

+ + {pageUrl} + +
+ + {/* 종합 점수 */} + + + 종합 점수 + + + + + + + {/* 카테고리별 점수 카드 */} +
+ {CATEGORY_KEYS.map((key) => { + const cat = result.categories[key]; + return ( + handleCategoryClick(key)} + /> + ); + })} +
+ + {/* 검사 메타 정보 */} +
+ +
+ + {/* 이슈 상세 링크 */} +
+ +
+ + {/* 이슈 요약 바 */} + + + + + +
+ ); +} + +/** + * 이슈 목록 인라인 뷰. + * 배치 검사 결과 페이지 내에서 이슈를 표시하여 사이드바를 유지한다. + */ +function InlineIssueView({ + inspectionId, + initialCategory, + onBack, +}: { + inspectionId: string; + initialCategory: string; + onBack: () => void; +}) { + const [selectedCategory, setSelectedCategory] = useState(initialCategory); + const [selectedSeverity, setSelectedSeverity] = useState("all"); + + const { data, isLoading, isError, refetch } = useInspectionIssues( + inspectionId, + selectedCategory === "all" ? undefined : selectedCategory, + selectedSeverity === "all" ? undefined : selectedSeverity + ); + + return ( +
+ {/* 뒤로가기 */} +
+ +
+ +

상세 이슈 목록

+ + {/* 필터 */} +
+ +
+ + {/* 이슈 목록 */} + {isError ? ( + refetch()} + /> + ) : ( + + )} +
+ ); +} diff --git a/frontend/src/app/batch-inspections/[id]/progress/page.tsx b/frontend/src/app/batch-inspections/[id]/progress/page.tsx new file mode 100644 index 0000000..e0682e3 --- /dev/null +++ b/frontend/src/app/batch-inspections/[id]/progress/page.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { use, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useBatchInspectionResult } from "@/lib/queries"; +import { BatchProgress } from "@/components/batch-inspection/BatchProgress"; + +export default function BatchInspectionProgressPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = use(params); + const router = useRouter(); + + // 이미 완료된 검사인 경우 결과 페이지로 리다이렉트 + const { data: batchResult } = useBatchInspectionResult(id); + + useEffect(() => { + if (batchResult?.status === "completed" || batchResult?.status === "error") { + router.replace(`/batch-inspections/${id}`); + } + }, [batchResult?.status, id, router]); + + return ( +
+

+ 배치 검사 +

+ +
+ ); +} diff --git a/frontend/src/app/history/page.tsx b/frontend/src/app/history/page.tsx index eb30d2e..e1bb6e6 100644 --- a/frontend/src/app/history/page.tsx +++ b/frontend/src/app/history/page.tsx @@ -1,28 +1,208 @@ "use client"; import { useState } from "react"; -import { useInspectionHistory } from "@/lib/queries"; +import { useRouter } from "next/navigation"; +import { + InspectionTabs, + type InspectionTab, +} from "@/components/inspection/InspectionTabs"; +import { + useInspectionHistory, + useSiteInspectionHistory, + useBatchInspectionHistory, +} from "@/lib/queries"; import { SearchBar } from "@/components/history/SearchBar"; import { InspectionHistoryTable } from "@/components/history/InspectionHistoryTable"; +import { SiteHistoryTable } from "@/components/history/SiteHistoryTable"; import { Pagination } from "@/components/history/Pagination"; import { ErrorState } from "@/components/common/ErrorState"; +import { LoadingSpinner } from "@/components/common/LoadingSpinner"; +import { EmptyState } from "@/components/common/EmptyState"; +import { ScoreBadge } from "@/components/common/ScoreBadge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Button } from "@/components/ui/button"; +import { formatDateTime, getScoreTailwindColor } from "@/lib/constants"; +import { Eye } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { BatchInspectionListItem } from "@/types/batch-inspection"; +import type { Grade } from "@/types/inspection"; -export default function HistoryPage() { - const [page, setPage] = useState(1); - const [searchQuery, setSearchQuery] = useState(""); +// ───────────────────────────────────────────────────── +// 배치 이력 테이블 (인라인) +// ───────────────────────────────────────────────────── - const { data, isLoading, isError, refetch } = useInspectionHistory( - page, - searchQuery || undefined - ); +/** 배치 검사 상태 한국어 라벨 */ +const BATCH_STATUS_LABELS: Record = { + inspecting: "검사 중", + completed: "완료", + error: "오류", +}; - const handleSearch = (query: string) => { - setSearchQuery(query); - setPage(1); // 검색 시 1페이지로 리셋 +interface BatchHistoryTableProps { + inspections: BatchInspectionListItem[] | undefined; + isLoading: boolean; +} + +function BatchHistoryTable({ inspections, isLoading }: BatchHistoryTableProps) { + const router = useRouter(); + + if (isLoading) { + return ; + } + + if (!inspections || inspections.length === 0) { + return ( + + ); + } + + const handleRowClick = (batchInspectionId: string) => { + router.push(`/batch-inspections/${batchInspectionId}`); }; - const handlePageChange = (newPage: number) => { - setPage(newPage); + return ( +
+ + + + 이름 + 상태 + + 종합 점수 + + + 등급 + + + URL 수 + + 검사일 + 액션 + + + + {inspections.map((item) => ( + handleRowClick(item.batch_inspection_id)} + > + + + {item.name} + + + {formatDateTime(item.created_at)} + + + + + {BATCH_STATUS_LABELS[item.status] || item.status} + + + + {item.overall_score !== null ? ( + + {item.overall_score} + + ) : ( + - + )} + + + {item.grade ? ( + + ) : ( + - + )} + + + {item.total_urls} + + + {formatDateTime(item.created_at)} + + + + + + ))} + +
+
+ ); +} + +// ───────────────────────────────────────────────────── +// 메인 이력 페이지 +// ───────────────────────────────────────────────────── + +export default function HistoryPage() { + const [activeTab, setActiveTab] = useState("single"); + + // 각 탭별 독립적 페이지/검색 state + const [singlePage, setSinglePage] = useState(1); + const [singleSearch, setSingleSearch] = useState(""); + + const [sitePage, setSitePage] = useState(1); + + const [batchPage, setBatchPage] = useState(1); + const [batchSearch, setBatchSearch] = useState(""); + + // 각 탭별 쿼리 + const singleQuery = useInspectionHistory( + singlePage, + singleSearch || undefined + ); + const siteQuery = useSiteInspectionHistory(sitePage); + const batchQuery = useBatchInspectionHistory(batchPage, batchSearch || undefined); + + const handleSingleSearch = (query: string) => { + setSingleSearch(query); + setSinglePage(1); + }; + + const handleBatchSearch = (query: string) => { + setBatchSearch(query); + setBatchPage(1); + }; + + const scrollToTop = () => { window.scrollTo({ top: 0, behavior: "smooth" }); }; @@ -30,35 +210,108 @@ export default function HistoryPage() {

검사 이력

- {/* 검색 바 */} -
- -
+ {/* 탭 */} + - {/* 테이블 또는 에러 */} - {isError ? ( - refetch()} - /> - ) : ( + {/* single 탭 */} + {activeTab === "single" && ( <> - - - {/* 페이지네이션 */} - {data && data.total_pages > 1 && ( - + +
+ + {singleQuery.isError ? ( + singleQuery.refetch()} + /> + ) : ( + <> + + {singleQuery.data && singleQuery.data.total_pages > 1 && ( + { + setSinglePage(p); + scrollToTop(); + }} + /> + )} + + )} + + )} + + {/* site 탭 */} + {activeTab === "site" && ( + <> + {siteQuery.isError ? ( + siteQuery.refetch()} + /> + ) : ( + <> + + {siteQuery.data && siteQuery.data.total_pages > 1 && ( + { + setSitePage(p); + scrollToTop(); + }} + /> + )} + + )} + + )} + + {/* batch 탭 */} + {activeTab === "batch" && ( + <> +
+ +
+ + {batchQuery.isError ? ( + batchQuery.refetch()} + /> + ) : ( + <> + + {batchQuery.data && batchQuery.data.total_pages > 1 && ( + { + setBatchPage(p); + scrollToTop(); + }} + /> + )} + )} )} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 156566b..b6756c3 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,10 +1,21 @@ "use client"; -import { UrlInputForm } from "@/components/inspection/UrlInputForm"; +import { useState } from "react"; +import { + InspectionTabs, + type InspectionTab, +} from "@/components/inspection/InspectionTabs"; +import { SinglePageForm } from "@/components/inspection/SinglePageForm"; +import { SiteCrawlForm } from "@/components/inspection/SiteCrawlForm"; +import { BatchUploadForm } from "@/components/inspection/BatchUploadForm"; import { RecentInspections } from "@/components/inspection/RecentInspections"; +import { RecentSiteInspections } from "@/components/inspection/RecentSiteInspections"; +import { RecentBatchInspections } from "@/components/inspection/RecentBatchInspections"; import { Search } from "lucide-react"; export default function HomePage() { + const [activeTab, setActiveTab] = useState("single"); + return (
{/* 히어로 섹션 */} @@ -22,11 +33,18 @@ export default function HomePage() {

- {/* URL 입력 폼 */} - + {/* 탭 */} + - {/* 최근 검사 이력 */} - + {/* 탭별 폼 */} + {activeTab === "single" && } + {activeTab === "site" && } + {activeTab === "batch" && } + + {/* 탭별 최근 이력 */} + {activeTab === "single" && } + {activeTab === "site" && } + {activeTab === "batch" && }
); } diff --git a/frontend/src/app/site-inspections/[id]/progress/page.tsx b/frontend/src/app/site-inspections/[id]/progress/page.tsx index a264837..f633637 100644 --- a/frontend/src/app/site-inspections/[id]/progress/page.tsx +++ b/frontend/src/app/site-inspections/[id]/progress/page.tsx @@ -25,7 +25,7 @@ export default function SiteInspectionProgressPage({ return (

- 사이트 전체 검사 + 사이트 크롤링 검사

diff --git a/frontend/src/components/batch-inspection/BatchProgress.tsx b/frontend/src/components/batch-inspection/BatchProgress.tsx new file mode 100644 index 0000000..218bbff --- /dev/null +++ b/frontend/src/components/batch-inspection/BatchProgress.tsx @@ -0,0 +1,218 @@ +"use client"; + +import { useEffect } from "react"; +import { useBatchInspectionStore } from "@/stores/useBatchInspectionStore"; +import { useBatchInspectionSSE } from "@/hooks/useBatchInspectionSSE"; +import { useBatchInspectionResult } from "@/lib/queries"; +import { Progress } from "@/components/ui/progress"; +import { Card, CardContent } from "@/components/ui/card"; +import { ErrorState } from "@/components/common/ErrorState"; +import { + Check, + X, + Circle, + Loader2, + FileText, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { getScoreTailwindColor } from "@/lib/constants"; +import type { BatchPage } from "@/types/batch-inspection"; + +interface BatchProgressProps { + batchInspectionId: string; +} + +/** + * 배치 검사 진행 상태 표시 컴포넌트. + * 크롤링 단계 없이 바로 검사 단계만 표시한다. + */ +export function BatchProgress({ batchInspectionId }: BatchProgressProps) { + const { + status, + name, + discoveredPages, + aggregateScores, + errorMessage, + initFromApi, + } = useBatchInspectionStore(); + + // API에서 현재 상태 조회 (리로드 시 스토어 복원용) + const { data: apiResult } = useBatchInspectionResult(batchInspectionId); + + useEffect(() => { + if (apiResult && status === "idle") { + initFromApi(apiResult); + } + }, [apiResult, status, initFromApi]); + + // SSE 연결 + useBatchInspectionSSE(batchInspectionId); + + const handleRetry = () => { + window.location.reload(); + }; + + // 전체 진행률 계산 + const completedPages = discoveredPages.filter( + (p) => p.status === "completed" + ).length; + const totalPages = discoveredPages.length; + const overallProgress = + totalPages > 0 ? Math.round((completedPages / totalPages) * 100) : 0; + + return ( +
+ {/* 배치 이름 표시 */} + {name && ( +
+ + {name} +
+ )} + + {/* 검사 단계 (크롤링 단계 없이 바로 검사) */} + {(status === "inspecting" || status === "completed") && ( + + )} + + {/* 에러 상태 */} + {status === "error" && ( +
+ +
+ )} + + {/* 초기 연결 중 상태 */} + {status === "idle" && ( +
+ +

+ 서버에 연결하는 중... +

+
+ )} +
+ ); +} + +/** 검사 단계 UI */ +function InspectionPhase({ + pages, + completedPages, + totalPages, + overallProgress, + aggregateScores, +}: { + pages: BatchPage[]; + completedPages: number; + totalPages: number; + overallProgress: number; + aggregateScores: { overall_score: number; grade: string } | null; +}) { + return ( +
+ {/* 전체 진행률 */} + + +
+

페이지 검사 진행

+ + {completedPages}/{totalPages} + +
+ +
+ + {overallProgress}% 완료 + + {aggregateScores && ( + + 현재 평균: {aggregateScores.overall_score}점{" "} + {aggregateScores.grade} + + )} +
+
+
+ + {/* 개별 페이지 목록 */} +
+ {pages.map((page) => ( + + ))} +
+
+ ); +} + +/** 개별 페이지 진행 항목 */ +function PageProgressItem({ page }: { page: BatchPage }) { + let displayPath: string; + try { + const parsed = new URL(page.url); + displayPath = parsed.pathname + parsed.search || "/"; + } catch { + displayPath = page.url; + } + + return ( +
+ {/* 상태 아이콘 */} + + + {/* URL 경로 */} + + {displayPath} + + + {/* 점수 (완료 시) */} + {page.status === "completed" && page.overall_score !== null && ( + + {page.overall_score}점 + + )} + + {/* 검사 중 표시 */} + {page.status === "inspecting" && ( + 검사 중 + )} +
+ ); +} + +/** 페이지 상태 아이콘 */ +function PageStatusIcon({ status }: { status: BatchPage["status"] }) { + switch (status) { + case "pending": + return ; + case "inspecting": + return ( + + ); + case "completed": + return ; + case "error": + return ; + default: + return ; + } +} diff --git a/frontend/src/components/batch-inspection/BatchUrlList.tsx b/frontend/src/components/batch-inspection/BatchUrlList.tsx new file mode 100644 index 0000000..e4928ab --- /dev/null +++ b/frontend/src/components/batch-inspection/BatchUrlList.tsx @@ -0,0 +1,180 @@ +"use client"; + +import { Globe, Check, X, Circle, Loader2 } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { getScoreTailwindColor } from "@/lib/constants"; +import type { BatchPage } from "@/types/batch-inspection"; +import type { AggregateScores } from "@/types/site-inspection"; + +interface BatchUrlListProps { + /** 배치 페이지 목록 (flat array) */ + pages: BatchPage[]; + /** 현재 선택된 페이지 URL (null = 전체 요약 보기) */ + selectedUrl: string | null; + /** 페이지 선택 핸들러 (null을 전달하면 전체 집계 보기) */ + onSelectPage: (url: string | null) => void; + /** 집계 점수 (전체 요약 노드에 표시) */ + aggregateScores: AggregateScores | null; +} + +/** + * 배치 검사 URL 목록 사이드바 컴포넌트. + * 트리 구조 없이 flat 리스트로 URL을 표시한다. + */ +export function BatchUrlList({ + pages, + selectedUrl, + onSelectPage, + aggregateScores, +}: BatchUrlListProps) { + const isAggregateSelected = selectedUrl === null; + + return ( +
+ {/* 헤더 */} +
+

+ URL 목록 +

+
+ + {/* 리스트 본문 */} +
+ {/* 전체 요약 항목 */} +
onSelectPage(null)} + role="option" + aria-selected={isAggregateSelected} + > + + 전체 요약 + {aggregateScores && ( + + {aggregateScores.overall_score}점 {aggregateScores.grade} + + )} +
+ + {/* URL 목록 */} + {pages.map((page) => ( + onSelectPage(page.url)} + /> + ))} + + {/* 빈 상태 */} + {pages.length === 0 && ( +
+ 등록된 URL이 없습니다 +
+ )} +
+ + {/* 하단 요약 */} + {pages.length > 0 && ( +
+ 총 {pages.length}개 URL + {aggregateScores && ( + + {" "}/ 검사 완료 {aggregateScores.pages_inspected}/ + {aggregateScores.pages_total} + + )} +
+ )} +
+ ); +} + +/** 개별 URL 목록 항목 */ +function UrlListItem({ + page, + isSelected, + onSelect, +}: { + page: BatchPage; + isSelected: boolean; + onSelect: () => void; +}) { + let displayPath: string; + try { + const parsed = new URL(page.url); + displayPath = + parsed.hostname + (parsed.pathname !== "/" ? parsed.pathname : "") + parsed.search; + } catch { + displayPath = page.url; + } + + return ( +
+ {/* 상태 아이콘 */} + + + {/* URL 경로 */} + + {displayPath} + + + {/* 점수 (완료 시) */} + {page.status === "completed" && page.overall_score !== null && ( + + {page.overall_score}점 + + )} + + {/* 검사 중 표시 */} + {page.status === "inspecting" && ( + + )} +
+ ); +} + +/** 페이지 상태 아이콘 */ +function PageStatusIcon({ status }: { status: BatchPage["status"] }) { + switch (status) { + case "pending": + return ; + case "inspecting": + return ( + + ); + case "completed": + return ; + case "error": + return ; + default: + return ; + } +} diff --git a/frontend/src/components/history/SiteHistoryTable.tsx b/frontend/src/components/history/SiteHistoryTable.tsx new file mode 100644 index 0000000..788dff9 --- /dev/null +++ b/frontend/src/components/history/SiteHistoryTable.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Button } from "@/components/ui/button"; +import { ScoreBadge } from "@/components/common/ScoreBadge"; +import { LoadingSpinner } from "@/components/common/LoadingSpinner"; +import { EmptyState } from "@/components/common/EmptyState"; +import { formatDateTime, getScoreTailwindColor } from "@/lib/constants"; +import { Eye } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { SiteInspectionListItem } from "@/types/site-inspection"; +import type { Grade } from "@/types/inspection"; + +/** 사이트 검사 상태 한국어 라벨 */ +const STATUS_LABELS: Record = { + crawling: "크롤링 중", + inspecting: "검사 중", + completed: "완료", + error: "오류", +}; + +interface SiteHistoryTableProps { + inspections: SiteInspectionListItem[] | undefined; + isLoading: boolean; +} + +/** 사이트 크롤링 이력 테이블 */ +export function SiteHistoryTable({ + inspections, + isLoading, +}: SiteHistoryTableProps) { + const router = useRouter(); + + if (isLoading) { + return ; + } + + if (!inspections || inspections.length === 0) { + return ( + + ); + } + + const handleRowClick = (siteInspectionId: string) => { + router.push(`/site-inspections/${siteInspectionId}`); + }; + + return ( +
+ + + + 도메인 + 상태 + + 종합 점수 + + + 등급 + + + 페이지 수 + + 검사일 + 액션 + + + + {inspections.map((item) => ( + handleRowClick(item.site_inspection_id)} + > + + + {item.domain || item.root_url} + + + {formatDateTime(item.created_at)} + + + + + {STATUS_LABELS[item.status] || item.status} + + + + {item.overall_score !== null ? ( + + {item.overall_score} + + ) : ( + - + )} + + + {item.grade ? ( + + ) : ( + - + )} + + + {item.pages_total} + + + {formatDateTime(item.created_at)} + + + + + + ))} + +
+
+ ); +} diff --git a/frontend/src/components/inspection/BatchUploadForm.tsx b/frontend/src/components/inspection/BatchUploadForm.tsx new file mode 100644 index 0000000..720a6c7 --- /dev/null +++ b/frontend/src/components/inspection/BatchUploadForm.tsx @@ -0,0 +1,315 @@ +"use client"; + +import { useState, useRef, useCallback, type FormEvent, type DragEvent } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Card, CardContent } from "@/components/ui/card"; +import { Upload, Loader2, FileText, X } from "lucide-react"; +import { api, ApiError } from "@/lib/api"; +import { isValidUrl } from "@/lib/constants"; +import { useBatchInspectionStore } from "@/stores/useBatchInspectionStore"; +import { cn } from "@/lib/utils"; + +/** 동시 검사 수 옵션 */ +const CONCURRENCY_OPTIONS = [1, 2, 4, 8] as const; + +/** 파일에서 URL 목록 파싱 */ +function parseUrlsFromText(text: string): string[] { + return text + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith("#")) + .filter((line) => isValidUrl(line)); +} + +/** 목록 업로드 폼 */ +export function BatchUploadForm() { + const [name, setName] = useState(""); + const [file, setFile] = useState(null); + const [parsedUrls, setParsedUrls] = useState([]); + const [concurrency, setConcurrency] = useState(4); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isDragOver, setIsDragOver] = useState(false); + const fileInputRef = useRef(null); + const router = useRouter(); + const { setBatchInspection } = useBatchInspectionStore(); + + /** 파일 처리 공통 로직 */ + const processFile = useCallback((selectedFile: File) => { + setError(null); + + if (!selectedFile.name.endsWith(".txt")) { + setError(".txt 파일만 업로드할 수 있습니다"); + return; + } + + const reader = new FileReader(); + reader.onload = (e) => { + const text = e.target?.result as string; + const urls = parseUrlsFromText(text); + + if (urls.length === 0) { + setError("파일에서 유효한 URL을 찾을 수 없습니다"); + setFile(null); + setParsedUrls([]); + return; + } + + setFile(selectedFile); + setParsedUrls(urls); + }; + reader.onerror = () => { + setError("파일 읽기 중 오류가 발생했습니다"); + }; + reader.readAsText(selectedFile); + }, []); + + /** 파일 선택 핸들러 */ + const handleFileChange = (e: React.ChangeEvent) => { + const selectedFile = e.target.files?.[0]; + if (selectedFile) { + processFile(selectedFile); + } + }; + + /** 드래그 앤 드롭 핸들러 */ + const handleDragOver = (e: DragEvent) => { + e.preventDefault(); + setIsDragOver(true); + }; + + const handleDragLeave = (e: DragEvent) => { + e.preventDefault(); + setIsDragOver(false); + }; + + const handleDrop = (e: DragEvent) => { + e.preventDefault(); + setIsDragOver(false); + const droppedFile = e.dataTransfer.files[0]; + if (droppedFile) { + processFile(droppedFile); + } + }; + + /** 파일 제거 */ + const handleRemoveFile = () => { + setFile(null); + setParsedUrls([]); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }; + + /** 폼 제출 */ + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(null); + + if (!name.trim()) { + setError("배치 이름을 입력해주세요"); + return; + } + + if (!file) { + setError("URL 목록 파일을 업로드해주세요"); + return; + } + + if (parsedUrls.length === 0) { + setError("파일에서 유효한 URL을 찾을 수 없습니다"); + return; + } + + setIsLoading(true); + + try { + const response = await api.startBatchInspection( + file, + name.trim(), + concurrency + ); + setBatchInspection(response.batch_inspection_id, name.trim()); + router.push(`/batch-inspections/${response.batch_inspection_id}/progress`); + } catch (err) { + if (err instanceof ApiError) { + setError(err.detail); + } else { + setError("배치 검사 시작 중 오류가 발생했습니다. 다시 시도해주세요."); + } + } finally { + setIsLoading(false); + } + }; + + return ( + + +
+ {/* 배치 이름 입력 */} +
+ + { + setName(e.target.value); + if (error) setError(null); + }} + placeholder="예: 2월 정기 검사" + className="h-10" + disabled={isLoading} + aria-label="배치 이름 입력" + /> +
+ + {/* 파일 업로드 영역 */} +
+ + + + {!file ? ( +
fileInputRef.current?.click()} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + fileInputRef.current?.click(); + } + }} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} + className={cn( + "flex flex-col items-center justify-center gap-2 p-8 rounded-lg border-2 border-dashed cursor-pointer transition-colors", + isDragOver + ? "border-primary bg-primary/5" + : "border-muted-foreground/25 hover:border-muted-foreground/50" + )} + aria-label="URL 목록 파일 업로드" + > + +

+ 클릭하거나 파일을 드래그하여 업로드 +

+

+ 한 줄에 하나의 URL, # 주석 지원 +

+
+ ) : ( +
+ {/* 파일 정보 */} +
+
+ + {file.name} +
+ +
+ + {/* URL 미리보기 */} +
+

+ {parsedUrls.length}개 URL 감지됨 +

+
    + {parsedUrls.slice(0, 5).map((parsedUrl, index) => ( +
  • + {parsedUrl} +
  • + ))} + {parsedUrls.length > 5 && ( +
  • + ... 외 {parsedUrls.length - 5}개 +
  • + )} +
+
+
+ )} +
+ + {/* 동시 검사 수 */} +
+ +
+ {CONCURRENCY_OPTIONS.map((option) => ( + + ))} +
+
+ + {/* 검사 시작 버튼 */} + +
+ + {error && ( +

+ {error} +

+ )} +
+
+ ); +} diff --git a/frontend/src/components/inspection/InspectionTabs.tsx b/frontend/src/components/inspection/InspectionTabs.tsx new file mode 100644 index 0000000..db9a5a9 --- /dev/null +++ b/frontend/src/components/inspection/InspectionTabs.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { Search, Globe, Upload } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export type InspectionTab = "single" | "site" | "batch"; + +const TABS: { key: InspectionTab; label: string; icon: typeof Search }[] = [ + { key: "single", label: "한 페이지 검사", icon: Search }, + { key: "site", label: "사이트 크롤링", icon: Globe }, + { key: "batch", label: "목록 업로드", icon: Upload }, +]; + +interface InspectionTabsProps { + activeTab: InspectionTab; + onTabChange: (tab: InspectionTab) => void; +} + +export function InspectionTabs({ activeTab, onTabChange }: InspectionTabsProps) { + return ( +
+ {TABS.map(({ key, label, icon: Icon }) => ( + + ))} +
+ ); +} diff --git a/frontend/src/components/inspection/RecentBatchInspections.tsx b/frontend/src/components/inspection/RecentBatchInspections.tsx new file mode 100644 index 0000000..690c060 --- /dev/null +++ b/frontend/src/components/inspection/RecentBatchInspections.tsx @@ -0,0 +1,93 @@ +"use client"; + +import Link from "next/link"; +import { Card, CardContent } from "@/components/ui/card"; +import { ScoreBadge } from "@/components/common/ScoreBadge"; +import { LoadingSpinner } from "@/components/common/LoadingSpinner"; +import { EmptyState } from "@/components/common/EmptyState"; +import { useRecentBatchInspections } from "@/lib/queries"; +import { formatDate, getScoreTailwindColor } from "@/lib/constants"; +import { Upload } from "lucide-react"; +import type { Grade } from "@/types/inspection"; +import { cn } from "@/lib/utils"; + +/** 최근 배치 검사 이력 (메인 페이지용) */ +export function RecentBatchInspections() { + const { data, isLoading, isError } = useRecentBatchInspections(); + + if (isLoading) { + return ; + } + + if (isError || !data) { + return null; + } + + if (data.items.length === 0) { + return ( + + ); + } + + return ( +
+

최근 배치 검사 이력

+
+ {data.items.map((item) => ( + + + +
+
+ + + {item.name} + +
+
+

+ {formatDate(item.created_at)} +

+ + {item.total_urls}개 URL + +
+
+
+ {item.overall_score !== null && ( + <> + + {item.overall_score}점 + + {item.grade && } + + )} + {item.overall_score === null && ( + + {item.status === "inspecting" + ? "검사 중" + : item.status === "error" + ? "오류" + : "대기 중"} + + )} +
+
+
+ + ))} +
+
+ ); +} diff --git a/frontend/src/components/inspection/RecentSiteInspections.tsx b/frontend/src/components/inspection/RecentSiteInspections.tsx new file mode 100644 index 0000000..ac4dd98 --- /dev/null +++ b/frontend/src/components/inspection/RecentSiteInspections.tsx @@ -0,0 +1,95 @@ +"use client"; + +import Link from "next/link"; +import { Card, CardContent } from "@/components/ui/card"; +import { ScoreBadge } from "@/components/common/ScoreBadge"; +import { LoadingSpinner } from "@/components/common/LoadingSpinner"; +import { EmptyState } from "@/components/common/EmptyState"; +import { useRecentSiteInspections } from "@/lib/queries"; +import { formatDate, getScoreTailwindColor } from "@/lib/constants"; +import { Globe } from "lucide-react"; +import type { Grade } from "@/types/inspection"; +import { cn } from "@/lib/utils"; + +/** 최근 사이트 크롤링 이력 (메인 페이지용) */ +export function RecentSiteInspections() { + const { data, isLoading, isError } = useRecentSiteInspections(); + + if (isLoading) { + return ; + } + + if (isError || !data) { + return null; + } + + if (data.items.length === 0) { + return ( + + ); + } + + return ( +
+

최근 사이트 크롤링 이력

+
+ {data.items.map((item) => ( + + + +
+
+ + + {item.domain || item.root_url} + +
+
+

+ {formatDate(item.created_at)} +

+ + {item.pages_total}페이지 + +
+
+
+ {item.overall_score !== null && ( + <> + + {item.overall_score}점 + + {item.grade && } + + )} + {item.overall_score === null && ( + + {item.status === "crawling" + ? "크롤링 중" + : item.status === "inspecting" + ? "검사 중" + : item.status === "error" + ? "오류" + : "대기 중"} + + )} +
+
+
+ + ))} +
+
+ ); +} diff --git a/frontend/src/components/inspection/SinglePageForm.tsx b/frontend/src/components/inspection/SinglePageForm.tsx new file mode 100644 index 0000000..edf5624 --- /dev/null +++ b/frontend/src/components/inspection/SinglePageForm.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Card, CardContent } from "@/components/ui/card"; +import { Search, Loader2 } from "lucide-react"; +import { api, ApiError } from "@/lib/api"; +import { isValidUrl } from "@/lib/constants"; +import { useInspectionStore } from "@/stores/useInspectionStore"; + +/** 한 페이지 검사 폼 */ +export function SinglePageForm() { + const [url, setUrl] = useState(""); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const router = useRouter(); + const { setInspection } = useInspectionStore(); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(null); + + const trimmedUrl = url.trim(); + if (!trimmedUrl) { + setError("URL을 입력해주세요"); + return; + } + if (!isValidUrl(trimmedUrl)) { + setError("유효한 URL을 입력해주세요 (http:// 또는 https://로 시작)"); + return; + } + + setIsLoading(true); + + try { + const response = await api.startInspection(trimmedUrl); + setInspection(response.inspection_id, trimmedUrl); + router.push(`/inspections/${response.inspection_id}/progress`); + } catch (err) { + if (err instanceof ApiError) { + setError(err.detail); + } else { + setError("검사 시작 중 오류가 발생했습니다. 다시 시도해주세요."); + } + } finally { + setIsLoading(false); + } + }; + + return ( + + +
+
+
+ + { + setUrl(e.target.value); + if (error) setError(null); + }} + placeholder="https://example.com" + className="pl-10 h-12 text-base" + disabled={isLoading} + aria-label="검사할 URL 입력" + aria-invalid={!!error} + aria-describedby={error ? "single-url-error" : undefined} + /> +
+ +
+
+ + {error && ( + + )} +
+
+ ); +} diff --git a/frontend/src/components/inspection/SiteCrawlForm.tsx b/frontend/src/components/inspection/SiteCrawlForm.tsx new file mode 100644 index 0000000..35ab0b9 --- /dev/null +++ b/frontend/src/components/inspection/SiteCrawlForm.tsx @@ -0,0 +1,206 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Card, CardContent } from "@/components/ui/card"; +import { Globe, Loader2 } from "lucide-react"; +import { api, ApiError } from "@/lib/api"; +import { isValidUrl } from "@/lib/constants"; +import { useSiteInspectionStore } from "@/stores/useSiteInspectionStore"; +import { cn } from "@/lib/utils"; + +/** 최대 페이지 수 옵션 (0 = 무제한) */ +const MAX_PAGES_OPTIONS = [10, 20, 50, 0] as const; + +/** 크롤링 깊이 옵션 */ +const MAX_DEPTH_OPTIONS = [1, 2, 3] as const; + +/** 동시 검사 수 옵션 */ +const CONCURRENCY_OPTIONS = [1, 2, 4, 8] as const; + +/** 사이트 크롤링 폼 */ +export function SiteCrawlForm() { + const [url, setUrl] = useState(""); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [maxPages, setMaxPages] = useState(20); + const [maxDepth, setMaxDepth] = useState(2); + const [concurrency, setConcurrency] = useState(4); + const router = useRouter(); + const { setSiteInspection } = useSiteInspectionStore(); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(null); + + const trimmedUrl = url.trim(); + if (!trimmedUrl) { + setError("URL을 입력해주세요"); + return; + } + if (!isValidUrl(trimmedUrl)) { + setError("유효한 URL을 입력해주세요 (http:// 또는 https://로 시작)"); + return; + } + + setIsLoading(true); + + try { + const response = await api.startSiteInspection( + trimmedUrl, + maxPages, + maxDepth, + concurrency + ); + setSiteInspection(response.site_inspection_id, trimmedUrl); + router.push( + `/site-inspections/${response.site_inspection_id}/progress` + ); + } catch (err) { + if (err instanceof ApiError) { + setError(err.detail); + } else { + setError("사이트 크롤링 시작 중 오류가 발생했습니다. 다시 시도해주세요."); + } + } finally { + setIsLoading(false); + } + }; + + return ( + + +
+ {/* URL 입력 필드 */} +
+ + { + setUrl(e.target.value); + if (error) setError(null); + }} + placeholder="https://example.com" + className="pl-10 h-12 text-base" + disabled={isLoading} + aria-label="크롤링할 사이트 URL 입력" + aria-invalid={!!error} + aria-describedby={error ? "site-url-error" : undefined} + /> +
+ + {/* 옵션 영역 */} +
+ {/* 최대 페이지 수 */} +
+ +
+ {MAX_PAGES_OPTIONS.map((option) => ( + + ))} +
+
+ + {/* 크롤링 깊이 */} +
+ +
+ {MAX_DEPTH_OPTIONS.map((option) => ( + + ))} +
+
+ + {/* 동시 검사 수 */} +
+ +
+ {CONCURRENCY_OPTIONS.map((option) => ( + + ))} +
+
+
+ + {/* 사이트 크롤링 시작 버튼 */} + +
+ + {error && ( + + )} +
+
+ ); +} diff --git a/frontend/src/components/site-inspection/AggregateScorePanel.tsx b/frontend/src/components/site-inspection/AggregateScorePanel.tsx index c511110..aaa4da9 100644 --- a/frontend/src/components/site-inspection/AggregateScorePanel.tsx +++ b/frontend/src/components/site-inspection/AggregateScorePanel.tsx @@ -22,7 +22,7 @@ interface AggregateCategoryItem { score: number; } -/** 사이트 전체 집계 점수 패널 */ +/** 사이트 크롤링 집계 점수 패널 */ export function AggregateScorePanel({ aggregateScores, rootUrl, @@ -54,7 +54,7 @@ export function AggregateScorePanel({
{/* 상단 URL 및 검사 요약 */}
-

사이트 전체 검사 결과

+

사이트 크롤링 검사 결과

- 사이트 전체 이슈 + 사이트 크롤링 이슈 총 {aggregateScores.total_issues}건 diff --git a/frontend/src/components/site-inspection/PageTree.tsx b/frontend/src/components/site-inspection/PageTree.tsx index 8c0ae4b..8fef722 100644 --- a/frontend/src/components/site-inspection/PageTree.tsx +++ b/frontend/src/components/site-inspection/PageTree.tsx @@ -71,7 +71,7 @@ export function PageTree({ {/* 트리 본문 */}
- {/* 사이트 전체 (집계) 노드 */} + {/* 사이트 크롤링 (집계) 노드 */}
- 사이트 전체 + 사이트 크롤링 {aggregateScores && ( (null); + + useEffect(() => { + if (!batchInspectionId) return; + + const streamUrl = api.getBatchStreamUrl(batchInspectionId); + const eventSource = new EventSource(streamUrl); + eventSourceRef.current = eventSource; + + /** 개별 페이지 검사 시작 이벤트 */ + eventSource.addEventListener("page_start", (e: MessageEvent) => { + try { + const data: SSEBatchPageStart = JSON.parse(e.data); + updatePageStatus(data.page_url, "inspecting"); + } catch { + // JSON 파싱 실패 무시 + } + }); + + /** 개별 페이지 검사 완료 이벤트 */ + eventSource.addEventListener("page_complete", (e: MessageEvent) => { + try { + const data: SSEBatchPageComplete = JSON.parse(e.data); + setPageComplete(data); + } catch { + // JSON 파싱 실패 무시 + } + }); + + /** 집계 점수 업데이트 이벤트 */ + eventSource.addEventListener("aggregate_update", (e: MessageEvent) => { + try { + const data: SSEBatchAggregateUpdate = JSON.parse(e.data); + updateAggregateScores(data); + } catch { + // JSON 파싱 실패 무시 + } + }); + + /** 배치 검사 완료 이벤트 */ + eventSource.addEventListener("complete", (e: MessageEvent) => { + try { + const data: SSEBatchComplete = JSON.parse(e.data); + setCompleted(data.aggregate_scores); + eventSource.close(); + // stale 캐시 제거 → 결과 페이지에서 fresh 데이터 로드 + queryClient.removeQueries({ + queryKey: ["batchInspection", batchInspectionId], + }); + router.push(`/batch-inspections/${batchInspectionId}`); + } catch { + // JSON 파싱 실패 무시 + } + }); + + /** 에러 이벤트 */ + eventSource.addEventListener("error", (e: Event) => { + if (e instanceof MessageEvent) { + try { + const data = JSON.parse(e.data); + setError(data.message || "배치 검사 중 오류가 발생했습니다"); + } catch { + setError("배치 검사 중 오류가 발생했습니다"); + } + } + // 네트워크 에러인 경우 + if (eventSource.readyState === EventSource.CLOSED) { + setError("서버와의 연결이 끊어졌습니다"); + } + }); + + // SSE 연결 타임아웃 (10분 - 배치 검사는 시간이 더 소요될 수 있음) + const timeout = setTimeout(() => { + eventSource.close(); + setError("배치 검사 시간이 초과되었습니다 (10분)"); + }, 600000); + + return () => { + clearTimeout(timeout); + eventSource.close(); + eventSourceRef.current = null; + }; + }, [ + batchInspectionId, + updatePageStatus, + setPageComplete, + updateAggregateScores, + setCompleted, + setError, + router, + queryClient, + ]); +} diff --git a/frontend/src/hooks/useSiteInspectionSSE.ts b/frontend/src/hooks/useSiteInspectionSSE.ts index 47c2e61..46e5663 100644 --- a/frontend/src/hooks/useSiteInspectionSSE.ts +++ b/frontend/src/hooks/useSiteInspectionSSE.ts @@ -15,7 +15,7 @@ import type { } from "@/types/site-inspection"; /** - * SSE를 통해 사이트 전체 검사 진행 상태를 수신하는 커스텀 훅. + * SSE를 통해 사이트 크롤링 검사 진행 상태를 수신하는 커스텀 훅. * EventSource로 크롤링 + 검사 진행 상태를 실시간 수신하고 * Zustand 스토어를 업데이트한다. */ @@ -122,7 +122,7 @@ export function useSiteInspectionSSE(siteInspectionId: string | null) { } }); - // SSE 연결 타임아웃 (10분 - 사이트 전체 검사는 시간이 더 소요됨) + // SSE 연결 타임아웃 (10분 - 사이트 크롤링 검사는 시간이 더 소요됨) const timeout = setTimeout(() => { eventSource.close(); setError("사이트 검사 시간이 초과되었습니다 (10분)"); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 0313070..fe20058 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -10,8 +10,14 @@ import type { import type { StartSiteInspectionResponse, SiteInspectionResult, + SiteInspectionPaginatedResponse, InspectPageResponse, } from "@/types/site-inspection"; +import type { + StartBatchInspectionResponse, + BatchInspectionResult, + BatchInspectionPaginatedResponse, +} from "@/types/batch-inspection"; const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? ""; @@ -150,10 +156,10 @@ class ApiClient { } // ───────────────────────────────────────────────────── - // 사이트 전체 검사 API + // 사이트 크롤링 검사 API // ───────────────────────────────────────────────────── - /** 사이트 전체 검사 시작 */ + /** 사이트 크롤링 검사 시작 */ async startSiteInspection( url: string, maxPages?: number, @@ -194,6 +200,76 @@ class ApiClient { getSiteStreamUrl(siteInspectionId: string): string { return `${this.baseUrl}/api/site-inspections/${siteInspectionId}/stream`; } + + /** 사이트 검사 이력 목록 */ + async getSiteInspections(params: { + page?: number; + limit?: number; + }): Promise { + const qs = new URLSearchParams(); + qs.set("page", String(params.page || 1)); + qs.set("limit", String(params.limit || 20)); + return this.request(`/api/site-inspections?${qs}`); + } + + // ───────────────────────────────────────────────────── + // 배치 검사 API + // ───────────────────────────────────────────────────── + + /** 배치 검사 시작 (multipart/form-data) */ + async startBatchInspection( + file: File, + name: string, + concurrency?: number + ): Promise { + const formData = new FormData(); + formData.append("file", file); + formData.append("name", name); + if (concurrency !== undefined) { + formData.append("concurrency", String(concurrency)); + } + + // NOTE: Content-Type을 직접 설정하지 않아야 boundary가 자동 설정됨 + const response = await fetch(`${this.baseUrl}/api/batch-inspections`, { + method: "POST", + body: formData, + }); + + if (!response.ok) { + let detail = "요청 처리 중 오류가 발생했습니다"; + try { + const error = await response.json(); + detail = error.detail || detail; + } catch { + // JSON 파싱 실패 시 기본 메시지 사용 + } + throw new ApiError(response.status, detail); + } + return response.json(); + } + + /** 배치 검사 결과 조회 */ + async getBatchInspection(id: string): Promise { + return this.request(`/api/batch-inspections/${id}`); + } + + /** 배치 검사 이력 목록 */ + async getBatchInspections(params: { + page?: number; + limit?: number; + name?: string; + }): Promise { + const qs = new URLSearchParams(); + qs.set("page", String(params.page || 1)); + qs.set("limit", String(params.limit || 20)); + if (params.name) qs.set("name", params.name); + return this.request(`/api/batch-inspections?${qs}`); + } + + /** 배치 검사 SSE 스트림 URL 반환 */ + getBatchStreamUrl(batchInspectionId: string): string { + return `${this.baseUrl}/api/batch-inspections/${batchInspectionId}/stream`; + } } export const api = new ApiClient(API_BASE_URL); diff --git a/frontend/src/lib/queries.ts b/frontend/src/lib/queries.ts index 58aeb43..1e1bfaf 100644 --- a/frontend/src/lib/queries.ts +++ b/frontend/src/lib/queries.ts @@ -56,7 +56,7 @@ export function useRecentInspections() { }); } -/** 사이트 전체 검사 결과 조회 */ +/** 사이트 크롤링 검사 결과 조회 */ export function useSiteInspectionResult(siteInspectionId: string | null) { return useQuery({ queryKey: ["siteInspection", siteInspectionId], @@ -65,3 +65,49 @@ export function useSiteInspectionResult(siteInspectionId: string | null) { staleTime: 5 * 60 * 1000, }); } + +/** 사이트 검사 이력 조회 (페이지네이션) */ +export function useSiteInspectionHistory(page: number) { + return useQuery({ + queryKey: ["site-inspection-history", page], + queryFn: () => api.getSiteInspections({ page, limit: 20 }), + placeholderData: keepPreviousData, + }); +} + +/** 최근 사이트 검사 이력 (메인 페이지용, 5건) */ +export function useRecentSiteInspections() { + return useQuery({ + queryKey: ["recent-site-inspections"], + queryFn: () => api.getSiteInspections({ page: 1, limit: 5 }), + staleTime: 60 * 1000, + }); +} + +/** 배치 검사 결과 조회 */ +export function useBatchInspectionResult(batchInspectionId: string | null) { + return useQuery({ + queryKey: ["batchInspection", batchInspectionId], + queryFn: () => api.getBatchInspection(batchInspectionId!), + enabled: !!batchInspectionId, + staleTime: 5 * 60 * 1000, + }); +} + +/** 배치 검사 이력 조회 (페이지네이션) */ +export function useBatchInspectionHistory(page: number, name?: string) { + return useQuery({ + queryKey: ["batch-inspection-history", page, name], + queryFn: () => api.getBatchInspections({ page, limit: 20, name }), + placeholderData: keepPreviousData, + }); +} + +/** 최근 배치 검사 이력 (메인 페이지용, 5건) */ +export function useRecentBatchInspections() { + return useQuery({ + queryKey: ["recent-batch-inspections"], + queryFn: () => api.getBatchInspections({ page: 1, limit: 5 }), + staleTime: 60 * 1000, + }); +} diff --git a/frontend/src/stores/useBatchInspectionStore.ts b/frontend/src/stores/useBatchInspectionStore.ts new file mode 100644 index 0000000..5b027ac --- /dev/null +++ b/frontend/src/stores/useBatchInspectionStore.ts @@ -0,0 +1,143 @@ +import { create } from "zustand"; +import type { AggregateScores } from "@/types/site-inspection"; +import type { + BatchPage, + BatchInspectionPhase, + BatchInspectionResult, + SSEBatchPageComplete, + SSEBatchAggregateUpdate, +} from "@/types/batch-inspection"; + +interface BatchInspectionState { + batchInspectionId: string | null; + name: string | null; + status: BatchInspectionPhase; + discoveredPages: BatchPage[]; + aggregateScores: AggregateScores | null; + errorMessage: string | null; + + // Actions + setBatchInspection: (id: string, name: string) => void; + initFromApi: (data: BatchInspectionResult) => void; + updatePageStatus: ( + pageUrl: string, + status: BatchPage["status"], + extra?: { + inspection_id?: string; + overall_score?: number; + grade?: string; + } + ) => void; + setPageComplete: (data: SSEBatchPageComplete) => void; + updateAggregateScores: (data: SSEBatchAggregateUpdate) => void; + setCompleted: (aggregateScores: AggregateScores) => void; + setError: (message: string) => void; + reset: () => void; +} + +const initialState = { + batchInspectionId: null, + name: null, + status: "idle" as BatchInspectionPhase, + discoveredPages: [] as BatchPage[], + aggregateScores: null, + errorMessage: null, +}; + +export const useBatchInspectionStore = create( + (set) => ({ + ...initialState, + + setBatchInspection: (id, name) => + set({ + ...initialState, + batchInspectionId: id, + name, + status: "inspecting", + }), + + initFromApi: (data) => + set((state) => { + // Only init if store is idle (prevents overwriting live SSE data) + if (state.status !== "idle") return state; + return { + batchInspectionId: data.batch_inspection_id, + name: data.name, + status: data.status as BatchInspectionPhase, + discoveredPages: data.discovered_pages, + aggregateScores: data.aggregate_scores, + }; + }), + + updatePageStatus: (pageUrl, status, extra) => + set((state) => ({ + discoveredPages: state.discoveredPages.map((page) => + page.url === pageUrl + ? { + ...page, + status, + ...(extra?.inspection_id && { + inspection_id: extra.inspection_id, + }), + ...(extra?.overall_score !== undefined && { + overall_score: extra.overall_score, + }), + ...(extra?.grade && { grade: extra.grade }), + } + : page + ), + })), + + setPageComplete: (data) => + set((state) => ({ + discoveredPages: state.discoveredPages.map((page) => + page.url === data.page_url + ? { + ...page, + status: "completed" as const, + inspection_id: data.inspection_id, + overall_score: data.overall_score, + grade: data.grade, + } + : page + ), + })), + + updateAggregateScores: (data) => + set((state) => ({ + aggregateScores: state.aggregateScores + ? { + ...state.aggregateScores, + pages_inspected: data.pages_inspected, + pages_total: data.pages_total, + overall_score: data.overall_score, + grade: data.grade, + } + : { + overall_score: data.overall_score, + grade: data.grade, + html_css: 0, + accessibility: 0, + seo: 0, + performance_security: 0, + total_issues: 0, + pages_inspected: data.pages_inspected, + pages_total: data.pages_total, + }, + })), + + setCompleted: (aggregateScores) => + set({ + status: "completed", + aggregateScores, + }), + + setError: (message) => + set({ + status: "error", + errorMessage: message, + }), + + reset: () => set({ ...initialState }), + }) +); diff --git a/frontend/src/types/batch-inspection.ts b/frontend/src/types/batch-inspection.ts new file mode 100644 index 0000000..1c6ab6c --- /dev/null +++ b/frontend/src/types/batch-inspection.ts @@ -0,0 +1,117 @@ +import type { AggregateScores } from "@/types/site-inspection"; + +// ─────────────────────────────────────────────────────── +// 배치 검사 도메인 타입 +// ─────────────────────────────────────────────────────── + +/** 배치 검사 상태 */ +export type BatchInspectionStatus = "inspecting" | "completed" | "error"; + +/** 배치 검사 진행 상태 (Zustand Store) */ +export type BatchInspectionPhase = + | "idle" + | "inspecting" + | "completed" + | "error"; + +/** 배치 페이지 (site-inspection의 DiscoveredPage와 동일) */ +export interface BatchPage { + url: string; + depth: number; + parent_url: string | null; + inspection_id: string | null; + status: "pending" | "inspecting" | "completed" | "error"; + title: string | null; + overall_score: number | null; + grade: string | null; +} + +/** 배치 검사 설정 */ +export interface BatchInspectionConfig { + concurrency: number; +} + +/** GET /api/batch-inspections/{id} 응답 - 배치 검사 결과 */ +export interface BatchInspectionResult { + batch_inspection_id: string; + name: string; + status: BatchInspectionStatus; + created_at: string; + completed_at: string | null; + config: BatchInspectionConfig; + source_urls: string[]; + discovered_pages: BatchPage[]; + aggregate_scores: AggregateScores | null; +} + +/** POST /api/batch-inspections 응답 */ +export interface StartBatchInspectionResponse { + batch_inspection_id: string; + status: string; + name: string; + total_urls: number; + stream_url: string; +} + +/** 이력 목록 항목 */ +export interface BatchInspectionListItem { + batch_inspection_id: string; + name: string; + status: BatchInspectionStatus; + created_at: string; + total_urls: number; + pages_inspected: number; + overall_score: number | null; + grade: string | null; +} + +/** GET /api/batch-inspections 응답 - 페이지네이션 */ +export interface BatchInspectionPaginatedResponse { + items: BatchInspectionListItem[]; + total: number; + page: number; + limit: number; + total_pages: number; +} + +// ─────────────────────────────────────────────────────── +// SSE 이벤트 타입 +// ─────────────────────────────────────────────────────── + +/** SSE batch_page_start 이벤트 */ +export interface SSEBatchPageStart { + batch_inspection_id: string; + page_url: string; + page_index: number; +} + +/** SSE batch_page_complete 이벤트 */ +export interface SSEBatchPageComplete { + batch_inspection_id: string; + page_url: string; + inspection_id: string; + overall_score: number; + grade: string; +} + +/** SSE batch_aggregate_update 이벤트 */ +export interface SSEBatchAggregateUpdate { + batch_inspection_id: string; + pages_inspected: number; + pages_total: number; + overall_score: number; + grade: string; +} + +/** SSE batch_complete 이벤트 (배치 검사 완료) */ +export interface SSEBatchComplete { + batch_inspection_id: string; + status: string; + aggregate_scores: AggregateScores; +} + +/** SSE batch_error 이벤트 */ +export interface SSEBatchError { + batch_inspection_id: string; + message: string; +} diff --git a/frontend/src/types/site-inspection.ts b/frontend/src/types/site-inspection.ts index 6e2d9a9..e087872 100644 --- a/frontend/src/types/site-inspection.ts +++ b/frontend/src/types/site-inspection.ts @@ -30,7 +30,7 @@ export interface DiscoveredPage { grade: string | null; } -/** 사이트 전체 집계 점수 */ +/** 사이트 크롤링 집계 점수 */ export interface AggregateScores { overall_score: number; grade: string; @@ -75,6 +75,28 @@ export interface InspectPageResponse { inspection_id: string; } +/** 사이트 검사 이력 목록 항목 */ +export interface SiteInspectionListItem { + site_inspection_id: string; + root_url: string; + domain: string; + status: SiteInspectionStatus; + created_at: string; + pages_total: number; + pages_inspected: number; + overall_score: number | null; + grade: string | null; +} + +/** GET /api/site-inspections 응답 - 페이지네이션 */ +export interface SiteInspectionPaginatedResponse { + items: SiteInspectionListItem[]; + total: number; + page: number; + limit: number; + total_pages: number; +} + // ─────────────────────────────────────────────────────── // SSE 이벤트 타입 // ─────────────────────────────────────────────────────── diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 188463c..d4e265e 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -25,8 +25,8 @@ server { proxy_send_timeout 120s; } - # SSE 스트리밍 전용 (버퍼링 OFF) - location ~ ^/api/inspections/[^/]+/stream$ { + # SSE 스트리밍 전용 (모든 검사 타입: 단일/사이트/배치) + location ~ ^/api/(inspections|site-inspections|batch-inspections)/[^/]+/stream$ { proxy_pass http://backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -36,7 +36,7 @@ server { proxy_buffering off; proxy_cache off; - proxy_read_timeout 300s; + proxy_read_timeout 600s; chunked_transfer_encoding off; # SSE content type