diff --git a/.github/scripts/detect-changes.sh b/.github/scripts/detect-changes.sh index 4823b587..08dce1e2 100644 --- a/.github/scripts/detect-changes.sh +++ b/.github/scripts/detect-changes.sh @@ -8,60 +8,65 @@ set -e BASE_BRANCH="${1:-origin/main}" CHANGED_FILES=$(git diff --name-only "$BASE_BRANCH"...HEAD) -echo "Changed files:" +echo "๐Ÿ“‚ Changed files list:" echo "$CHANGED_FILES" -echo "" +echo "---------------------------------------" # ๋ณ€๊ฒฝ๋œ ์„œ๋น„์Šค ์ถ”์  CHANGED_SERVICES=() -# ๊ฐ ์„œ๋น„์Šค๋ณ„ ๋ณ€๊ฒฝ ๊ฐ์ง€ -check_service_change() { - local service_name=$1 - local service_path=$2 +# ์ „์—ญ ํŒŒ์ผ(Lockfile, Workflow ๋“ฑ)์ด ๋ฐ”๋€Œ๋ฉด ์ „์ฒด ์„œ๋น„์Šค ๋นŒ๋“œ +GLOBAL_FILES="pnpm-lock.yaml|pnpm-workspace.yaml|package.json|.github/workflows/ci.yml" +if echo "$CHANGED_FILES" | grep -E -q "$GLOBAL_FILES"; then + echo "๐Ÿšจ Global config changed! Triggering CI for all services." + CHANGED_SERVICES=("frontend" "api-server" "ticket-server" "queue-backend") +else - if echo "$CHANGED_FILES" | grep -q "^${service_path}/"; then - CHANGED_SERVICES+=("$service_name") - return 0 - fi - return 1 -} - -# ๊ณตํ†ต ํŒจํ‚ค์ง€ ๋ณ€๊ฒฝ ๊ฐ์ง€ ๋ฐ ์˜์กด ์„œ๋น„์Šค ์ถ”๊ฐ€ -check_package_dependencies() { - local package_name=$1 - shift - local dependent_services=("$@") - - if echo "$CHANGED_FILES" | grep -q "^packages/${package_name}/"; then - echo "Package ${package_name} changed, adding dependent services..." - for service in "${dependent_services[@]}"; do - if [[ ! " ${CHANGED_SERVICES[@]} " =~ " ${service} " ]]; then - CHANGED_SERVICES+=("$service") - fi - done - fi -} + # ๊ฐ ์„œ๋น„์Šค๋ณ„ ๋ณ€๊ฒฝ ๊ฐ์ง€ + check_service_change() { + local service_name=$1 + local service_path=$2 + + if echo "$CHANGED_FILES" | grep -q "^${service_path}/"; then + CHANGED_SERVICES+=("$service_name") + fi + } -# ์„œ๋น„์Šค๋ณ„ ๋ณ€๊ฒฝ ๊ฐ์ง€ -check_service_change "frontend" "frontend" || true -check_service_change "api-server" "backend/api-server" || true -check_service_change "ticket-server" "backend/ticket-server" || true -check_service_change "queue-backend" "queue-backend" || true + # ๊ณตํ†ต ํŒจํ‚ค์ง€ ๋ณ€๊ฒฝ ๊ฐ์ง€ ๋ฐ ์˜์กด ์„œ๋น„์Šค ์ถ”๊ฐ€ + check_package_dependencies() { + local package_name=$1 + shift + local dependent_services=("$@") -# ๊ณตํ†ต ํŒจํ‚ค์ง€ ๋ณ€๊ฒฝ ์‹œ ์˜์กด ์„œ๋น„์Šค ์ถ”๊ฐ€ -check_package_dependencies "shared-types" "api-server" "ticket-server" -check_package_dependencies "backend-config" "queue-backend" -check_package_dependencies "shared-constants" "queue-backend" + if echo "$CHANGED_FILES" | grep -q "^packages/${package_name}/"; then + echo "๐Ÿ“ฆ Package ${package_name} changed, adding dependent services..." + for service in "${dependent_services[@]}"; do + if [[ ! " ${CHANGED_SERVICES[@]} " =~ " ${service} " ]]; then + CHANGED_SERVICES+=("$service") + fi + done + fi + } + + # ์„œ๋น„์Šค๋ณ„ ๋ณ€๊ฒฝ ๊ฐ์ง€ + check_service_change "frontend" "frontend" || true + check_service_change "api-server" "backend/api-server" || true + check_service_change "ticket-server" "backend/ticket-server" || true + check_service_change "queue-backend" "queue-backend" || true + + # ๊ณตํ†ต ํŒจํ‚ค์ง€ ๋ณ€๊ฒฝ ์‹œ ์˜์กด ์„œ๋น„์Šค ์ถ”๊ฐ€ + check_package_dependencies "shared-types" "api-server" "ticket-server" "queue-backend" + check_package_dependencies "backend-config" "ticket-server" "queue-backend" + check_package_dependencies "shared-constants" "ticket-server" "queue-backend" +fi # ๊ฒฐ๊ณผ ์ถœ๋ ฅ if [ ${#CHANGED_SERVICES[@]} -eq 0 ]; then - echo "No services changed" + echo "โœ… No services changed. Skipping CI." echo "changed_services=[]" >> $GITHUB_OUTPUT echo "has_changes=false" >> $GITHUB_OUTPUT else - echo "Changed services:" - printf '%s\n' "${CHANGED_SERVICES[@]}" + echo "๐Ÿš€ Changed services: ${CHANGED_SERVICES[*]}" # JSON ๋ฐฐ์—ด ์ƒ์„ฑ (jq ์—†์ด) SERVICES_JSON="[" @@ -82,9 +87,10 @@ fi # ๊ฐœ๋ณ„ ์„œ๋น„์Šค ํ”Œ๋ž˜๊ทธ ์„ค์ • (๋ณ€๊ฒฝ ์—ฌ๋ถ€์™€ ๊ด€๊ณ„์—†์ด ํ•ญ์ƒ ์„ค์ •) for service in "frontend" "api-server" "ticket-server" "queue-backend"; do + VAR_NAME="${service//-/_}_changed" if [[ " ${CHANGED_SERVICES[@]} " =~ " ${service} " ]]; then - echo "${service//-/_}_changed=true" >> $GITHUB_OUTPUT + echo "${VAR_NAME}=true" >> "$GITHUB_OUTPUT" else - echo "${service//-/_}_changed=false" >> $GITHUB_OUTPUT + echo "${VAR_NAME}=false" >> "$GITHUB_OUTPUT" fi done diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d226c384..58a5f578 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,289 +2,146 @@ name: CI - Pull Request on: pull_request: - branches: - - main - - dev + branches: [main, dev] push: - branches: - - main - - dev + branches: [main, dev] jobs: detect-changes: - name: Detect Changed Services + name: ๐Ÿ” Detect Changed Services runs-on: ubuntu-latest outputs: has_changes: ${{ steps.detect.outputs.has_changes }} - changed_services: ${{ steps.detect.outputs.changed_services }} + backend_matrix: ${{ steps.filter.outputs.matrix }} frontend_changed: ${{ steps.detect.outputs.frontend_changed }} - api_server_changed: ${{ steps.detect.outputs.api_server_changed }} - ticket_server_changed: ${{ steps.detect.outputs.ticket_server_changed }} - queue_backend_changed: ${{ steps.detect.outputs.queue_backend_changed }} + changed_services: ${{ steps.detect.outputs.changed_services }} steps: - - name: Checkout + - name: โฌ‡๏ธ Checkout Repository uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Detect changes + - name: ๐Ÿ•ต๏ธ Run Change Detection Script id: detect run: | chmod +x .github/scripts/detect-changes.sh - # PR์˜ base ๋ธŒ๋žœ์น˜์™€ ๋น„๊ต (feature->dev๋Š” dev์™€, dev->main์€ main๊ณผ ๋น„๊ต) - BASE_BRANCH="origin/${{ github.base_ref }}" - echo "Comparing with base branch: $BASE_BRANCH" - .github/scripts/detect-changes.sh "$BASE_BRANCH" + BASE="${{ github.base_ref || github.ref_name }}" + .github/scripts/detect-changes.sh "origin/$BASE" + + - name: ๐Ÿงช Filter Backend Matrix + id: filter + run: | + # ๊ฐ์ง€๋œ ์ „์ฒด ์„œ๋น„์Šค ๋ชฉ๋ก ๊ฐ€์ ธ์˜ค๊ธฐ + SERVICES='${{ steps.detect.outputs.changed_services }}' + + # ๋ฐฑ์—”๋“œ๋งŒ ์†Ž์•„๋‚ด๊ธฐ (sed) + BACKEND=$(echo $SERVICES | sed 's/"frontend",//g; s/,"frontend"//g; s/\["frontend"\]/[]/g') + + echo "matrix=$BACKEND" >> $GITHUB_OUTPUT + + - name: ๐Ÿ“ข Detection Result Summary + run: | + echo "### ๐Ÿ” Detection Result" >> $GITHUB_STEP_SUMMARY + echo "- **Has Changes:** ${{ steps.detect.outputs.has_changes }}" >> $GITHUB_STEP_SUMMARY + echo "- **Frontend Changed:** ${{ steps.detect.outputs.frontend_changed }}" >> $GITHUB_STEP_SUMMARY + echo "- **Backend Matrix:** \`${{ steps.filter.outputs.matrix }}\`" >> $GITHUB_STEP_SUMMARY # Frontend CI ci-frontend: - name: CI - Frontend + name: ๐ŸŽจ CI - Frontend needs: detect-changes if: needs.detect-changes.outputs.frontend_changed == 'true' runs-on: ubuntu-latest steps: - - name: Checkout + - name: โฌ‡๏ธ Checkout code uses: actions/checkout@v4 - - name: Setup pnpm + - name: ๐Ÿ“ฆ Setup pnpm uses: pnpm/action-setup@v4 - with: - version: latest + with: { version: 10.28.1 } - - name: Setup Node.js + - name: ๐ŸŸข Setup Node.js uses: actions/setup-node@v4 - with: - node-version: '22' - cache: 'pnpm' + with: { node-version: "22", cache: "pnpm" } - - name: Install dependencies + - name: ๐Ÿ“ฅ Install dependencies run: pnpm install --frozen-lockfile - - name: Lint - run: pnpm --filter frontend lint - - - name: Build - run: pnpm --filter frontend build - - - name: Set up Docker Buildx + - name: ๐Ÿณ Set up Docker Buildx uses: docker/setup-buildx-action@v3 - # dev -> main PR์ธ ๊ฒฝ์šฐ์—๋งŒ NCP Registry์— ๋กœ๊ทธ์ธ - - name: Login to NCP Container Registry - if: github.base_ref == 'main' && github.event_name == 'pull_request' - uses: docker/login-action@v3 - with: - registry: ${{ secrets.NCP_REGISTRY_URL }} - username: ${{ secrets.NCP_REGISTRY_USERNAME }} - password: ${{ secrets.NCP_REGISTRY_PASSWORD }} + - name: ๐Ÿงน Run Lint + run: pnpm --filter frontend lint - # dev -> main PR์ธ ๊ฒฝ์šฐ: ์ด๋ฏธ์ง€ ๋นŒ๋“œ ๋ฐ ํ‘ธ์‹œ - - name: Build and push Docker image (dev -> main) - if: github.base_ref == 'main' && github.event_name == 'pull_request' - uses: docker/build-push-action@v5 - with: - context: . - file: frontend/Dockerfile - push: true - tags: | - ${{ secrets.NCP_REGISTRY_URL }}/beastcamp-frontend:${{ github.sha }} - ${{ secrets.NCP_REGISTRY_URL }}/beastcamp-frontend:latest - build-args: | - NEXT_PUBLIC_API_URL=http://localhost/api - NEXT_PUBLIC_API_MODE=mock - cache-from: type=registry,ref=${{ secrets.NCP_REGISTRY_URL }}/beastcamp-frontend:buildcache - cache-to: type=registry,ref=${{ secrets.NCP_REGISTRY_URL }}/beastcamp-frontend:buildcache,mode=max + - name: ๐Ÿ—๏ธ Build Application (Next.js) + run: pnpm --filter frontend build + # Next.js ๋นŒ๋“œ ์‹œ ํ•„์š”ํ•œ ํ™˜๊ฒฝ๋ณ€์ˆ˜๊ฐ€ ์žˆ๋‹ค๋ฉด ์—ฌ๊ธฐ์— ์ถ”๊ฐ€ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. + # env: + # NEXT_PUBLIC_API_URL: ${{ secrets.API_URL }} - # feature -> dev PR์ธ ๊ฒฝ์šฐ: ์ด๋ฏธ์ง€ ๋นŒ๋“œ๋งŒ (ํ‘ธ์‹œ ์•ˆ ํ•จ) - - name: Build Docker image (feature -> dev) - if: github.base_ref != 'main' || github.event_name != 'pull_request' + - name: ๐Ÿณ Docker Build Check uses: docker/build-push-action@v5 with: context: . file: frontend/Dockerfile - push: false - tags: beastcamp-frontend:ci-${{ github.sha }} - build-args: | - NEXT_PUBLIC_API_URL=http://localhost/api - NEXT_PUBLIC_API_MODE=mock - cache-from: type=gha - cache-to: type=gha,mode=max - - # API Server CI - ci-api-server: - name: CI - API Server - needs: detect-changes - if: needs.detect-changes.outputs.api_server_changed == 'true' - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: latest - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '22' - cache: 'pnpm' - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Lint - run: pnpm --filter @beastcamp/api-server lint - - - name: Test - run: pnpm --filter @beastcamp/api-server test - - - name: Build - run: pnpm --filter @beastcamp/api-server build - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build Docker image - uses: docker/build-push-action@v5 - with: - context: . - file: backend/api-server/Dockerfile - push: false - tags: beastcamp-api-server:ci-${{ github.sha }} + push: false # CI๋Š” ๊ฒ€์‚ฌ๋งŒ ํ•˜๋ฏ€๋กœ ํ‘ธ์‹œ๋Š” ํ•˜์ง€ ์•Š์Œ! cache-from: type=gha cache-to: type=gha,mode=max - # Ticket Server CI - ci-ticket-server: - name: CI - Ticket Server + ci-backend: + name: โš™๏ธ CI - Backend needs: detect-changes - if: needs.detect-changes.outputs.ticket_server_changed == 'true' + if: needs.detect-changes.outputs.has_changes == 'true' && needs.detect-changes.outputs.backend_matrix != '[]' runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + service: ${{ fromJson(needs.detect-changes.outputs.backend_matrix) }} steps: - - name: Checkout + - name: โฌ‡๏ธ Checkout code uses: actions/checkout@v4 - - name: Setup pnpm + - name: ๐Ÿ“ฆ Setup pnpm uses: pnpm/action-setup@v4 - with: - version: latest + with: { version: 10.28.1 } - - name: Setup Node.js + - name: ๐ŸŸข Setup Node.js uses: actions/setup-node@v4 - with: - node-version: '22' - cache: 'pnpm' + with: { node-version: "22", cache: "pnpm" } - - name: Install dependencies + - name: ๐Ÿ“ฅ Install dependencies run: pnpm install --frozen-lockfile - - name: Lint - run: pnpm --filter @beastcamp/ticket-server lint - - - name: Test - run: pnpm --filter @beastcamp/ticket-server test - - - name: Build - run: pnpm --filter @beastcamp/ticket-server build - - - name: Set up Docker Buildx + - name: ๐Ÿณ Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Build Docker image - uses: docker/build-push-action@v5 - with: - context: . - file: backend/ticket-server/Dockerfile - push: false - tags: beastcamp-ticket-server:ci-${{ github.sha }} - cache-from: type=gha - cache-to: type=gha,mode=max - - # Queue Backend CI - ci-queue-backend: - name: CI - Queue Backend - needs: detect-changes - if: needs.detect-changes.outputs.queue_backend_changed == 'true' - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: latest - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '22' - cache: 'pnpm' - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Lint - run: pnpm --filter @beastcamp/queue-backend lint - - - name: Test - run: pnpm --filter @beastcamp/queue-backend test + - name: ๐Ÿงน Run Lint + run: pnpm --filter @beastcamp/${{ matrix.service }} lint - - name: Build - run: pnpm --filter @beastcamp/queue-backend build - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + - name: ๐Ÿงช Run Unit Tests + run: pnpm --filter @beastcamp/${{ matrix.service }} test - - name: Build Docker image + - name: ๐Ÿณ Docker Build Check uses: docker/build-push-action@v5 with: context: . - file: queue-backend/Dockerfile + file: ${{ matrix.service == 'queue-backend' && 'queue-backend/Dockerfile' || format('backend/{0}/Dockerfile', matrix.service) }} push: false - tags: beastcamp-queue-backend:ci-${{ github.sha }} cache-from: type=gha cache-to: type=gha,mode=max - # CI Summary ci-summary: - name: CI Summary - needs: - [ - detect-changes, - ci-frontend, - ci-api-server, - ci-ticket-server, - ci-queue-backend, - ] + name: ๐Ÿ“Š CI Result Summary + needs: [detect-changes, ci-frontend, ci-backend] if: always() runs-on: ubuntu-latest steps: - - name: Check CI Results + - name: ๐Ÿ Final Status Check run: | - echo "=== CI Summary ===" - echo "Changed services: ${{ needs.detect-changes.outputs.changed_services }}" - - if [ "${{ needs.detect-changes.outputs.has_changes }}" == "false" ]; then - echo "No services changed - skipping CI" - exit 0 - fi - - echo "" - echo "CI Results:" - echo "- Frontend: ${{ needs.ci-frontend.result }}" - echo "- API Server: ${{ needs.ci-api-server.result }}" - echo "- Ticket Server: ${{ needs.ci-ticket-server.result }}" - echo "- Queue Backend: ${{ needs.ci-queue-backend.result }}" - - # Check if any CI job failed - if [ "${{ contains(needs.*.result, 'failure') }}" == "true" ]; then - echo "" - echo "โŒ Some CI jobs failed" + F_ST="${{ needs.ci-frontend.result }}" + B_ST="${{ needs.ci-backend.result }}" + if [[ "$F_ST" == "failure" || "$B_ST" == "failure" ]]; then + echo "โŒ CI Failed: Please check your code" exit 1 fi - - echo "" - echo "โœ… All CI jobs passed" + echo "โœ… CI Passed: Deployment ready" diff --git a/backend/api-server/src/app.module.ts b/backend/api-server/src/app.module.ts index 02f7b352..d741332f 100644 --- a/backend/api-server/src/app.module.ts +++ b/backend/api-server/src/app.module.ts @@ -5,6 +5,7 @@ import { ServeStaticModule } from '@nestjs/serve-static'; import { join } from 'path'; import { VenuesModule } from './venues/venues.module'; import { PerformancesModule } from './performances/performances.module'; +import { CongestionModule } from './congestion/congestion.module'; @Module({ imports: [ @@ -45,6 +46,7 @@ import { PerformancesModule } from './performances/performances.module'; }), VenuesModule, PerformancesModule, + CongestionModule, ], controllers: [], providers: [], diff --git a/backend/api-server/src/congestion/congestion.controller.ts b/backend/api-server/src/congestion/congestion.controller.ts new file mode 100644 index 00000000..ab28acdf --- /dev/null +++ b/backend/api-server/src/congestion/congestion.controller.ts @@ -0,0 +1,25 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { CongestionService } from './congestion.service'; +import { CongestionResponseDto } from './dto/congestion-response.dto'; + +@ApiTags('๊ฒฝ์Ÿ ๊ฐ•๋„') +@Controller('api/congestion') +export class CongestionController { + constructor(private readonly congestionService: CongestionService) {} + + @Get() + @ApiOperation({ + summary: '์˜ˆ๋งค ๊ฒฝ์Ÿ ๊ฐ•๋„ ์กฐํšŒ', + description: + 'ํ‹ฐ์ผ“ํŒ… ์‚ฌ์ดํŠธ๋ณ„ ์‘๋‹ต ์ง€์—ฐ, ์—๋Ÿฌ์œจ ๋“ฑ์„ ์ธก์ •ํ•˜์—ฌ ๊ฒฝ์Ÿ ๊ฐ•๋„๋ฅผ ์ถ”์ •ํ•ฉ๋‹ˆ๋‹ค.', + }) + @ApiResponse({ + status: 200, + description: '๊ฒฝ์Ÿ ๊ฐ•๋„ ๋ฐ์ดํ„ฐ ์กฐํšŒ ์„ฑ๊ณต', + type: CongestionResponseDto, + }) + async getCongestionData(): Promise { + return this.congestionService.getCongestionData(); + } +} diff --git a/backend/api-server/src/congestion/congestion.module.ts b/backend/api-server/src/congestion/congestion.module.ts new file mode 100644 index 00000000..6240bb8a --- /dev/null +++ b/backend/api-server/src/congestion/congestion.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { CongestionController } from './congestion.controller'; +import { CongestionService } from './congestion.service'; + +@Module({ + controllers: [CongestionController], + providers: [CongestionService], + exports: [CongestionService], +}) +export class CongestionModule {} diff --git a/backend/api-server/src/congestion/congestion.service.ts b/backend/api-server/src/congestion/congestion.service.ts new file mode 100644 index 00000000..07288d71 --- /dev/null +++ b/backend/api-server/src/congestion/congestion.service.ts @@ -0,0 +1,235 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { + CongestionResponseDto, + SiteCongestionDto, + CongestionDataPointDto, + CongestionLevel, + SiteMetricsDto, +} from './dto/congestion-response.dto'; + +interface SiteConfig { + site: string; + displayName: string; + color: string; + backgroundColor: string; + borderColor: string; + textColor: string; + url: string; +} + +const SITE_CONFIGS: SiteConfig[] = [ + { + site: 'INTERPARK', + displayName: '์ธํ„ฐํŒŒํฌ', + color: '#8b5cf6', + backgroundColor: 'bg-purple-50', + borderColor: 'border-purple-100', + textColor: 'text-purple-600', + url: 'https://ticket.interpark.com', + }, + { + site: 'YES24', + displayName: 'YES24', + color: '#3b82f6', + backgroundColor: 'bg-blue-50', + borderColor: 'border-blue-100', + textColor: 'text-blue-600', + url: 'https://ticket.yes24.com', + }, + { + site: 'MELON_TICKET', + displayName: '๋ฉœ๋ก ํ‹ฐ์ผ“', + color: '#10b981', + backgroundColor: 'bg-green-50', + borderColor: 'border-green-100', + textColor: 'text-green-600', + url: 'https://ticket.melon.com', + }, +]; + +@Injectable() +export class CongestionService { + private readonly logger = new Logger(CongestionService.name); + + /** + * ๊ฒฝ์Ÿ ๊ฐ•๋„ ์ ์ˆ˜๋ฅผ ๋ ˆ๋ฒจ๋กœ ๋ณ€ํ™˜ + */ + private getCongestionLevel(score: number): CongestionLevel { + if (score >= 75) return 'EXTREME'; + if (score >= 50) return 'HIGH'; + if (score >= 25) return 'MEDIUM'; + return 'LOW'; + } + + /** + * ์‚ฌ์ดํŠธ์˜ ์‘๋‹ต ์‹œ๊ฐ„ ์ธก์ • + */ + private async measureResponseTime(url: string): Promise { + const startTime = Date.now(); + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); // 5์ดˆ ํƒ€์ž„์•„์›ƒ + + await fetch(url, { + method: 'GET', + signal: controller.signal, + headers: { + 'User-Agent': 'Mozilla/5.0 (compatible; BeastCamp/1.0)', + }, + }); + + clearTimeout(timeout); + return Date.now() - startTime; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + this.logger.warn(`Failed to measure ${url}: ${errorMessage}`); + return 5000; // ํƒ€์ž„์•„์›ƒ ์‹œ ์ตœ๋Œ€๊ฐ’ ๋ฐ˜ํ™˜ + } + } + + /** + * ์‚ฌ์ดํŠธ ๋ฉ”ํŠธ๋ฆญ ์ธก์ • + */ + private async measureSiteMetrics(url: string): Promise { + const measurements: number[] = []; + let timeouts = 0; + let errors = 0; + const totalAttempts = 5; + + for (let i = 0; i < totalAttempts; i++) { + try { + const responseTime = await this.measureResponseTime(url); + if (responseTime >= 5000) { + timeouts++; + } else { + measurements.push(responseTime); + } + } catch { + errors++; + } + } + + const avgResponseTime = + measurements.length > 0 + ? measurements.reduce((a, b) => a + b, 0) / measurements.length + : 5000; + + return { + avgResponseTime: Math.round(avgResponseTime), + timeoutRate: timeouts / totalAttempts, + errorRate: errors / totalAttempts, + queueDetected: avgResponseTime > 3000, // 3์ดˆ ์ด์ƒ์ด๋ฉด ๋Œ€๊ธฐ์—ด ์˜์‹ฌ + }; + } + + /** + * ๋ฉ”ํŠธ๋ฆญ์„ ๊ฒฝ์Ÿ ๊ฐ•๋„ ์ ์ˆ˜๋กœ ๋ณ€ํ™˜ + */ + private calculateCongestionScore(metrics: SiteMetricsDto): number { + // ๊ฐ€์ค‘์น˜ + const weights = { + responseTime: 0.4, + timeout: 0.3, + error: 0.2, + queue: 0.1, + }; + + // ์‘๋‹ต ์‹œ๊ฐ„ ์ ์ˆ˜ (0-100, 5์ดˆ ์ด์ƒ์€ 100์ ) + const responseTimeScore = Math.min( + (metrics.avgResponseTime / 5000) * 100, + 100, + ); + + // ํƒ€์ž„์•„์›ƒ ์ ์ˆ˜ + const timeoutScore = metrics.timeoutRate * 100; + + // ์—๋Ÿฌ ์ ์ˆ˜ + const errorScore = metrics.errorRate * 100; + + // ๋Œ€๊ธฐ์—ด ์ ์ˆ˜ + const queueScore = metrics.queueDetected ? 100 : 0; + + const totalScore = + responseTimeScore * weights.responseTime + + timeoutScore * weights.timeout + + errorScore * weights.error + + queueScore * weights.queue; + + return Math.round(Math.min(totalScore, 100)); + } + + /** + * ์ตœ๊ทผ 24์‹œ๊ฐ„ ๋ฐ์ดํ„ฐ ์ƒ์„ฑ (์‹œ๋ฎฌ๋ ˆ์ด์…˜) + * TODO: ์‹ค์ œ ํ™˜๊ฒฝ์—์„œ๋Š” DB์— ์ €์žฅ๋œ ๊ณผ๊ฑฐ ์ธก์ • ๋ฐ์ดํ„ฐ ์‚ฌ์šฉ + */ + private generateHistoricalData( + currentScore: number, + ): CongestionDataPointDto[] { + const data: CongestionDataPointDto[] = []; + const now = new Date(); + + for (let i = 23; i >= 0; i--) { + const time = new Date(now.getTime() - i * 60 * 60 * 1000); + const hour = time.getHours(); + + // ํ‹ฐ์ผ“ํŒ… ์˜คํ”ˆ ์‹œ๊ฐ„(10์‹œ) ๊ธฐ์ค€ ํŒจํ„ด + ํ˜„์žฌ ์ ์ˆ˜ ๋ฐ˜์˜ + let baseScore = currentScore * 0.3; // ํ˜„์žฌ ์ ์ˆ˜์˜ 30%๋ฅผ ๊ธฐ๋ณธ๊ฐ’์œผ๋กœ + if (hour >= 9 && hour <= 11) { + baseScore += 50 + Math.random() * 30; // ์˜คํ”ˆ ์‹œ๊ฐ„ ๊ธ‰์ฆ + } else if (hour >= 8 && hour <= 12) { + baseScore += 20 + Math.random() * 20; + } else { + baseScore += Math.random() * 20; + } + + data.push({ + timestamp: time.toLocaleTimeString('ko-KR', { + hour: '2-digit', + minute: '2-digit', + }), + congestionScore: Math.round(Math.min(100, Math.max(0, baseScore))), + }); + } + + return data; + } + + /** + * ๋ชจ๋“  ์‚ฌ์ดํŠธ์˜ ๊ฒฝ์Ÿ ๊ฐ•๋„ ์กฐํšŒ + */ + async getCongestionData(): Promise { + this.logger.log('Fetching congestion data for all sites'); + + const sitesData: SiteCongestionDto[] = await Promise.all( + SITE_CONFIGS.map(async (config) => { + // ์‚ฌ์ดํŠธ ๋ฉ”ํŠธ๋ฆญ ์ธก์ • + const metrics = await this.measureSiteMetrics(config.url); + + // ๊ฒฝ์Ÿ ๊ฐ•๋„ ์ ์ˆ˜ ๊ณ„์‚ฐ + const currentScore = this.calculateCongestionScore(metrics); + + // ๊ณผ๊ฑฐ ๋ฐ์ดํ„ฐ ์ƒ์„ฑ + const historicalData = this.generateHistoricalData(currentScore); + + return { + site: config.site, + displayName: config.displayName, + color: config.color, + backgroundColor: config.backgroundColor, + borderColor: config.borderColor, + textColor: config.textColor, + data: historicalData, + currentCongestionScore: currentScore, + currentLevel: this.getCongestionLevel(currentScore), + metrics, + }; + }), + ); + + return { + sites: sitesData, + lastUpdated: new Date().toISOString(), + }; + } +} diff --git a/backend/api-server/src/congestion/dto/congestion-response.dto.ts b/backend/api-server/src/congestion/dto/congestion-response.dto.ts new file mode 100644 index 00000000..92b197b3 --- /dev/null +++ b/backend/api-server/src/congestion/dto/congestion-response.dto.ts @@ -0,0 +1,77 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export type CongestionLevel = 'LOW' | 'MEDIUM' | 'HIGH' | 'EXTREME'; + +export class CongestionDataPointDto { + @ApiProperty({ description: '์‹œ๊ฐ„ (HH:MM)', example: '10:00' }) + timestamp: string; + + @ApiProperty({ description: '๊ฒฝ์Ÿ ๊ฐ•๋„ ์ ์ˆ˜ (0-100)', example: 75 }) + congestionScore: number; +} + +export class SiteMetricsDto { + @ApiProperty({ description: 'ํ‰๊ท  ์‘๋‹ต ์‹œ๊ฐ„ (ms)', example: 1250 }) + avgResponseTime: number; + + @ApiProperty({ description: 'ํƒ€์ž„์•„์›ƒ ๋น„์œจ (0-1)', example: 0.12 }) + timeoutRate: number; + + @ApiProperty({ description: '์—๋Ÿฌ ๋น„์œจ (0-1)', example: 0.08 }) + errorRate: number; + + @ApiProperty({ description: '๋Œ€๊ธฐ์—ด ๊ฐ์ง€ ์—ฌ๋ถ€', example: true }) + queueDetected: boolean; +} + +export class SiteCongestionDto { + @ApiProperty({ description: '์‚ฌ์ดํŠธ ํ‚ค', example: 'INTERPARK' }) + site: string; + + @ApiProperty({ description: '์‚ฌ์ดํŠธ ํ‘œ์‹œ๋ช…', example: '์ธํ„ฐํŒŒํฌ' }) + displayName: string; + + @ApiProperty({ description: '์ฐจํŠธ ์ƒ‰์ƒ', example: '#8b5cf6' }) + color: string; + + @ApiProperty({ description: '๋ฐฐ๊ฒฝ ์ƒ‰์ƒ ํด๋ž˜์Šค', example: 'bg-purple-50' }) + backgroundColor: string; + + @ApiProperty({ + description: 'ํ…Œ๋‘๋ฆฌ ์ƒ‰์ƒ ํด๋ž˜์Šค', + example: 'border-purple-100', + }) + borderColor: string; + + @ApiProperty({ + description: 'ํ…์ŠคํŠธ ์ƒ‰์ƒ ํด๋ž˜์Šค', + example: 'text-purple-600', + }) + textColor: string; + + @ApiProperty({ description: '์‹œ๊ณ„์—ด ๋ฐ์ดํ„ฐ', type: [CongestionDataPointDto] }) + data: CongestionDataPointDto[]; + + @ApiProperty({ description: 'ํ˜„์žฌ ๊ฒฝ์Ÿ ๊ฐ•๋„ ์ ์ˆ˜', example: 72 }) + currentCongestionScore: number; + + @ApiProperty({ description: 'ํ˜„์žฌ ๊ฒฝ์Ÿ ๊ฐ•๋„ ๋ ˆ๋ฒจ', example: 'HIGH' }) + currentLevel: CongestionLevel; + + @ApiProperty({ description: '์ธก์ • ์ง€ํ‘œ (์„ ํƒ)', required: false }) + metrics?: SiteMetricsDto; +} + +export class CongestionResponseDto { + @ApiProperty({ + description: '์‚ฌ์ดํŠธ๋ณ„ ๊ฒฝ์Ÿ ๊ฐ•๋„ ๋ฐ์ดํ„ฐ', + type: [SiteCongestionDto], + }) + sites: SiteCongestionDto[]; + + @ApiProperty({ + description: '๋งˆ์ง€๋ง‰ ์—…๋ฐ์ดํŠธ ์‹œ๊ฐ„', + example: '2026-01-27T01:30:00Z', + }) + lastUpdated: string; +} diff --git a/frontend/app/_source/components/TrafficChart.tsx b/frontend/app/_source/components/TrafficChart.tsx new file mode 100644 index 00000000..26ed0689 --- /dev/null +++ b/frontend/app/_source/components/TrafficChart.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { Activity } from 'lucide-react'; +import { useTraffic } from '../hooks/useTraffic'; +import { TrafficLineChart } from './traffic/TrafficLineChart'; +import { TrafficSummaryCards } from './traffic/TrafficSummaryCards'; +import { + TrafficLoading, + TrafficError, + TrafficEmpty, +} from './traffic/TrafficStates'; +import { CongestionChartData } from '@/types/traffic'; + +export function TrafficChart() { + // ํ™˜๊ฒฝ ๋ณ€์ˆ˜๋กœ Mock ๋ฐ์ดํ„ฐ ์‚ฌ์šฉ ์—ฌ๋ถ€ ๊ฒฐ์ • (๊ธฐ๋ณธ๊ฐ’: false, ์‹ค์ œ API ์‚ฌ์šฉ) + const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_TRAFFIC === 'true'; + const { sites, isLoading, isError, error } = useTraffic(useMockData); + + // ์ฐจํŠธ ๋ฐ์ดํ„ฐ ๋ณ€ํ™˜: API ์‘๋‹ต โ†’ Recharts ํ˜•์‹ + const chartData: CongestionChartData[] = + sites.length > 0 && sites[0].data.length > 0 + ? sites[0].data.map((_, index) => { + const dataPoint: CongestionChartData = { + timestamp: sites[0].data[index].timestamp, + }; + + sites.forEach((site) => { + dataPoint[site.displayName] = + site.data[index]?.congestionScore ?? 0; + }); + + return dataPoint; + }) + : []; + + if (isLoading) return ; + if (isError) + return ( + + ); + if (sites.length === 0) return ; + + return ( +
+
+
+ +
+
+

์˜ˆ๋งค ๊ฒฝ์Ÿ ๊ฐ•๋„ ๋ถ„์„

+

+ ์‚ฌ์ดํŠธ๋ณ„ ์‘๋‹ต ์ง€์—ฐยท์—๋Ÿฌ์œจ ๊ธฐ๋ฐ˜ ๊ฒฝ์Ÿ ๊ฐ•๋„ ์ถ”์ • (์ตœ๊ทผ 24์‹œ๊ฐ„) +

+
+
+ + + +
+ ); +} diff --git a/frontend/app/_source/components/ticketing/UpcomingTicketing.tsx b/frontend/app/_source/components/ticketing/UpcomingTicketing.tsx index 772893e2..4beed5ae 100644 --- a/frontend/app/_source/components/ticketing/UpcomingTicketing.tsx +++ b/frontend/app/_source/components/ticketing/UpcomingTicketing.tsx @@ -4,16 +4,14 @@ import TicketingData from './TicketingData'; export default function UpcomingTicketing() { return ( -
-
-
- ์ถ”ํ›„ ์—๋Ÿฌ ํ‘œ์‹œ ํ™”๋ฉด ๊ตฌํ˜„
}> - ๊ณต์—ฐ ์ •๋ณด๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘์ž…๋‹ˆ๋‹ค.
}> - - - - +
+
+ ์ถ”ํ›„ ์—๋Ÿฌ ํ‘œ์‹œ ํ™”๋ฉด ๊ตฌํ˜„
}> + ๊ณต์—ฐ ์ •๋ณด๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘์ž…๋‹ˆ๋‹ค.
}> + + + -
+ ); } diff --git a/frontend/app/_source/components/traffic/TrafficLineChart.tsx b/frontend/app/_source/components/traffic/TrafficLineChart.tsx new file mode 100644 index 00000000..d7a49f0b --- /dev/null +++ b/frontend/app/_source/components/traffic/TrafficLineChart.tsx @@ -0,0 +1,62 @@ +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer, +} from 'recharts'; +import { SiteCongestion, CongestionChartData } from '@/types/traffic'; + +interface Props { + sites: SiteCongestion[]; + chartData: CongestionChartData[]; +} + +export function TrafficLineChart({ sites, chartData }: Props) { + return ( +
+ + + + + `${value}`} + label={{ + value: '๊ฒฝ์Ÿ ๊ฐ•๋„', + angle: -90, + position: 'insideLeft', + style: { fontSize: 12 }, + }} + /> + [`${value.toFixed(0)}์ `, '']} + contentStyle={{ + backgroundColor: 'white', + border: '1px solid #e5e7eb', + borderRadius: '8px', + padding: '8px 12px', + }} + /> + + + {sites.map((site) => ( + + ))} + + +
+ ); +} diff --git a/frontend/app/_source/components/traffic/TrafficStates.tsx b/frontend/app/_source/components/traffic/TrafficStates.tsx new file mode 100644 index 00000000..6de6fbc8 --- /dev/null +++ b/frontend/app/_source/components/traffic/TrafficStates.tsx @@ -0,0 +1,32 @@ +import { AlertCircle, Loader2 } from 'lucide-react'; + +export function TrafficLoading() { + return ( +
+ + ํŠธ๋ž˜ํ”ฝ ๋ฐ์ดํ„ฐ ๋กœ๋”ฉ ์ค‘... +
+ ); +} + +export function TrafficError({ message }: { message?: string }) { + return ( +
+ +
+

๋ฐ์ดํ„ฐ ๋กœ๋”ฉ ์‹คํŒจ

+

+ {message || 'ํŠธ๋ž˜ํ”ฝ ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'} +

+
+
+ ); +} + +export function TrafficEmpty() { + return ( +
+

ํ‘œ์‹œํ•  ํŠธ๋ž˜ํ”ฝ ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.

+
+ ); +} diff --git a/frontend/app/_source/components/traffic/TrafficSummaryCards.tsx b/frontend/app/_source/components/traffic/TrafficSummaryCards.tsx new file mode 100644 index 00000000..3e12a137 --- /dev/null +++ b/frontend/app/_source/components/traffic/TrafficSummaryCards.tsx @@ -0,0 +1,38 @@ +import { SiteCongestion } from '@/types/traffic'; +import { CONGESTION_LEVELS } from './trafficConfig'; + +interface Props { + sites: SiteCongestion[]; +} + +export function TrafficSummaryCards({ sites }: Props) { + return ( +
+ {sites.map((site) => { + const levelConfig = CONGESTION_LEVELS[site.currentLevel]; + + return ( +
+
+ {site.displayName} +
+
+
+ {site.currentCongestionScore.toFixed(0)}์  +
+
+ {levelConfig.label} +
+
+
์˜ˆ๋งค ๊ฒฝ์Ÿ ๊ฐ•๋„
+
+ ); + })} +
+ ); +} diff --git a/frontend/app/_source/components/traffic/mockData.ts b/frontend/app/_source/components/traffic/mockData.ts new file mode 100644 index 00000000..61e562ad --- /dev/null +++ b/frontend/app/_source/components/traffic/mockData.ts @@ -0,0 +1,58 @@ +import { SiteCongestion, CongestionDataPoint } from '@/types/traffic'; +import { SITE_COLORS, ACTIVE_SITES, getCongestionLevel } from './trafficConfig'; + +/** + * ๊ฒฝ์Ÿ ๊ฐ•๋„ Mock ๋ฐ์ดํ„ฐ ์ƒ์„ฑ + * - ํ‹ฐ์ผ“ํŒ… ์˜คํ”ˆ ์‹œ๊ฐ„(์˜ค์ „ 10์‹œ)์— ๊ธ‰์ฆํ•˜๋Š” ํŒจํ„ด ์‹œ๋ฎฌ๋ ˆ์ด์…˜ + * - ์‘๋‹ต ์ง€์—ฐ, ํƒ€์ž„์•„์›ƒ, ์—๋Ÿฌ์œจ ๋“ฑ์„ ๋ฐ˜์˜ํ•œ ๊ฒฝ์Ÿ ๊ฐ•๋„ ์ ์ˆ˜ (0-100) + */ +export function generateMockTrafficData(): SiteCongestion[] { + const now = new Date(); + const sites: SiteCongestion[] = []; + + ACTIVE_SITES.forEach((siteKey) => { + const config = SITE_COLORS[siteKey]; + const data: CongestionDataPoint[] = []; + + // ์ตœ๊ทผ 24์‹œ๊ฐ„ ๋ฐ์ดํ„ฐ ์ƒ์„ฑ (๊ฒฝ์Ÿ ๊ฐ•๋„ ๊ธฐ๋ฐ˜) + for (let i = 23; i >= 0; i--) { + const time = new Date(now.getTime() - i * 60 * 60 * 1000); + const hour = time.getHours(); + + // ํ‹ฐ์ผ“ํŒ… ์˜คํ”ˆ ์‹œ๊ฐ„ (10์‹œ) ์ „ํ›„๋กœ ๊ฒฝ์Ÿ ๊ฐ•๋„ ๊ธ‰์ฆ ํŒจํ„ด + let baseScore = 20; // ๊ธฐ๋ณธ ์ ์ˆ˜ + if (hour >= 9 && hour <= 11) { + baseScore = 70 + Math.random() * 25; // ์˜คํ”ˆ ์‹œ๊ฐ„: 70-95์  + } else if (hour >= 8 && hour <= 12) { + baseScore = 40 + Math.random() * 30; // ์˜คํ”ˆ ์ „ํ›„: 40-70์  + } else { + baseScore = 10 + Math.random() * 30; // ๊ธฐํƒ€: 10-40์  + } + + data.push({ + timestamp: time.toLocaleTimeString('ko-KR', { + hour: '2-digit', + minute: '2-digit', + }), + congestionScore: Math.min(100, Math.max(0, baseScore)), + }); + } + + // ํ˜„์žฌ ๊ฒฝ์Ÿ ๊ฐ•๋„ ์ ์ˆ˜ (๋งˆ์ง€๋ง‰ ๋ฐ์ดํ„ฐ ๊ธฐ์ค€) + const currentScore = data[data.length - 1]?.congestionScore ?? 50; + + sites.push({ + site: siteKey, + displayName: config.displayName, + color: config.color, + backgroundColor: config.backgroundColor, + borderColor: config.borderColor, + textColor: config.textColor, + data, + currentCongestionScore: currentScore, + currentLevel: getCongestionLevel(currentScore), + }); + }); + + return sites; +} diff --git a/frontend/app/_source/components/traffic/trafficConfig.ts b/frontend/app/_source/components/traffic/trafficConfig.ts new file mode 100644 index 00000000..4c5d66cd --- /dev/null +++ b/frontend/app/_source/components/traffic/trafficConfig.ts @@ -0,0 +1,76 @@ +import { CongestionLevel } from '@/types/traffic'; + +export const SITE_COLORS = { + INTERPARK: { + displayName: '์ธํ„ฐํŒŒํฌ', + color: '#8b5cf6', + backgroundColor: 'bg-purple-50', + borderColor: 'border-purple-100', + textColor: 'text-purple-600', + key: 'interpark', + }, + YES24: { + displayName: 'YES24', + color: '#3b82f6', + backgroundColor: 'bg-blue-50', + borderColor: 'border-blue-100', + textColor: 'text-blue-600', + key: 'yes24', + }, + MELON_TICKET: { + displayName: '๋ฉœ๋ก ํ‹ฐ์ผ“', + color: '#10b981', + backgroundColor: 'bg-green-50', + borderColor: 'border-green-100', + textColor: 'text-green-600', + key: 'melon', + }, +} as const; + +export const ACTIVE_SITES = ['INTERPARK', 'YES24', 'MELON_TICKET'] as const; + +export type SiteKey = (typeof ACTIVE_SITES)[number]; + +// ๊ฒฝ์Ÿ ๊ฐ•๋„ ๋ ˆ๋ฒจ๋ณ„ ํ‘œ์‹œ ์„ค์ • +export const CONGESTION_LEVELS: Record< + CongestionLevel, + { + label: string; + color: string; + bgColor: string; + textColor: string; + } +> = { + LOW: { + label: '๋‚ฎ์Œ', + color: '#10b981', + bgColor: 'bg-green-100', + textColor: 'text-green-700', + }, + MEDIUM: { + label: '๋ณดํ†ต', + color: '#f59e0b', + bgColor: 'bg-yellow-100', + textColor: 'text-yellow-700', + }, + HIGH: { + label: '๋†’์Œ', + color: '#f97316', + bgColor: 'bg-orange-100', + textColor: 'text-orange-700', + }, + EXTREME: { + label: '๋งค์šฐ ๋†’์Œ', + color: '#ef4444', + bgColor: 'bg-red-100', + textColor: 'text-red-700', + }, +}; + +// ๊ฒฝ์Ÿ ๊ฐ•๋„ ์ ์ˆ˜๋ฅผ ๋ ˆ๋ฒจ๋กœ ๋ณ€ํ™˜ +export function getCongestionLevel(score: number): CongestionLevel { + if (score >= 75) return 'EXTREME'; + if (score >= 50) return 'HIGH'; + if (score >= 25) return 'MEDIUM'; + return 'LOW'; +} diff --git a/frontend/app/_source/hooks/useTraffic.ts b/frontend/app/_source/hooks/useTraffic.ts new file mode 100644 index 00000000..4baa596f --- /dev/null +++ b/frontend/app/_source/hooks/useTraffic.ts @@ -0,0 +1,33 @@ +import { useMemo } from 'react'; +import { generateMockTrafficData } from '../components/traffic/mockData'; +import { SiteTraffic } from '@/types/traffic'; +import { useTrafficData } from '../queries/traffic'; + +interface UseTrafficResult { + sites: SiteTraffic[]; + isLoading: boolean; + isError: boolean; + error: Error | null; +} + +export const useTraffic = (useMockData: boolean = false): UseTrafficResult => { + const { data, isLoading, isError, error } = useTrafficData(); + const mockSites = useMemo(() => generateMockTrafficData(), []); + + if (useMockData) { + return { + sites: mockSites, + isLoading: false, + isError: false, + error: null, + }; + } + + // ์‹ค์ œ API ๋ฐ์ดํ„ฐ ์‚ฌ์šฉ + return { + sites: data?.sites ?? [], + isLoading, + isError, + error: error as Error | null, + }; +}; diff --git a/frontend/app/_source/queries/traffic.ts b/frontend/app/_source/queries/traffic.ts new file mode 100644 index 00000000..ede3f945 --- /dev/null +++ b/frontend/app/_source/queries/traffic.ts @@ -0,0 +1,21 @@ +import { useQuery } from '@tanstack/react-query'; +import { api } from '@/lib/api/api'; +import { CongestionResponse } from '@/types/traffic'; + +/** + * ๊ฒฝ์Ÿ ๊ฐ•๋„ ๋ฐ์ดํ„ฐ ์กฐํšŒ React Query ํ›… + */ +export const useTrafficData = () => { + return useQuery({ + queryKey: ['traffic', 'congestion'], + queryFn: async () => { + const response = await api.get('/congestion', { + serverType: 'api', + }); + return response; + }, + refetchInterval: 60000, // 1๋ถ„๋งˆ๋‹ค ๊ฐฑ์‹  + retry: 2, + staleTime: 30000, // 30์ดˆ + }); +}; diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 3bb90796..47d6c4a1 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -5,12 +5,14 @@ */ import UpcomingTicketing from './_source/components/ticketing/UpcomingTicketing'; +import { TrafficChart } from './_source/components/TrafficChart'; export const dynamic = 'force-dynamic'; export default async function Home() { return ( - <> +
- + +
); } diff --git a/frontend/package.json b/frontend/package.json index b1729306..13d88a6b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,7 @@ "react-day-picker": "^9.13.0", "react-dom": "19.2.3", "react-error-boundary": "^6.0.2", + "recharts": "^3.7.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "zustand": "^5.0.10" diff --git a/frontend/types/traffic.ts b/frontend/types/traffic.ts new file mode 100644 index 00000000..0b106b90 --- /dev/null +++ b/frontend/types/traffic.ts @@ -0,0 +1,48 @@ +// ๊ฒฝ์Ÿ ๊ฐ•๋„ ๋ ˆ๋ฒจ +export type CongestionLevel = 'LOW' | 'MEDIUM' | 'HIGH' | 'EXTREME'; + +// ์ธก์ • ์ง€ํ‘œ +export interface SiteMetrics { + avgResponseTime: number; // ms + timeoutRate: number; // 0-1 + errorRate: number; // 0-1 + queueDetected: boolean; +} + +// ๊ฐœ๋ณ„ ๋ฐ์ดํ„ฐํฌ์ธํŠธ (๊ฒฝ์Ÿ ๊ฐ•๋„ ๊ธฐ๋ฐ˜) +export interface CongestionDataPoint { + timestamp: string; + congestionScore: number; // 0-100 +} + +// ์‚ฌ์ดํŠธ๋ณ„ ๊ฒฝ์Ÿ ๊ฐ•๋„ ๋ฐ์ดํ„ฐ +export interface SiteCongestion { + site: string; + displayName: string; + color: string; + backgroundColor: string; + borderColor: string; + textColor: string; + data: CongestionDataPoint[]; + currentCongestionScore: number; // 0-100 + currentLevel: CongestionLevel; + metrics?: SiteMetrics; // ์„ ํƒ์  ์ƒ์„ธ ์ง€ํ‘œ +} + +// API ์‘๋‹ต +export interface CongestionResponse { + sites: SiteCongestion[]; + lastUpdated: string; +} + +// Recharts ์ฐจํŠธ ๋ฐ์ดํ„ฐ +export interface CongestionChartData { + timestamp: string; + [siteKey: string]: number | string; +} + +// ๋ ˆ๊ฑฐ์‹œ ํƒ€์ž… ๋ณ„์นญ (ํ˜ธํ™˜์„ฑ ์œ ์ง€) +export type TrafficDataPoint = CongestionDataPoint; +export type SiteTraffic = SiteCongestion; +export type TrafficResponse = CongestionResponse; +export type TrafficChartData = CongestionChartData; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 073f2a5e..4051bf3b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -334,6 +334,9 @@ importers: react-error-boundary: specifier: ^6.0.2 version: 6.1.0(react@19.2.3) + recharts: + specifier: ^3.7.0 + version: 3.7.0(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react-is@18.3.1)(react@19.2.3)(redux@5.0.1) sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -342,7 +345,7 @@ importers: version: 3.4.0 zustand: specifier: ^5.0.10 - version: 5.0.10(@types/react@19.2.8)(react@19.2.3) + version: 5.0.10(@types/react@19.2.8)(immer@11.1.3)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)) devDependencies: '@tailwindcss/postcss': specifier: ^4 @@ -1539,6 +1542,17 @@ packages: '@types/react': optional: true + '@reduxjs/toolkit@2.11.2': + resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -1557,6 +1571,12 @@ packages: '@sqltools/formatter@1.2.5': resolution: {integrity: sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -1721,6 +1741,33 @@ packages: resolution: {integrity: sha512-ViRBkoZD9Rk0hGeMdd2GHGaOaZuH9mDmwsE5/Zo53Ftwcvh7h9VJc8lIt2wdgEwS4EW5lbtTX6vlE0idCLPOyA==} deprecated: This is a stub types definition. cron provides its own type definitions, so you do not need this installed. + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -1817,6 +1864,9 @@ packages: '@types/supertest@6.0.3': resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@types/validator@13.15.10': resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} @@ -2583,6 +2633,50 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} @@ -2624,6 +2718,9 @@ packages: supports-color: optional: true + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decompress-response@6.0.0: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} @@ -2796,6 +2893,9 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + es-toolkit@1.44.0: + resolution: {integrity: sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==} + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -3327,6 +3427,12 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + immer@10.2.0: + resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + + immer@11.1.3: + resolution: {integrity: sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -3361,6 +3467,10 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + ioredis@5.9.2: resolution: {integrity: sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==} engines: {node: '>=12.22.0'} @@ -4501,6 +4611,18 @@ packages: react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react-redux@9.2.0: + resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + react@19.2.3: resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} engines: {node: '>=0.10.0'} @@ -4517,6 +4639,14 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + recharts@3.7.0: + resolution: {integrity: sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + redis-errors@1.2.0: resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} engines: {node: '>=4'} @@ -4525,6 +4655,14 @@ packages: resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} engines: {node: '>=4'} + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} @@ -4544,6 +4682,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + resolve-cwd@3.0.0: resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} engines: {node: '>=8'} @@ -4996,6 +5137,9 @@ packages: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -5256,6 +5400,11 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -5286,6 +5435,9 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} @@ -6518,6 +6670,18 @@ snapshots: optionalDependencies: '@types/react': 19.2.8 + '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.8)(react@19.2.3)(redux@5.0.1))(react@19.2.3)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.3 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.1.1 + optionalDependencies: + react: 19.2.3 + react-redux: 9.2.0(@types/react@19.2.8)(react@19.2.3)(redux@5.0.1) + '@rtsao/scc@1.1.0': {} '@scarf/scarf@1.4.0': {} @@ -6534,6 +6698,10 @@ snapshots: '@sqltools/formatter@1.2.5': {} + '@standard-schema/spec@1.1.0': {} + + '@standard-schema/utils@0.3.0': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -6687,6 +6855,30 @@ snapshots: dependencies: cron: 4.4.0 + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1 @@ -6807,6 +6999,8 @@ snapshots: '@types/methods': 1.1.4 '@types/superagent': 8.1.9 + '@types/use-sync-external-store@0.0.6': {} + '@types/validator@13.15.10': {} '@types/yargs-parser@21.0.3': {} @@ -7645,6 +7839,44 @@ snapshots: csstype@3.2.3: {} + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + damerau-levenshtein@1.0.8: {} data-view-buffer@1.0.2: @@ -7681,6 +7913,8 @@ snapshots: optionalDependencies: supports-color: 5.5.0 + decimal.js-light@2.5.1: {} + decompress-response@6.0.0: dependencies: mimic-response: 3.1.0 @@ -7896,6 +8130,8 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + es-toolkit@1.44.0: {} + escalade@3.2.0: {} escape-html@1.0.3: {} @@ -8553,6 +8789,10 @@ snapshots: ignore@7.0.5: {} + immer@10.2.0: {} + + immer@11.1.3: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -8586,6 +8826,8 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 + internmap@2.0.3: {} + ioredis@5.9.2: dependencies: '@ioredis/commands': 1.5.0 @@ -9934,6 +10176,15 @@ snapshots: react-is@18.3.1: {} + react-redux@9.2.0(@types/react@19.2.8)(react@19.2.3)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.2.3 + use-sync-external-store: 1.6.0(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + redux: 5.0.1 + react@19.2.3: {} readable-stream@3.6.2: @@ -9948,12 +10199,38 @@ snapshots: readdirp@4.1.2: {} + recharts@3.7.0(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react-is@18.3.1)(react@19.2.3)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.8)(react@19.2.3)(redux@5.0.1))(react@19.2.3) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.44.0 + eventemitter3: 5.0.1 + immer: 10.2.0 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-is: 18.3.1 + react-redux: 9.2.0(@types/react@19.2.8)(react@19.2.3)(redux@5.0.1) + reselect: 5.1.1 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@19.2.3) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux + redis-errors@1.2.0: {} redis-parser@3.0.0: dependencies: redis-errors: 1.2.0 + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + reflect-metadata@0.2.2: {} reflect.getprototypeof@1.0.10: @@ -9980,6 +10257,8 @@ snapshots: require-from-string@2.0.2: {} + reselect@5.1.1: {} + resolve-cwd@3.0.0: dependencies: resolve-from: 5.0.0 @@ -10538,6 +10817,8 @@ snapshots: glob: 7.2.3 minimatch: 3.1.2 + tiny-invariant@1.3.3: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -10808,6 +11089,10 @@ snapshots: dependencies: punycode: 2.3.1 + use-sync-external-store@1.6.0(react@19.2.3): + dependencies: + react: 19.2.3 + util-deprecate@1.0.2: {} utils-merge@1.0.1: {} @@ -10828,6 +11113,23 @@ snapshots: vary@1.1.2: {} + victory-vendor@37.3.6: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + walker@1.0.8: dependencies: makeerror: 1.0.12 @@ -10996,7 +11298,9 @@ snapshots: zod@4.3.5: {} - zustand@5.0.10(@types/react@19.2.8)(react@19.2.3): + zustand@5.0.10(@types/react@19.2.8)(immer@11.1.3)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)): optionalDependencies: '@types/react': 19.2.8 + immer: 11.1.3 react: 19.2.3 + use-sync-external-store: 1.6.0(react@19.2.3)