feat: 3-mode inspection with tabbed UI + batch upload
- 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 <noreply@anthropic.com>
This commit is contained in:
@ -26,6 +26,10 @@ class Settings(BaseSettings):
|
|||||||
SITE_MAX_DEPTH: int = 2
|
SITE_MAX_DEPTH: int = 2
|
||||||
SITE_CONCURRENCY: int = 8
|
SITE_CONCURRENCY: int = 8
|
||||||
|
|
||||||
|
# Batch inspection
|
||||||
|
BATCH_MAX_URLS: int = 200
|
||||||
|
BATCH_CONCURRENCY: int = 8
|
||||||
|
|
||||||
# Application
|
# Application
|
||||||
PROJECT_NAME: str = "Web Inspector API"
|
PROJECT_NAME: str = "Web Inspector API"
|
||||||
|
|
||||||
|
|||||||
@ -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([("domain", 1), ("created_at", -1)])
|
||||||
await _db.site_inspections.create_index([("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
|
# Verify connection
|
||||||
await _client.admin.command("ping")
|
await _client.admin.command("ping")
|
||||||
logger.info("MongoDB connected successfully: %s", settings.DB_NAME)
|
logger.info("MongoDB connected successfully: %s", settings.DB_NAME)
|
||||||
|
|||||||
@ -10,7 +10,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
|
|
||||||
from app.core.database import connect_db, close_db
|
from app.core.database import connect_db, close_db
|
||||||
from app.core.redis import connect_redis, close_redis
|
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
|
# Configure logging
|
||||||
logging.basicConfig(
|
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(inspections.router, prefix="/api", tags=["Inspections"])
|
||||||
app.include_router(reports.router, prefix="/api", tags=["Reports"])
|
app.include_router(reports.router, prefix="/api", tags=["Reports"])
|
||||||
app.include_router(site_inspections.router, prefix="/api", tags=["Site Inspections"])
|
app.include_router(site_inspections.router, prefix="/api", tags=["Site Inspections"])
|
||||||
|
app.include_router(batch_inspections.router, prefix="/api", tags=["Batch Inspections"])
|
||||||
|
|||||||
86
backend/app/models/batch_schemas.py
Normal file
86
backend/app/models/batch_schemas.py
Normal file
@ -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
|
||||||
286
backend/app/routers/batch_inspections.py
Normal file
286
backend/app/routers/batch_inspections.py
Normal file
@ -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
|
||||||
537
backend/app/services/batch_inspection_service.py
Normal file
537
backend/app/services/batch_inspection_service.py
Normal file
@ -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),
|
||||||
|
)
|
||||||
@ -33,5 +33,8 @@ Jinja2>=3.1.0
|
|||||||
# Rules (YAML)
|
# Rules (YAML)
|
||||||
PyYAML>=6.0.0
|
PyYAML>=6.0.0
|
||||||
|
|
||||||
|
# Multipart (file upload)
|
||||||
|
python-multipart>=0.0.7
|
||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
python-slugify>=8.0.0
|
python-slugify>=8.0.0
|
||||||
|
|||||||
481
frontend/src/app/batch-inspections/[id]/page.tsx
Normal file
481
frontend/src/app/batch-inspections/[id]/page.tsx
Normal file
@ -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<string | null>(null);
|
||||||
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: batchResult,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
} = useBatchInspectionResult(id);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<LoadingSpinner message="배치 검사 결과를 불러오는 중..." />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !batchResult) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<ErrorState
|
||||||
|
message={
|
||||||
|
error instanceof ApiError
|
||||||
|
? error.detail
|
||||||
|
: "배치 검사 결과를 불러올 수 없습니다"
|
||||||
|
}
|
||||||
|
onRetry={() => refetch()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 선택된 페이지의 inspection_id 찾기
|
||||||
|
const selectedPage = selectedPageUrl
|
||||||
|
? batchResult.discovered_pages.find((p) => p.url === selectedPageUrl)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const handleSelectPage = (url: string | null) => {
|
||||||
|
setSelectedPageUrl(url);
|
||||||
|
// 모바일: 사이드바 닫기
|
||||||
|
setIsSidebarOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-[calc(100vh-64px)]">
|
||||||
|
{/* 배치 이름 헤더 */}
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 border-b">
|
||||||
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium truncate">{batchResult.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 모바일 사이드바 토글 */}
|
||||||
|
<div className="lg:hidden flex items-center gap-2 px-4 py-2 border-b">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||||
|
>
|
||||||
|
{isSidebarOpen ? (
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Menu className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
URL 목록
|
||||||
|
</Button>
|
||||||
|
{selectedPageUrl && (
|
||||||
|
<span className="text-sm text-muted-foreground truncate">
|
||||||
|
{selectedPageUrl}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex h-full relative">
|
||||||
|
{/* 왼쪽 사이드바: URL 목록 */}
|
||||||
|
<aside
|
||||||
|
className={`
|
||||||
|
w-72 border-r bg-card flex-shrink-0 h-full
|
||||||
|
lg:relative lg:translate-x-0 lg:block
|
||||||
|
absolute z-20 top-0 left-0
|
||||||
|
transition-transform duration-200
|
||||||
|
${isSidebarOpen ? "translate-x-0" : "-translate-x-full lg:translate-x-0"}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<BatchUrlList
|
||||||
|
pages={batchResult.discovered_pages}
|
||||||
|
selectedUrl={selectedPageUrl}
|
||||||
|
onSelectPage={handleSelectPage}
|
||||||
|
aggregateScores={batchResult.aggregate_scores}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* 모바일 오버레이 */}
|
||||||
|
{isSidebarOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/30 z-10 lg:hidden"
|
||||||
|
onClick={() => setIsSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 오른쪽 패널: 결과 표시 */}
|
||||||
|
<main className="flex-1 overflow-y-auto p-6">
|
||||||
|
{selectedPageUrl === null ? (
|
||||||
|
// 전체 집계 보기
|
||||||
|
batchResult.aggregate_scores ? (
|
||||||
|
<BatchAggregatePanel
|
||||||
|
aggregateScores={batchResult.aggregate_scores}
|
||||||
|
name={batchResult.name}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center py-12 text-muted-foreground">
|
||||||
|
<Clock className="h-8 w-8 mb-3" />
|
||||||
|
<p>아직 집계 결과가 없습니다</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : selectedPage?.inspection_id ? (
|
||||||
|
// 개별 페이지 결과 (대시보드 재사용)
|
||||||
|
<PageDashboard
|
||||||
|
inspectionId={selectedPage.inspection_id}
|
||||||
|
pageUrl={selectedPageUrl}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
// 검사 대기 중인 페이지
|
||||||
|
<div className="flex flex-col items-center py-12 text-muted-foreground">
|
||||||
|
<Clock className="h-8 w-8 mb-3" />
|
||||||
|
<p className="text-lg font-medium">검사 대기 중</p>
|
||||||
|
<p className="text-sm mt-1">
|
||||||
|
이 페이지는 아직 검사가 완료되지 않았습니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배치 검사 전체 집계 패널.
|
||||||
|
* 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 (
|
||||||
|
<div>
|
||||||
|
{/* 상단 배치 이름 및 검사 요약 */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-1">배치 검사 결과</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">{name}</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
검사 완료: {aggregateScores.pages_inspected}/
|
||||||
|
{aggregateScores.pages_total} 페이지
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 종합 점수 게이지 */}
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle className="text-xl">배치 종합 점수</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex justify-center">
|
||||||
|
<OverallScoreGauge
|
||||||
|
score={aggregateScores.overall_score}
|
||||||
|
grade={aggregateScores.grade as import("@/types/inspection").Grade}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 카테고리별 평균 점수 카드 (4개) */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
|
||||||
|
{categoryItems.map((item) => (
|
||||||
|
<Card key={item.key}>
|
||||||
|
<CardContent className="pt-5 pb-4 px-5 text-center">
|
||||||
|
<h3 className="text-sm font-medium text-muted-foreground mb-2">
|
||||||
|
{item.label}
|
||||||
|
</h3>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"text-3xl font-bold mb-1",
|
||||||
|
getScoreTailwindColor(item.score)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.score}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
페이지 평균 점수
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 총 이슈 수 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
배치 전체 이슈
|
||||||
|
</span>
|
||||||
|
<span className="text-lg font-bold">
|
||||||
|
총 {aggregateScores.total_issues}건
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개별 페이지 대시보드 컴포넌트.
|
||||||
|
* 카테고리 클릭 시 이슈 목록을 인라인으로 표시하여 사이드바를 유지한다.
|
||||||
|
*/
|
||||||
|
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 <LoadingSpinner message="페이지 검사 결과를 불러오는 중..." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !result) {
|
||||||
|
return (
|
||||||
|
<ErrorState
|
||||||
|
message={
|
||||||
|
error instanceof ApiError
|
||||||
|
? error.detail
|
||||||
|
: "검사 결과를 불러올 수 없습니다"
|
||||||
|
}
|
||||||
|
onRetry={() => refetch()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이슈 목록 인라인 보기
|
||||||
|
if (issueView.showing) {
|
||||||
|
return (
|
||||||
|
<InlineIssueView
|
||||||
|
inspectionId={inspectionId}
|
||||||
|
initialCategory={issueView.initialCategory}
|
||||||
|
onBack={handleBackToDashboard}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* 페이지 URL 표시 */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<h2 className="text-lg font-semibold">페이지 검사 결과</h2>
|
||||||
|
<a
|
||||||
|
href={pageUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{pageUrl}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 종합 점수 */}
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle className="text-xl">종합 점수</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex justify-center">
|
||||||
|
<OverallScoreGauge
|
||||||
|
score={result.overall_score}
|
||||||
|
grade={result.grade}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 카테고리별 점수 카드 */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
|
||||||
|
{CATEGORY_KEYS.map((key) => {
|
||||||
|
const cat = result.categories[key];
|
||||||
|
return (
|
||||||
|
<CategoryScoreCard
|
||||||
|
key={key}
|
||||||
|
categoryName={CATEGORY_LABELS[key]}
|
||||||
|
score={cat.score}
|
||||||
|
grade={cat.grade}
|
||||||
|
issueCount={cat.total_issues}
|
||||||
|
onClick={() => handleCategoryClick(key)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검사 메타 정보 */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<InspectionMeta
|
||||||
|
url={result.url}
|
||||||
|
createdAt={result.created_at}
|
||||||
|
durationSeconds={result.duration_seconds}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 이슈 상세 링크 */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<Button onClick={handleViewIssues} variant="default">
|
||||||
|
이슈 상세 보기
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 이슈 요약 바 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-4">
|
||||||
|
<IssueSummaryBar
|
||||||
|
critical={result.summary.critical}
|
||||||
|
major={result.summary.major}
|
||||||
|
minor={result.summary.minor}
|
||||||
|
info={result.summary.info}
|
||||||
|
total={result.summary.total_issues}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이슈 목록 인라인 뷰.
|
||||||
|
* 배치 검사 결과 페이지 내에서 이슈를 표시하여 사이드바를 유지한다.
|
||||||
|
*/
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
{/* 뒤로가기 */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<Button variant="ghost" size="sm" onClick={onBack}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
검사 결과로 돌아가기
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-xl font-bold mb-4">상세 이슈 목록</h2>
|
||||||
|
|
||||||
|
{/* 필터 */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<FilterBar
|
||||||
|
selectedCategory={selectedCategory}
|
||||||
|
selectedSeverity={selectedSeverity}
|
||||||
|
onCategoryChange={setSelectedCategory}
|
||||||
|
onSeverityChange={setSelectedSeverity}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 이슈 목록 */}
|
||||||
|
{isError ? (
|
||||||
|
<ErrorState
|
||||||
|
message="이슈 목록을 불러올 수 없습니다"
|
||||||
|
onRetry={() => refetch()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<IssueList
|
||||||
|
issues={data?.issues}
|
||||||
|
isLoading={isLoading}
|
||||||
|
total={data?.total || 0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
frontend/src/app/batch-inspections/[id]/progress/page.tsx
Normal file
33
frontend/src/app/batch-inspections/[id]/progress/page.tsx
Normal file
@ -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 (
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<h1 className="text-2xl font-bold mb-6 text-center">
|
||||||
|
배치 검사
|
||||||
|
</h1>
|
||||||
|
<BatchProgress batchInspectionId={id} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,28 +1,208 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
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 { SearchBar } from "@/components/history/SearchBar";
|
||||||
import { InspectionHistoryTable } from "@/components/history/InspectionHistoryTable";
|
import { InspectionHistoryTable } from "@/components/history/InspectionHistoryTable";
|
||||||
|
import { SiteHistoryTable } from "@/components/history/SiteHistoryTable";
|
||||||
import { Pagination } from "@/components/history/Pagination";
|
import { Pagination } from "@/components/history/Pagination";
|
||||||
import { ErrorState } from "@/components/common/ErrorState";
|
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,
|
const BATCH_STATUS_LABELS: Record<string, string> = {
|
||||||
searchQuery || undefined
|
inspecting: "검사 중",
|
||||||
|
completed: "완료",
|
||||||
|
error: "오류",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface BatchHistoryTableProps {
|
||||||
|
inspections: BatchInspectionListItem[] | undefined;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BatchHistoryTable({ inspections, isLoading }: BatchHistoryTableProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <LoadingSpinner message="배치 검사 이력을 불러오는 중..." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inspections || inspections.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
message="배치 검사 이력이 없습니다"
|
||||||
|
description="URL 목록 파일을 업로드하여 첫 번째 배치 검사를 시작해보세요"
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const handleSearch = (query: string) => {
|
const handleRowClick = (batchInspectionId: string) => {
|
||||||
setSearchQuery(query);
|
router.push(`/batch-inspections/${batchInspectionId}`);
|
||||||
setPage(1); // 검색 시 1페이지로 리셋
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePageChange = (newPage: number) => {
|
return (
|
||||||
setPage(newPage);
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>이름</TableHead>
|
||||||
|
<TableHead className="text-center">상태</TableHead>
|
||||||
|
<TableHead className="text-center hidden sm:table-cell">
|
||||||
|
종합 점수
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-center hidden md:table-cell">
|
||||||
|
등급
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-center hidden md:table-cell">
|
||||||
|
URL 수
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="hidden sm:table-cell">검사일</TableHead>
|
||||||
|
<TableHead className="text-center">액션</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{inspections.map((item) => (
|
||||||
|
<TableRow
|
||||||
|
key={item.batch_inspection_id}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => handleRowClick(item.batch_inspection_id)}
|
||||||
|
>
|
||||||
|
<TableCell className="max-w-[200px] lg:max-w-[300px]">
|
||||||
|
<span className="truncate block text-sm font-medium">
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground sm:hidden">
|
||||||
|
{formatDateTime(item.created_at)}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs font-medium px-2 py-1 rounded-full",
|
||||||
|
item.status === "completed"
|
||||||
|
? "bg-green-100 text-green-700"
|
||||||
|
: item.status === "error"
|
||||||
|
? "bg-red-100 text-red-700"
|
||||||
|
: "bg-yellow-100 text-yellow-700"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{BATCH_STATUS_LABELS[item.status] || item.status}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center hidden sm:table-cell">
|
||||||
|
{item.overall_score !== null ? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-bold",
|
||||||
|
getScoreTailwindColor(item.overall_score)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.overall_score}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center hidden md:table-cell">
|
||||||
|
{item.grade ? (
|
||||||
|
<ScoreBadge grade={item.grade as Grade} />
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center hidden md:table-cell text-sm">
|
||||||
|
{item.total_urls}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden sm:table-cell text-sm text-muted-foreground">
|
||||||
|
{formatDateTime(item.created_at)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleRowClick(item.batch_inspection_id);
|
||||||
|
}}
|
||||||
|
aria-label="결과 보기"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────
|
||||||
|
// 메인 이력 페이지
|
||||||
|
// ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function HistoryPage() {
|
||||||
|
const [activeTab, setActiveTab] = useState<InspectionTab>("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" });
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -30,38 +210,111 @@ export default function HistoryPage() {
|
|||||||
<div className="container mx-auto px-4 py-8">
|
<div className="container mx-auto px-4 py-8">
|
||||||
<h1 className="text-2xl font-bold mb-6">검사 이력</h1>
|
<h1 className="text-2xl font-bold mb-6">검사 이력</h1>
|
||||||
|
|
||||||
{/* 검색 바 */}
|
{/* 탭 */}
|
||||||
|
<InspectionTabs activeTab={activeTab} onTabChange={setActiveTab} />
|
||||||
|
|
||||||
|
{/* single 탭 */}
|
||||||
|
{activeTab === "single" && (
|
||||||
|
<>
|
||||||
<div className="mb-6 max-w-md">
|
<div className="mb-6 max-w-md">
|
||||||
<SearchBar
|
<SearchBar
|
||||||
query={searchQuery}
|
query={singleSearch}
|
||||||
onSearch={handleSearch}
|
onSearch={handleSingleSearch}
|
||||||
placeholder="URL 검색..."
|
placeholder="URL 검색..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 테이블 또는 에러 */}
|
{singleQuery.isError ? (
|
||||||
{isError ? (
|
|
||||||
<ErrorState
|
<ErrorState
|
||||||
message="검사 이력을 불러올 수 없습니다"
|
message="검사 이력을 불러올 수 없습니다"
|
||||||
onRetry={() => refetch()}
|
onRetry={() => singleQuery.refetch()}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<InspectionHistoryTable
|
<InspectionHistoryTable
|
||||||
inspections={data?.items}
|
inspections={singleQuery.data?.items}
|
||||||
isLoading={isLoading}
|
isLoading={singleQuery.isLoading}
|
||||||
/>
|
/>
|
||||||
|
{singleQuery.data && singleQuery.data.total_pages > 1 && (
|
||||||
{/* 페이지네이션 */}
|
|
||||||
{data && data.total_pages > 1 && (
|
|
||||||
<Pagination
|
<Pagination
|
||||||
currentPage={data.page}
|
currentPage={singleQuery.data.page}
|
||||||
totalPages={data.total_pages}
|
totalPages={singleQuery.data.total_pages}
|
||||||
onPageChange={handlePageChange}
|
onPageChange={(p) => {
|
||||||
|
setSinglePage(p);
|
||||||
|
scrollToTop();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* site 탭 */}
|
||||||
|
{activeTab === "site" && (
|
||||||
|
<>
|
||||||
|
{siteQuery.isError ? (
|
||||||
|
<ErrorState
|
||||||
|
message="사이트 크롤링 이력을 불러올 수 없습니다"
|
||||||
|
onRetry={() => siteQuery.refetch()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<SiteHistoryTable
|
||||||
|
inspections={siteQuery.data?.items}
|
||||||
|
isLoading={siteQuery.isLoading}
|
||||||
|
/>
|
||||||
|
{siteQuery.data && siteQuery.data.total_pages > 1 && (
|
||||||
|
<Pagination
|
||||||
|
currentPage={siteQuery.data.page}
|
||||||
|
totalPages={siteQuery.data.total_pages}
|
||||||
|
onPageChange={(p) => {
|
||||||
|
setSitePage(p);
|
||||||
|
scrollToTop();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* batch 탭 */}
|
||||||
|
{activeTab === "batch" && (
|
||||||
|
<>
|
||||||
|
<div className="mb-6 max-w-md">
|
||||||
|
<SearchBar
|
||||||
|
query={batchSearch}
|
||||||
|
onSearch={handleBatchSearch}
|
||||||
|
placeholder="배치 이름 검색..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{batchQuery.isError ? (
|
||||||
|
<ErrorState
|
||||||
|
message="배치 검사 이력을 불러올 수 없습니다"
|
||||||
|
onRetry={() => batchQuery.refetch()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<BatchHistoryTable
|
||||||
|
inspections={batchQuery.data?.items}
|
||||||
|
isLoading={batchQuery.isLoading}
|
||||||
|
/>
|
||||||
|
{batchQuery.data && batchQuery.data.total_pages > 1 && (
|
||||||
|
<Pagination
|
||||||
|
currentPage={batchQuery.data.page}
|
||||||
|
totalPages={batchQuery.data.total_pages}
|
||||||
|
onPageChange={(p) => {
|
||||||
|
setBatchPage(p);
|
||||||
|
scrollToTop();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,21 @@
|
|||||||
"use client";
|
"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 { RecentInspections } from "@/components/inspection/RecentInspections";
|
||||||
|
import { RecentSiteInspections } from "@/components/inspection/RecentSiteInspections";
|
||||||
|
import { RecentBatchInspections } from "@/components/inspection/RecentBatchInspections";
|
||||||
import { Search } from "lucide-react";
|
import { Search } from "lucide-react";
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
|
const [activeTab, setActiveTab] = useState<InspectionTab>("single");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-12">
|
<div className="container mx-auto px-4 py-12">
|
||||||
{/* 히어로 섹션 */}
|
{/* 히어로 섹션 */}
|
||||||
@ -22,11 +33,18 @@ export default function HomePage() {
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* URL 입력 폼 */}
|
{/* 탭 */}
|
||||||
<UrlInputForm />
|
<InspectionTabs activeTab={activeTab} onTabChange={setActiveTab} />
|
||||||
|
|
||||||
{/* 최근 검사 이력 */}
|
{/* 탭별 폼 */}
|
||||||
<RecentInspections />
|
{activeTab === "single" && <SinglePageForm />}
|
||||||
|
{activeTab === "site" && <SiteCrawlForm />}
|
||||||
|
{activeTab === "batch" && <BatchUploadForm />}
|
||||||
|
|
||||||
|
{/* 탭별 최근 이력 */}
|
||||||
|
{activeTab === "single" && <RecentInspections />}
|
||||||
|
{activeTab === "site" && <RecentSiteInspections />}
|
||||||
|
{activeTab === "batch" && <RecentBatchInspections />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,7 +25,7 @@ export default function SiteInspectionProgressPage({
|
|||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div className="container mx-auto px-4 py-8">
|
||||||
<h1 className="text-2xl font-bold mb-6 text-center">
|
<h1 className="text-2xl font-bold mb-6 text-center">
|
||||||
사이트 전체 검사
|
사이트 크롤링 검사
|
||||||
</h1>
|
</h1>
|
||||||
<SiteCrawlProgress siteInspectionId={id} />
|
<SiteCrawlProgress siteInspectionId={id} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
218
frontend/src/components/batch-inspection/BatchProgress.tsx
Normal file
218
frontend/src/components/batch-inspection/BatchProgress.tsx
Normal file
@ -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 (
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
{/* 배치 이름 표시 */}
|
||||||
|
{name && (
|
||||||
|
<div className="flex items-center gap-2 mb-6 text-muted-foreground">
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
<span className="text-sm truncate">{name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 검사 단계 (크롤링 단계 없이 바로 검사) */}
|
||||||
|
{(status === "inspecting" || status === "completed") && (
|
||||||
|
<InspectionPhase
|
||||||
|
pages={discoveredPages}
|
||||||
|
completedPages={completedPages}
|
||||||
|
totalPages={totalPages}
|
||||||
|
overallProgress={overallProgress}
|
||||||
|
aggregateScores={aggregateScores}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 에러 상태 */}
|
||||||
|
{status === "error" && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<ErrorState
|
||||||
|
message={errorMessage || "배치 검사 중 오류가 발생했습니다"}
|
||||||
|
onRetry={handleRetry}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 초기 연결 중 상태 */}
|
||||||
|
{status === "idle" && (
|
||||||
|
<div className="flex flex-col items-center py-12">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
<p className="mt-3 text-sm text-muted-foreground">
|
||||||
|
서버에 연결하는 중...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 검사 단계 UI */
|
||||||
|
function InspectionPhase({
|
||||||
|
pages,
|
||||||
|
completedPages,
|
||||||
|
totalPages,
|
||||||
|
overallProgress,
|
||||||
|
aggregateScores,
|
||||||
|
}: {
|
||||||
|
pages: BatchPage[];
|
||||||
|
completedPages: number;
|
||||||
|
totalPages: number;
|
||||||
|
overallProgress: number;
|
||||||
|
aggregateScores: { overall_score: number; grade: string } | null;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* 전체 진행률 */}
|
||||||
|
<Card className="mb-4">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="font-semibold">페이지 검사 진행</h3>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{completedPages}/{totalPages}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={overallProgress} className="h-3" />
|
||||||
|
<div className="flex items-center justify-between mt-2">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{overallProgress}% 완료
|
||||||
|
</span>
|
||||||
|
{aggregateScores && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs font-bold",
|
||||||
|
getScoreTailwindColor(aggregateScores.overall_score)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
현재 평균: {aggregateScores.overall_score}점{" "}
|
||||||
|
{aggregateScores.grade}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 개별 페이지 목록 */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{pages.map((page) => (
|
||||||
|
<PageProgressItem key={page.url} page={page} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 개별 페이지 진행 항목 */
|
||||||
|
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 (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 rounded-md bg-muted/30">
|
||||||
|
{/* 상태 아이콘 */}
|
||||||
|
<PageStatusIcon status={page.status} />
|
||||||
|
|
||||||
|
{/* URL 경로 */}
|
||||||
|
<span className="text-sm truncate flex-1 min-w-0" title={page.url}>
|
||||||
|
{displayPath}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* 점수 (완료 시) */}
|
||||||
|
{page.status === "completed" && page.overall_score !== null && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs font-bold flex-shrink-0",
|
||||||
|
getScoreTailwindColor(page.overall_score)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{page.overall_score}점
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 검사 중 표시 */}
|
||||||
|
{page.status === "inspecting" && (
|
||||||
|
<span className="text-xs text-blue-500 flex-shrink-0">검사 중</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 페이지 상태 아이콘 */
|
||||||
|
function PageStatusIcon({ status }: { status: BatchPage["status"] }) {
|
||||||
|
switch (status) {
|
||||||
|
case "pending":
|
||||||
|
return <Circle className="h-4 w-4 flex-shrink-0 text-muted-foreground/50" />;
|
||||||
|
case "inspecting":
|
||||||
|
return (
|
||||||
|
<Loader2 className="h-4 w-4 flex-shrink-0 text-blue-500 animate-spin" />
|
||||||
|
);
|
||||||
|
case "completed":
|
||||||
|
return <Check className="h-4 w-4 flex-shrink-0 text-green-500" />;
|
||||||
|
case "error":
|
||||||
|
return <X className="h-4 w-4 flex-shrink-0 text-red-500" />;
|
||||||
|
default:
|
||||||
|
return <Circle className="h-4 w-4 flex-shrink-0 text-muted-foreground/50" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
180
frontend/src/components/batch-inspection/BatchUrlList.tsx
Normal file
180
frontend/src/components/batch-inspection/BatchUrlList.tsx
Normal file
@ -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 (
|
||||||
|
<div
|
||||||
|
className="flex flex-col h-full"
|
||||||
|
role="listbox"
|
||||||
|
aria-label="URL 목록"
|
||||||
|
>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="px-3 py-2 border-b">
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground">
|
||||||
|
URL 목록
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 리스트 본문 */}
|
||||||
|
<div className="flex-1 overflow-y-auto py-1">
|
||||||
|
{/* 전체 요약 항목 */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 py-2 px-3 rounded-md cursor-pointer text-sm",
|
||||||
|
"hover:bg-accent/50 transition-colors",
|
||||||
|
isAggregateSelected && "bg-accent text-accent-foreground"
|
||||||
|
)}
|
||||||
|
onClick={() => onSelectPage(null)}
|
||||||
|
role="option"
|
||||||
|
aria-selected={isAggregateSelected}
|
||||||
|
>
|
||||||
|
<Globe className="h-4 w-4 flex-shrink-0 text-primary" />
|
||||||
|
<span className="font-medium truncate flex-1">전체 요약</span>
|
||||||
|
{aggregateScores && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex-shrink-0 text-xs font-bold",
|
||||||
|
getScoreTailwindColor(aggregateScores.overall_score)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{aggregateScores.overall_score}점 {aggregateScores.grade}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* URL 목록 */}
|
||||||
|
{pages.map((page) => (
|
||||||
|
<UrlListItem
|
||||||
|
key={page.url}
|
||||||
|
page={page}
|
||||||
|
isSelected={selectedUrl === page.url}
|
||||||
|
onSelect={() => onSelectPage(page.url)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 빈 상태 */}
|
||||||
|
{pages.length === 0 && (
|
||||||
|
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||||
|
등록된 URL이 없습니다
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 하단 요약 */}
|
||||||
|
{pages.length > 0 && (
|
||||||
|
<div className="px-3 py-2 border-t text-xs text-muted-foreground">
|
||||||
|
총 {pages.length}개 URL
|
||||||
|
{aggregateScores && (
|
||||||
|
<span>
|
||||||
|
{" "}/ 검사 완료 {aggregateScores.pages_inspected}/
|
||||||
|
{aggregateScores.pages_total}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 개별 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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 py-2 px-3 rounded-md cursor-pointer text-sm",
|
||||||
|
"hover:bg-accent/50 transition-colors",
|
||||||
|
isSelected && "bg-accent text-accent-foreground"
|
||||||
|
)}
|
||||||
|
onClick={onSelect}
|
||||||
|
role="option"
|
||||||
|
aria-selected={isSelected}
|
||||||
|
>
|
||||||
|
{/* 상태 아이콘 */}
|
||||||
|
<PageStatusIcon status={page.status} />
|
||||||
|
|
||||||
|
{/* URL 경로 */}
|
||||||
|
<span className="truncate flex-1 min-w-0" title={page.url}>
|
||||||
|
{displayPath}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* 점수 (완료 시) */}
|
||||||
|
{page.status === "completed" && page.overall_score !== null && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex-shrink-0 text-xs font-bold",
|
||||||
|
getScoreTailwindColor(page.overall_score)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{page.overall_score}점
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 검사 중 표시 */}
|
||||||
|
{page.status === "inspecting" && (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 flex-shrink-0 text-blue-500 animate-spin" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 페이지 상태 아이콘 */
|
||||||
|
function PageStatusIcon({ status }: { status: BatchPage["status"] }) {
|
||||||
|
switch (status) {
|
||||||
|
case "pending":
|
||||||
|
return <Circle className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/50" />;
|
||||||
|
case "inspecting":
|
||||||
|
return (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 flex-shrink-0 text-blue-500 animate-spin" />
|
||||||
|
);
|
||||||
|
case "completed":
|
||||||
|
return <Check className="h-3.5 w-3.5 flex-shrink-0 text-green-500" />;
|
||||||
|
case "error":
|
||||||
|
return <X className="h-3.5 w-3.5 flex-shrink-0 text-red-500" />;
|
||||||
|
default:
|
||||||
|
return <Circle className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/50" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
155
frontend/src/components/history/SiteHistoryTable.tsx
Normal file
155
frontend/src/components/history/SiteHistoryTable.tsx
Normal file
@ -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<string, string> = {
|
||||||
|
crawling: "크롤링 중",
|
||||||
|
inspecting: "검사 중",
|
||||||
|
completed: "완료",
|
||||||
|
error: "오류",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SiteHistoryTableProps {
|
||||||
|
inspections: SiteInspectionListItem[] | undefined;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 사이트 크롤링 이력 테이블 */
|
||||||
|
export function SiteHistoryTable({
|
||||||
|
inspections,
|
||||||
|
isLoading,
|
||||||
|
}: SiteHistoryTableProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <LoadingSpinner message="사이트 크롤링 이력을 불러오는 중..." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inspections || inspections.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
message="사이트 크롤링 이력이 없습니다"
|
||||||
|
description="사이트 URL을 입력하여 첫 번째 크롤링을 시작해보세요"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRowClick = (siteInspectionId: string) => {
|
||||||
|
router.push(`/site-inspections/${siteInspectionId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>도메인</TableHead>
|
||||||
|
<TableHead className="text-center">상태</TableHead>
|
||||||
|
<TableHead className="text-center hidden sm:table-cell">
|
||||||
|
종합 점수
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-center hidden md:table-cell">
|
||||||
|
등급
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-center hidden md:table-cell">
|
||||||
|
페이지 수
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="hidden sm:table-cell">검사일</TableHead>
|
||||||
|
<TableHead className="text-center">액션</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{inspections.map((item) => (
|
||||||
|
<TableRow
|
||||||
|
key={item.site_inspection_id}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => handleRowClick(item.site_inspection_id)}
|
||||||
|
>
|
||||||
|
<TableCell className="max-w-[200px] lg:max-w-[300px]">
|
||||||
|
<span className="truncate block text-sm font-medium">
|
||||||
|
{item.domain || item.root_url}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground sm:hidden">
|
||||||
|
{formatDateTime(item.created_at)}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs font-medium px-2 py-1 rounded-full",
|
||||||
|
item.status === "completed"
|
||||||
|
? "bg-green-100 text-green-700"
|
||||||
|
: item.status === "error"
|
||||||
|
? "bg-red-100 text-red-700"
|
||||||
|
: "bg-yellow-100 text-yellow-700"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{STATUS_LABELS[item.status] || item.status}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center hidden sm:table-cell">
|
||||||
|
{item.overall_score !== null ? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-bold",
|
||||||
|
getScoreTailwindColor(item.overall_score)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.overall_score}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center hidden md:table-cell">
|
||||||
|
{item.grade ? (
|
||||||
|
<ScoreBadge grade={item.grade as Grade} />
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center hidden md:table-cell text-sm">
|
||||||
|
{item.pages_total}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden sm:table-cell text-sm text-muted-foreground">
|
||||||
|
{formatDateTime(item.created_at)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleRowClick(item.site_inspection_id);
|
||||||
|
}}
|
||||||
|
aria-label="결과 보기"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
315
frontend/src/components/inspection/BatchUploadForm.tsx
Normal file
315
frontend/src/components/inspection/BatchUploadForm.tsx
Normal file
@ -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<File | null>(null);
|
||||||
|
const [parsedUrls, setParsedUrls] = useState<string[]>([]);
|
||||||
|
const [concurrency, setConcurrency] = useState<number>(4);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||||
|
const selectedFile = e.target.files?.[0];
|
||||||
|
if (selectedFile) {
|
||||||
|
processFile(selectedFile);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 드래그 앤 드롭 핸들러 */
|
||||||
|
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragOver(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragOver(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
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 (
|
||||||
|
<Card className="w-full max-w-2xl mx-auto">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
|
{/* 배치 이름 입력 */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground mb-1.5 block">
|
||||||
|
배치 이름
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => {
|
||||||
|
setName(e.target.value);
|
||||||
|
if (error) setError(null);
|
||||||
|
}}
|
||||||
|
placeholder="예: 2월 정기 검사"
|
||||||
|
className="h-10"
|
||||||
|
disabled={isLoading}
|
||||||
|
aria-label="배치 이름 입력"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 파일 업로드 영역 */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground mb-1.5 block">
|
||||||
|
URL 목록 파일 (.txt)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".txt"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="hidden"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!file ? (
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => 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 목록 파일 업로드"
|
||||||
|
>
|
||||||
|
<Upload className="h-8 w-8 text-muted-foreground" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
클릭하거나 파일을 드래그하여 업로드
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground/70">
|
||||||
|
한 줄에 하나의 URL, # 주석 지원
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3 p-4 rounded-lg border bg-muted/30">
|
||||||
|
{/* 파일 정보 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium">{file.name}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRemoveFile}
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
aria-label="파일 제거"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* URL 미리보기 */}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-primary mb-1.5">
|
||||||
|
{parsedUrls.length}개 URL 감지됨
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{parsedUrls.slice(0, 5).map((parsedUrl, index) => (
|
||||||
|
<li
|
||||||
|
key={index}
|
||||||
|
className="text-xs text-muted-foreground truncate"
|
||||||
|
>
|
||||||
|
{parsedUrl}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{parsedUrls.length > 5 && (
|
||||||
|
<li className="text-xs text-muted-foreground/70">
|
||||||
|
... 외 {parsedUrls.length - 5}개
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 동시 검사 수 */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground mb-1.5 block">
|
||||||
|
동시 검사 수
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{CONCURRENCY_OPTIONS.map((option) => (
|
||||||
|
<Button
|
||||||
|
key={option}
|
||||||
|
type="button"
|
||||||
|
variant={concurrency === option ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"flex-1",
|
||||||
|
concurrency === option && "pointer-events-none"
|
||||||
|
)}
|
||||||
|
onClick={() => setConcurrency(option)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{option}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검사 시작 버튼 */}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="lg"
|
||||||
|
className="h-12 text-base w-full"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
검사 시작 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
검사 시작
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p
|
||||||
|
className="mt-2 text-sm text-destructive"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
frontend/src/components/inspection/InspectionTabs.tsx
Normal file
39
frontend/src/components/inspection/InspectionTabs.tsx
Normal file
@ -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 (
|
||||||
|
<div className="flex justify-center gap-2 mb-6">
|
||||||
|
{TABS.map(({ key, label, icon: Icon }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => onTabChange(key)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors",
|
||||||
|
activeTab === key
|
||||||
|
? "bg-primary text-primary-foreground shadow-sm"
|
||||||
|
: "bg-muted/50 text-muted-foreground hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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 <LoadingSpinner message="최근 배치 검사 이력을 불러오는 중..." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.items.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
message="배치 검사 이력이 없습니다"
|
||||||
|
description="URL 목록 파일을 업로드하여 첫 번째 배치 검사를 시작해보세요"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-2xl mx-auto mt-8">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">최근 배치 검사 이력</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{data.items.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.batch_inspection_id}
|
||||||
|
href={`/batch-inspections/${item.batch_inspection_id}`}
|
||||||
|
>
|
||||||
|
<Card className="hover:shadow-md transition-shadow cursor-pointer">
|
||||||
|
<CardContent className="flex items-center justify-between py-4 px-5">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Upload className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
<span className="text-sm font-medium truncate">
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{formatDate(item.created_at)}
|
||||||
|
</p>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{item.total_urls}개 URL
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 ml-4">
|
||||||
|
{item.overall_score !== null && (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-bold",
|
||||||
|
getScoreTailwindColor(item.overall_score)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.overall_score}점
|
||||||
|
</span>
|
||||||
|
{item.grade && <ScoreBadge grade={item.grade as Grade} />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{item.overall_score === null && (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{item.status === "inspecting"
|
||||||
|
? "검사 중"
|
||||||
|
: item.status === "error"
|
||||||
|
? "오류"
|
||||||
|
: "대기 중"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
frontend/src/components/inspection/RecentSiteInspections.tsx
Normal file
95
frontend/src/components/inspection/RecentSiteInspections.tsx
Normal file
@ -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 <LoadingSpinner message="최근 사이트 크롤링 이력을 불러오는 중..." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.items.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
message="사이트 크롤링 이력이 없습니다"
|
||||||
|
description="사이트 URL을 입력하여 첫 번째 크롤링을 시작해보세요"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-2xl mx-auto mt-8">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">최근 사이트 크롤링 이력</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{data.items.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.site_inspection_id}
|
||||||
|
href={`/site-inspections/${item.site_inspection_id}`}
|
||||||
|
>
|
||||||
|
<Card className="hover:shadow-md transition-shadow cursor-pointer">
|
||||||
|
<CardContent className="flex items-center justify-between py-4 px-5">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Globe className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
<span className="text-sm font-medium truncate">
|
||||||
|
{item.domain || item.root_url}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{formatDate(item.created_at)}
|
||||||
|
</p>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{item.pages_total}페이지
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 ml-4">
|
||||||
|
{item.overall_score !== null && (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-bold",
|
||||||
|
getScoreTailwindColor(item.overall_score)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.overall_score}점
|
||||||
|
</span>
|
||||||
|
{item.grade && <ScoreBadge grade={item.grade as Grade} />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{item.overall_score === null && (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{item.status === "crawling"
|
||||||
|
? "크롤링 중"
|
||||||
|
: item.status === "inspecting"
|
||||||
|
? "검사 중"
|
||||||
|
: item.status === "error"
|
||||||
|
? "오류"
|
||||||
|
: "대기 중"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
frontend/src/components/inspection/SinglePageForm.tsx
Normal file
104
frontend/src/components/inspection/SinglePageForm.tsx
Normal file
@ -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<string | null>(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 (
|
||||||
|
<Card className="w-full max-w-2xl mx-auto">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => {
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="lg"
|
||||||
|
className="h-12 px-6 text-base"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
검사 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"검사 시작"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p
|
||||||
|
id="single-url-error"
|
||||||
|
className="mt-2 text-sm text-destructive"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
206
frontend/src/components/inspection/SiteCrawlForm.tsx
Normal file
206
frontend/src/components/inspection/SiteCrawlForm.tsx
Normal file
@ -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<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [maxPages, setMaxPages] = useState<number>(20);
|
||||||
|
const [maxDepth, setMaxDepth] = useState<number>(2);
|
||||||
|
const [concurrency, setConcurrency] = useState<number>(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 (
|
||||||
|
<Card className="w-full max-w-2xl mx-auto">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
|
{/* URL 입력 필드 */}
|
||||||
|
<div className="relative">
|
||||||
|
<Globe className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => {
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 옵션 영역 */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
{/* 최대 페이지 수 */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground mb-1.5 block">
|
||||||
|
최대 페이지 수
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{MAX_PAGES_OPTIONS.map((option) => (
|
||||||
|
<Button
|
||||||
|
key={option}
|
||||||
|
type="button"
|
||||||
|
variant={maxPages === option ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"flex-1 text-xs",
|
||||||
|
maxPages === option && "pointer-events-none"
|
||||||
|
)}
|
||||||
|
onClick={() => setMaxPages(option)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{option === 0 ? "무제한" : option}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 크롤링 깊이 */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground mb-1.5 block">
|
||||||
|
크롤링 깊이
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{MAX_DEPTH_OPTIONS.map((option) => (
|
||||||
|
<Button
|
||||||
|
key={option}
|
||||||
|
type="button"
|
||||||
|
variant={maxDepth === option ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"flex-1 text-xs",
|
||||||
|
maxDepth === option && "pointer-events-none"
|
||||||
|
)}
|
||||||
|
onClick={() => setMaxDepth(option)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{option}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 동시 검사 수 */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground mb-1.5 block">
|
||||||
|
동시 검사 수
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{CONCURRENCY_OPTIONS.map((option) => (
|
||||||
|
<Button
|
||||||
|
key={option}
|
||||||
|
type="button"
|
||||||
|
variant={concurrency === option ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"flex-1 text-xs",
|
||||||
|
concurrency === option && "pointer-events-none"
|
||||||
|
)}
|
||||||
|
onClick={() => setConcurrency(option)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{option}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 사이트 크롤링 시작 버튼 */}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="lg"
|
||||||
|
className="h-12 text-base w-full"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
크롤링 시작 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Globe className="h-4 w-4" />
|
||||||
|
사이트 크롤링 시작
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p
|
||||||
|
id="site-url-error"
|
||||||
|
className="mt-2 text-sm text-destructive"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -22,7 +22,7 @@ interface AggregateCategoryItem {
|
|||||||
score: number;
|
score: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 사이트 전체 집계 점수 패널 */
|
/** 사이트 크롤링 집계 점수 패널 */
|
||||||
export function AggregateScorePanel({
|
export function AggregateScorePanel({
|
||||||
aggregateScores,
|
aggregateScores,
|
||||||
rootUrl,
|
rootUrl,
|
||||||
@ -54,7 +54,7 @@ export function AggregateScorePanel({
|
|||||||
<div>
|
<div>
|
||||||
{/* 상단 URL 및 검사 요약 */}
|
{/* 상단 URL 및 검사 요약 */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h2 className="text-lg font-semibold mb-1">사이트 전체 검사 결과</h2>
|
<h2 className="text-lg font-semibold mb-1">사이트 크롤링 검사 결과</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
<a
|
<a
|
||||||
href={rootUrl}
|
href={rootUrl}
|
||||||
@ -113,7 +113,7 @@ export function AggregateScorePanel({
|
|||||||
<CardContent className="py-4">
|
<CardContent className="py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
사이트 전체 이슈
|
사이트 크롤링 이슈
|
||||||
</span>
|
</span>
|
||||||
<span className="text-lg font-bold">
|
<span className="text-lg font-bold">
|
||||||
총 {aggregateScores.total_issues}건
|
총 {aggregateScores.total_issues}건
|
||||||
|
|||||||
@ -71,7 +71,7 @@ export function PageTree({
|
|||||||
|
|
||||||
{/* 트리 본문 */}
|
{/* 트리 본문 */}
|
||||||
<div className="flex-1 overflow-y-auto py-1">
|
<div className="flex-1 overflow-y-auto py-1">
|
||||||
{/* 사이트 전체 (집계) 노드 */}
|
{/* 사이트 크롤링 (집계) 노드 */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-1.5 py-2 px-3 rounded-md cursor-pointer text-sm",
|
"flex items-center gap-1.5 py-2 px-3 rounded-md cursor-pointer text-sm",
|
||||||
@ -84,7 +84,7 @@ export function PageTree({
|
|||||||
>
|
>
|
||||||
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
|
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
<Globe className="h-4 w-4 flex-shrink-0 text-primary" />
|
<Globe className="h-4 w-4 flex-shrink-0 text-primary" />
|
||||||
<span className="font-medium truncate flex-1">사이트 전체</span>
|
<span className="font-medium truncate flex-1">사이트 크롤링</span>
|
||||||
{aggregateScores && (
|
{aggregateScores && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@ -25,7 +25,7 @@ interface SiteCrawlProgressProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 사이트 전체 검사 진행 상태 표시 컴포넌트.
|
* 사이트 크롤링 검사 진행 상태 표시 컴포넌트.
|
||||||
* 크롤링 단계와 검사 단계를 시각적으로 표현한다.
|
* 크롤링 단계와 검사 단계를 시각적으로 표현한다.
|
||||||
*/
|
*/
|
||||||
export function SiteCrawlProgress({
|
export function SiteCrawlProgress({
|
||||||
|
|||||||
123
frontend/src/hooks/useBatchInspectionSSE.ts
Normal file
123
frontend/src/hooks/useBatchInspectionSSE.ts
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useBatchInspectionStore } from "@/stores/useBatchInspectionStore";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type {
|
||||||
|
SSEBatchPageStart,
|
||||||
|
SSEBatchPageComplete,
|
||||||
|
SSEBatchAggregateUpdate,
|
||||||
|
SSEBatchComplete,
|
||||||
|
} from "@/types/batch-inspection";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSE를 통해 배치 검사 진행 상태를 수신하는 커스텀 훅.
|
||||||
|
* EventSource로 검사 진행 상태를 실시간 수신하고
|
||||||
|
* Zustand 스토어를 업데이트한다.
|
||||||
|
* 크롤링 단계 없이 page_start, page_complete, aggregate_update, complete, error만 리스닝.
|
||||||
|
*/
|
||||||
|
export function useBatchInspectionSSE(batchInspectionId: string | null) {
|
||||||
|
const {
|
||||||
|
updatePageStatus,
|
||||||
|
setPageComplete,
|
||||||
|
updateAggregateScores,
|
||||||
|
setCompleted,
|
||||||
|
setError,
|
||||||
|
} = useBatchInspectionStore();
|
||||||
|
const router = useRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const eventSourceRef = useRef<EventSource | null>(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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@ -15,7 +15,7 @@ import type {
|
|||||||
} from "@/types/site-inspection";
|
} from "@/types/site-inspection";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SSE를 통해 사이트 전체 검사 진행 상태를 수신하는 커스텀 훅.
|
* SSE를 통해 사이트 크롤링 검사 진행 상태를 수신하는 커스텀 훅.
|
||||||
* EventSource로 크롤링 + 검사 진행 상태를 실시간 수신하고
|
* EventSource로 크롤링 + 검사 진행 상태를 실시간 수신하고
|
||||||
* Zustand 스토어를 업데이트한다.
|
* Zustand 스토어를 업데이트한다.
|
||||||
*/
|
*/
|
||||||
@ -122,7 +122,7 @@ export function useSiteInspectionSSE(siteInspectionId: string | null) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// SSE 연결 타임아웃 (10분 - 사이트 전체 검사는 시간이 더 소요됨)
|
// SSE 연결 타임아웃 (10분 - 사이트 크롤링 검사는 시간이 더 소요됨)
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
setError("사이트 검사 시간이 초과되었습니다 (10분)");
|
setError("사이트 검사 시간이 초과되었습니다 (10분)");
|
||||||
|
|||||||
@ -10,8 +10,14 @@ import type {
|
|||||||
import type {
|
import type {
|
||||||
StartSiteInspectionResponse,
|
StartSiteInspectionResponse,
|
||||||
SiteInspectionResult,
|
SiteInspectionResult,
|
||||||
|
SiteInspectionPaginatedResponse,
|
||||||
InspectPageResponse,
|
InspectPageResponse,
|
||||||
} from "@/types/site-inspection";
|
} from "@/types/site-inspection";
|
||||||
|
import type {
|
||||||
|
StartBatchInspectionResponse,
|
||||||
|
BatchInspectionResult,
|
||||||
|
BatchInspectionPaginatedResponse,
|
||||||
|
} from "@/types/batch-inspection";
|
||||||
|
|
||||||
const API_BASE_URL =
|
const API_BASE_URL =
|
||||||
process.env.NEXT_PUBLIC_API_URL ?? "";
|
process.env.NEXT_PUBLIC_API_URL ?? "";
|
||||||
@ -150,10 +156,10 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────
|
||||||
// 사이트 전체 검사 API
|
// 사이트 크롤링 검사 API
|
||||||
// ─────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** 사이트 전체 검사 시작 */
|
/** 사이트 크롤링 검사 시작 */
|
||||||
async startSiteInspection(
|
async startSiteInspection(
|
||||||
url: string,
|
url: string,
|
||||||
maxPages?: number,
|
maxPages?: number,
|
||||||
@ -194,6 +200,76 @@ class ApiClient {
|
|||||||
getSiteStreamUrl(siteInspectionId: string): string {
|
getSiteStreamUrl(siteInspectionId: string): string {
|
||||||
return `${this.baseUrl}/api/site-inspections/${siteInspectionId}/stream`;
|
return `${this.baseUrl}/api/site-inspections/${siteInspectionId}/stream`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 사이트 검사 이력 목록 */
|
||||||
|
async getSiteInspections(params: {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<SiteInspectionPaginatedResponse> {
|
||||||
|
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<StartBatchInspectionResponse> {
|
||||||
|
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<BatchInspectionResult> {
|
||||||
|
return this.request(`/api/batch-inspections/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 배치 검사 이력 목록 */
|
||||||
|
async getBatchInspections(params: {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
name?: string;
|
||||||
|
}): Promise<BatchInspectionPaginatedResponse> {
|
||||||
|
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);
|
export const api = new ApiClient(API_BASE_URL);
|
||||||
|
|||||||
@ -56,7 +56,7 @@ export function useRecentInspections() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 사이트 전체 검사 결과 조회 */
|
/** 사이트 크롤링 검사 결과 조회 */
|
||||||
export function useSiteInspectionResult(siteInspectionId: string | null) {
|
export function useSiteInspectionResult(siteInspectionId: string | null) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["siteInspection", siteInspectionId],
|
queryKey: ["siteInspection", siteInspectionId],
|
||||||
@ -65,3 +65,49 @@ export function useSiteInspectionResult(siteInspectionId: string | null) {
|
|||||||
staleTime: 5 * 60 * 1000,
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
143
frontend/src/stores/useBatchInspectionStore.ts
Normal file
143
frontend/src/stores/useBatchInspectionStore.ts
Normal file
@ -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<BatchInspectionState>(
|
||||||
|
(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 }),
|
||||||
|
})
|
||||||
|
);
|
||||||
117
frontend/src/types/batch-inspection.ts
Normal file
117
frontend/src/types/batch-inspection.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
@ -30,7 +30,7 @@ export interface DiscoveredPage {
|
|||||||
grade: string | null;
|
grade: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 사이트 전체 집계 점수 */
|
/** 사이트 크롤링 집계 점수 */
|
||||||
export interface AggregateScores {
|
export interface AggregateScores {
|
||||||
overall_score: number;
|
overall_score: number;
|
||||||
grade: string;
|
grade: string;
|
||||||
@ -75,6 +75,28 @@ export interface InspectPageResponse {
|
|||||||
inspection_id: string;
|
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 이벤트 타입
|
// SSE 이벤트 타입
|
||||||
// ───────────────────────────────────────────────────────
|
// ───────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -25,8 +25,8 @@ server {
|
|||||||
proxy_send_timeout 120s;
|
proxy_send_timeout 120s;
|
||||||
}
|
}
|
||||||
|
|
||||||
# SSE 스트리밍 전용 (버퍼링 OFF)
|
# SSE 스트리밍 전용 (모든 검사 타입: 단일/사이트/배치)
|
||||||
location ~ ^/api/inspections/[^/]+/stream$ {
|
location ~ ^/api/(inspections|site-inspections|batch-inspections)/[^/]+/stream$ {
|
||||||
proxy_pass http://backend;
|
proxy_pass http://backend;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@ -36,7 +36,7 @@ server {
|
|||||||
|
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
proxy_cache off;
|
proxy_cache off;
|
||||||
proxy_read_timeout 300s;
|
proxy_read_timeout 600s;
|
||||||
chunked_transfer_encoding off;
|
chunked_transfer_encoding off;
|
||||||
|
|
||||||
# SSE content type
|
# SSE content type
|
||||||
|
|||||||
Reference in New Issue
Block a user