├── .github ├── ISSUE_TEMPLATE │ ├── 🐞-bug-report.md │ ├── 📋-task.md │ ├── 📗-story.md │ ├── 📚-documentation.md │ ├── 📦-epic.md │ ├── 🔧-refactor.md │ └── 🚀-feature-request.md ├── pull_request_template.md └── workflows │ ├── ci-cd.yml │ └── deploy-service.yml ├── .gitignore ├── README.md ├── backend ├── console-server │ ├── .dockerignore │ ├── .gitignore │ ├── .prettierrc │ ├── Dockerfile │ ├── README.md │ ├── docker-compose.yml │ ├── ecosystem.config.js │ ├── eslint.config.mjs │ ├── nest-cli.json │ ├── nginx.conf │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── app.controller.spec.ts │ │ ├── app.controller.ts │ │ ├── app.module.ts │ │ ├── app.service.ts │ │ ├── clickhouse │ │ │ ├── clickhouse.module.ts │ │ │ ├── clickhouse.ts │ │ │ ├── core │ │ │ │ └── clickhouse.error.ts │ │ │ ├── query-builder │ │ │ │ ├── time-series-query-builder.error.ts │ │ │ │ ├── time-series.query-builder.spec.ts │ │ │ │ └── time-series.query-builder.ts │ │ │ └── util │ │ │ │ ├── clickhouse-client.error.ts │ │ │ │ ├── map-filter-condition.ts │ │ │ │ └── metric-expressions.ts │ │ ├── common │ │ │ └── cache │ │ │ │ ├── cache.constant.ts │ │ │ │ ├── cache.decorator.ts │ │ │ │ ├── cache.interceptor.ts │ │ │ │ └── index.ts │ │ ├── config │ │ │ ├── clickhouse.config.ts │ │ │ ├── mailer.config.ts │ │ │ ├── redis.config.ts │ │ │ └── typeorm.config.ts │ │ ├── log │ │ │ ├── analytics │ │ │ │ ├── analytics.controller.spec.ts │ │ │ │ ├── analytics.controller.ts │ │ │ │ ├── analytics.module.ts │ │ │ │ ├── analytics.repository.spec.ts │ │ │ │ ├── analytics.repository.ts │ │ │ │ ├── analytics.service.spec.ts │ │ │ │ ├── analytics.service.ts │ │ │ │ ├── dto │ │ │ │ │ ├── get-project-dau-response.dto.ts │ │ │ │ │ └── get-project-dau.dto.ts │ │ │ │ └── metric │ │ │ │ │ └── dau.metric.ts │ │ │ ├── elapsed-time │ │ │ │ ├── dto │ │ │ │ │ ├── get-avg-elapsed-time-response.dto.ts │ │ │ │ │ ├── get-avg-elapsed-time.dto.ts │ │ │ │ │ ├── get-path-elapsed-time-response.dto.ts │ │ │ │ │ ├── get-path-elapsed-time.rank.ts │ │ │ │ │ ├── get-top5-elapsed-time.dto.ts │ │ │ │ │ └── get-top5-elapsed.time.ts │ │ │ │ ├── elapsed-time.controller.spec.ts │ │ │ │ ├── elapsed-time.controller.ts │ │ │ │ ├── elapsed-time.module.ts │ │ │ │ ├── elapsed-time.repository.spec.ts │ │ │ │ ├── elapsed-time.repository.ts │ │ │ │ ├── elapsed-time.service.spec.ts │ │ │ │ ├── elapsed-time.service.ts │ │ │ │ └── metric │ │ │ │ │ ├── avg-elapsed-time.metric.ts │ │ │ │ │ ├── host-avg-elapsed-time.metric.ts │ │ │ │ │ └── path-elapsed-time.metric.ts │ │ │ ├── log.module.ts │ │ │ ├── rank │ │ │ │ ├── dto │ │ │ │ │ ├── get-dau-rank-response.dto.ts │ │ │ │ │ ├── get-dau-rank.dto.ts │ │ │ │ │ ├── get-elapsed-time-rank-response.dto.ts │ │ │ │ │ ├── get-elapsed-time-rank.dto.ts │ │ │ │ │ ├── get-success-rate-rank-response.dto.ts │ │ │ │ │ ├── get-success-rate-rank.dto.ts │ │ │ │ │ ├── get-traffic-rank-response.dto.ts │ │ │ │ │ └── get-traffic-rank.dto.ts │ │ │ │ ├── metric │ │ │ │ │ ├── host-count.metric.ts │ │ │ │ │ ├── host-dau.metric.ts │ │ │ │ │ ├── host-elapsed-time.metric.ts │ │ │ │ │ └── host-error-rate.metric.ts │ │ │ │ ├── rank.controller.spec.ts │ │ │ │ ├── rank.controller.ts │ │ │ │ ├── rank.module.ts │ │ │ │ ├── rank.repository.spec.ts │ │ │ │ ├── rank.repository.ts │ │ │ │ ├── rank.service.spec.ts │ │ │ │ └── rank.service.ts │ │ │ ├── success-rate │ │ │ │ ├── dto │ │ │ │ │ ├── get-project-success-rate-response.dto.ts │ │ │ │ │ ├── get-project-success-rate.dto.ts │ │ │ │ │ ├── get-success-rate-response.dto.ts │ │ │ │ │ └── get-success-rate.dto.ts │ │ │ │ ├── metric │ │ │ │ │ ├── error-rate.metric.ts │ │ │ │ │ └── success-rate.metric.ts │ │ │ │ ├── success-rate.controller.spec.ts │ │ │ │ ├── success-rate.controller.ts │ │ │ │ ├── success-rate.module.ts │ │ │ │ ├── success-rate.repository.spec.ts │ │ │ │ ├── success-rate.repository.ts │ │ │ │ ├── success-rate.service.spec.ts │ │ │ │ └── success-rate.service.ts │ │ │ └── traffic │ │ │ │ ├── dto │ │ │ │ ├── get-traffic-by-generation-response.dto.ts │ │ │ │ ├── get-traffic-by-generation.dto.ts │ │ │ │ ├── get-traffic-by-project-response.dto.ts │ │ │ │ ├── get-traffic-by-project.dto.ts │ │ │ │ ├── get-traffic-daily-difference-response.dto.ts │ │ │ │ ├── get-traffic-daily-difference.dto.ts │ │ │ │ ├── get-traffic-top5-chart-response.dto.ts │ │ │ │ ├── get-traffic-top5-chart.dto.ts │ │ │ │ ├── get-traffic-top5-response.dto.ts │ │ │ │ └── get-traffic-top5.dto.ts │ │ │ │ ├── metric │ │ │ │ ├── traffic-chart.metric.ts │ │ │ │ ├── traffic-count-by-project.metric.ts │ │ │ │ ├── traffic-count-by-timeunit.metric.ts │ │ │ │ ├── traffic-count.metric.ts │ │ │ │ └── traffic-rank.metric.ts │ │ │ │ ├── traffic.constant.ts │ │ │ │ ├── traffic.controller.spec.ts │ │ │ │ ├── traffic.controller.ts │ │ │ │ ├── traffic.module.ts │ │ │ │ ├── traffic.repository.spec.ts │ │ │ │ ├── traffic.repository.ts │ │ │ │ ├── traffic.service.spec.ts │ │ │ │ └── traffic.service.ts │ │ ├── mail │ │ │ ├── mail.module.ts │ │ │ ├── mail.service.spec.ts │ │ │ ├── mail.service.ts │ │ │ └── templates │ │ │ │ └── nameserver.hbs │ │ ├── main.ts │ │ └── project │ │ │ ├── dto │ │ │ ├── count-project-by-generation-response.dto.ts │ │ │ ├── count-project-by-generation.dto.ts │ │ │ ├── create-project-response.dto.ts │ │ │ ├── create-project.dto.ts │ │ │ ├── exists-project-response.dto.ts │ │ │ ├── exists-project.dto.ts │ │ │ ├── find-by-generation-response.dto.ts │ │ │ └── find-by-generation.dto.ts │ │ │ ├── entities │ │ │ └── project.entity.ts │ │ │ ├── project.controller.spec.ts │ │ │ ├── project.controller.ts │ │ │ ├── project.module.ts │ │ │ ├── project.service.spec.ts │ │ │ └── project.service.ts │ ├── test │ │ ├── app.e2e-spec.ts │ │ └── jest-e2e.json │ ├── tsconfig.build.json │ └── tsconfig.json ├── name-server │ ├── .eslintignore │ ├── .gitignore │ ├── .prettierignore │ ├── .prettierrc │ ├── Dockerfile │ ├── NAME_SERVER.md │ ├── babel.config.js │ ├── docker-compose.yml │ ├── ecosystem.config.cjs │ ├── eslint.config.js │ ├── jest.config.js │ ├── nginx.conf │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── app.ts │ │ ├── common │ │ │ ├── core │ │ │ │ └── custom.error.ts │ │ │ ├── error │ │ │ │ └── configuration.error.ts │ │ │ └── utils │ │ │ │ ├── logger │ │ │ │ ├── console.logger.ts │ │ │ │ └── logger.ts │ │ │ │ └── validator │ │ │ │ └── configuration.validator.ts │ │ ├── database │ │ │ ├── error │ │ │ │ └── database.error.ts │ │ │ ├── mysql │ │ │ │ ├── config.ts │ │ │ │ └── mysql-database.ts │ │ │ ├── query │ │ │ │ ├── cache.query.interface.ts │ │ │ │ ├── cache.query.ts │ │ │ │ ├── project.query.interface.ts │ │ │ │ └── project.query.ts │ │ │ └── redis │ │ │ │ └── redis-database.ts │ │ ├── index.ts │ │ └── server │ │ │ ├── constant │ │ │ ├── dns-packet.constant.ts │ │ │ └── message-type.constants.ts │ │ │ ├── error │ │ │ └── server.error.ts │ │ │ ├── server.ts │ │ │ ├── service │ │ │ └── health-check.service.ts │ │ │ └── utils │ │ │ ├── dns-response-builder.ts │ │ │ └── packet.validator.ts │ ├── test │ │ ├── constant │ │ │ └── packet.ts │ │ ├── database │ │ │ ├── test-cache.query.ts │ │ │ ├── test-database.ts │ │ │ └── test-project.query.ts │ │ ├── dns-response-builder.test.ts │ │ ├── packet.validator.test.ts │ │ └── server.test.ts │ └── tsconfig.json └── proxy-server │ ├── .gitignore │ ├── .prettierignore │ ├── .prettierrc │ ├── Dockerfile │ ├── babel.config.js │ ├── clickhouse-local.yml │ ├── docker-compose.yml │ ├── ecosystem.config.cjs │ ├── eslint.config.js │ ├── jest.config.js │ ├── package-lock.json │ ├── package.json │ ├── src │ ├── common │ │ ├── constant │ │ │ ├── error-message.constant.ts │ │ │ ├── http-status.constant.ts │ │ │ └── http.constant.ts │ │ ├── container │ │ │ └── container.ts │ │ ├── core │ │ │ ├── proxy-error.type.guard.ts │ │ │ └── proxy.error.ts │ │ ├── error │ │ │ ├── database-query.error.ts │ │ │ ├── domain-not-found.error.ts │ │ │ ├── missing-host-header.error.ts │ │ │ ├── system.error.ts │ │ │ └── types │ │ │ │ └── system-error.type.ts │ │ ├── logger │ │ │ ├── createFastifyLogger.ts │ │ │ ├── error-log.repository.test.ts │ │ │ ├── error-log.repository.ts │ │ │ ├── fastify.logger.test.ts │ │ │ └── logger.interface.ts │ │ └── utils │ │ │ └── date.util.ts │ ├── database │ │ ├── clickhouse │ │ │ ├── clickhouse-database.ts │ │ │ └── config │ │ │ │ ├── buffer.config.ts │ │ │ │ └── clickhouse.config.ts │ │ ├── mysql │ │ │ ├── config │ │ │ │ └── pool.config.ts │ │ │ └── mysql-database.ts │ │ ├── query │ │ │ ├── log.repository.clickhouse.ts │ │ │ ├── project-cache.repository.redis.ts │ │ │ └── project.repository.mysql.ts │ │ └── redis │ │ │ └── redis-database.ts │ ├── domain │ │ ├── entity │ │ │ ├── http-log.entity.test.ts │ │ │ ├── http-log.entity.ts │ │ │ └── project.entity.ts │ │ ├── port │ │ │ ├── input │ │ │ │ ├── log.use-case.ts │ │ │ │ └── project.use-case.ts │ │ │ └── output │ │ │ │ ├── log.repository.ts │ │ │ │ ├── project-cache.repository.ts │ │ │ │ └── project.repository.ts │ │ ├── service │ │ │ ├── log.service.test.ts │ │ │ ├── log.service.ts │ │ │ ├── project.service.test.ts │ │ │ └── project.service.ts │ │ └── util │ │ │ ├── utils.test.ts │ │ │ └── utils.ts │ ├── index.ts │ └── server │ │ ├── adapter │ │ ├── log.adapter.ts │ │ └── project.adapter.ts │ │ ├── config │ │ ├── fastify.config.ts │ │ └── server.configuration.ts │ │ ├── fastify.server.ts │ │ └── handler │ │ ├── error.handler.ts │ │ ├── health-check.handler.ts │ │ ├── log.handler.ts │ │ └── proxy.handler.ts │ ├── test │ └── integration.test.ts │ ├── tsconfig.json │ └── tsconfig.test.json └── frontend ├── .gitignore ├── .prettierrc ├── README.md ├── asset └── image │ ├── CloudFlareDescription.png │ ├── Favicon.png │ ├── Favicon.svg │ ├── FaviconLight.png │ ├── FirstMedal.png │ ├── GabiaDescription.png │ ├── Github.png │ ├── ProjectActive.png │ ├── ProjectInactive.png │ ├── ProjectsActive.svg │ ├── ProjectsInactive.png │ ├── ProjectsInactive.svg │ ├── RankingActive.png │ ├── RankingInactive.png │ ├── SecondMedal.png │ └── ThirdMedal.png ├── eslint.config.js ├── index.html ├── netlify.toml ├── package-lock.json ├── package.json ├── postcss.config.js ├── src ├── App.tsx ├── api │ ├── README.md │ ├── axios.ts │ ├── get │ │ ├── MainPage.ts │ │ ├── ProjectPage.ts │ │ └── RankingPage.ts │ └── post.ts ├── boundary │ ├── CustomErrorBoundary.tsx │ ├── CustomErrorFallback.tsx │ ├── CustomQueryProvider.tsx │ ├── MainBoundary.tsx │ ├── README.md │ └── toastError.ts ├── chart │ ├── BarChart.tsx │ ├── Chart.tsx │ ├── LineChart.tsx │ ├── PieChart.tsx │ ├── PolarAreaChart.tsx │ └── README.md ├── component │ ├── README.md │ ├── atom │ │ ├── Alert.tsx │ │ ├── Button.tsx │ │ ├── H1.tsx │ │ ├── H2.tsx │ │ ├── Img.tsx │ │ ├── Input.tsx │ │ ├── Loading.tsx │ │ ├── P.tsx │ │ ├── Select.tsx │ │ ├── Span.tsx │ │ ├── TextMotionDiv.tsx │ │ └── ValidIcon.tsx │ ├── molecule │ │ ├── DarkModeButton.tsx │ │ ├── InformationButton.tsx │ │ ├── NavbarContact.tsx │ │ ├── NavbarCustomSelect.tsx │ │ ├── NavbarDefaultSelect.tsx │ │ ├── NavbarMenu.tsx │ │ ├── NavbarRanking.tsx │ │ ├── NavbarSelect.tsx │ │ ├── NavbarTitle.tsx │ │ ├── NavigateButton.tsx │ │ ├── ProjectElapsedTimeLegend.tsx │ │ ├── RankingItem.tsx │ │ ├── RankingTab.tsx │ │ ├── RegisterText.tsx │ │ └── ValidateTextInput.tsx │ ├── organism │ │ ├── MainData.tsx │ │ ├── MainResponse.tsx │ │ ├── MainTrafficChart.tsx │ │ ├── NavBar.tsx │ │ ├── NavbarSelectWrapper.tsx │ │ ├── ProjectDAU.tsx │ │ ├── ProjectElapsedTime.tsx │ │ ├── ProjectSuccessRate.tsx │ │ ├── ProjectTrafficChart.tsx │ │ ├── RankingList.tsx │ │ ├── RegisterDescription.tsx │ │ └── RegisterForm.tsx │ ├── page │ │ ├── ErrorPage.tsx │ │ ├── MainPage.tsx │ │ ├── ProjectDetailPage.tsx │ │ ├── ProjectPage.tsx │ │ ├── RankingPage.tsx │ │ └── RegisterPage.tsx │ └── template │ │ ├── DataLayout.tsx │ │ ├── MobileLayout.tsx │ │ └── NabvarLayout.tsx ├── constant │ ├── Chart.ts │ ├── Date.ts │ ├── Medals.ts │ ├── NavbarMenu.ts │ ├── NavbarSelect.ts │ ├── Path.ts │ ├── README.md │ └── Rank.ts ├── hook │ ├── README.md │ ├── api │ │ ├── useGroupNames.ts │ │ ├── useIsExistGroup.ts │ │ ├── useProjectDAU.ts │ │ ├── useProjectElapsedTime.ts │ │ ├── useProjectSuccessRate.ts │ │ ├── useProjectTraffic.ts │ │ ├── useRankData.ts │ │ ├── useRankings.ts │ │ ├── useTop5ResponseTime.ts │ │ ├── useTop5Traffic.ts │ │ └── useTotalDatas.ts │ ├── useAlert.ts │ ├── useCustomMutation.ts │ ├── useDarkMode.ts │ ├── useDefaultOption.ts │ ├── useIsMobile.ts │ ├── useNavContext.ts │ └── useRegisterForm.ts ├── index.css ├── index.tsx ├── router │ ├── README.md │ └── Router.tsx ├── store │ ├── NavbarStore.ts │ └── README.md ├── type │ ├── Date.ts │ ├── Navbar.ts │ ├── README.md │ ├── Rank.ts │ ├── RegisterForm.ts │ └── api.ts ├── util │ ├── README.md │ ├── Time.ts │ └── Validate.ts └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.github/ISSUE_TEMPLATE/🐞-bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F41E Bug Report" 3 | about: 버그 리포트 작성 4 | title: "[BUG] " 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 🔍 버그 설명 11 | 12 | 13 | ## 💻 재현 방법 14 | 1. A 페이지로 이동 15 | 2. B 버튼 클릭 16 | 3. C 입력 17 | 4. D 에러 발생 18 | 19 | ## ✔️ 기대한 결과 20 | 21 | 22 | ## ❌ 실제 결과 23 | 24 | 25 | ## 🔍 환경 정보 26 | - OS: [e.g. Windows 10] 27 | - Browser: [e.g. Chrome 120.0] 28 | - Version: [e.g. 1.0.0] 29 | 30 | ## 📎 추가 정보 31 | 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/📋-task.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F4D7 Task" 3 | about: 테스크 이슈 생성 4 | title: "[TASK]" 5 | labels: task 6 | assignees: '' 7 | 8 | --- 9 | 10 | # 태스크: [#101] 소셜 로그인 UI 구현 11 | 12 | ## 📋 태스크 정보 13 | - 상위 스토리: #100 소셜 로그인 구현 14 | - 담당자: @frontend-dev 15 | - 예상 소요시간: 8시간 16 | - 우선순위: High 17 | 18 | ## 📝 태스크 설명 19 | 20 | ## ✅ 수행 작업 목록 21 | - [ ] 로그인 버튼 컴포넌트 제작 22 | 23 | ## 🛠 기술 요구사항 24 | - React 18 사용 25 | 26 | ## ✅ Definition of Done 27 | - [ ] 단위 테스트 작성 28 | - [ ] 코드 리뷰 완료 29 | 30 | ## 🔍 테스트 시나리오 31 | 1. 각 소셜 로그인 버튼 클릭 시 정상 작동 32 | 2. 로딩 상태 표시 확인 33 | 3. 에러 발생 시 적절한 피드백 표시 34 | 4. 모바일/데스크톱 레이아웃 확인 35 | 36 | ## 📌 참고사항 37 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/📗-story.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F4D7 Story" 3 | about: 스토리 이슈 생성 4 | title: "[STORY]" 5 | labels: stroy 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 📋 사용자 스토리 11 | 12 | - As a `[사용자 역할]` 13 | - I want to `[하고 싶은 행동]` 14 | - So that `[기대하는 가치/결과]` 15 | 16 | ## 🎯 목표 17 | 18 | 19 | ## 📋 상세 설명 20 | 21 | 22 | ## ✅ 완료 조건 (DoD) 23 | 24 | - [ ] 단위 테스트 작성 완료 25 | - [ ] 인수 테스트 작성 완료 26 | - [ ] 코드 리뷰 완료 27 | - [ ] 문서화 완료 28 | 29 | ## 🔍 인수 기준 (Acceptance Criteria) 30 | 31 | ### Scenario 1: [시나리오 제목] 32 | - Given: [전제 조건] 33 | - When: [행동/이벤트] 34 | - Then: [기대 결과] 35 | 36 | ## 📊 규모 추정 37 | - Story Points: 38 | - 예상 소요 시간: 39 | 40 | ## 🔗 연관관계 41 | ### 📦 Epic 42 | - Epic: #이슈번호 43 | 44 | ## 🚀 하위 태스크 목록 45 | - [ ] #issue 46 | - [ ] #issue 47 | - [ ] #issue 48 | 49 | ## 🎨 UI/UX 50 | 51 | 52 | ## 📎 참고 자료 53 | 54 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/📚-documentation.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F4DA Documentation" 3 | about: 문서 작성 및 수정 4 | title: "[DOCS] " 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 📑 문서 유형 11 | 12 | 13 | ## 📝 작업 내용 14 | 15 | 16 | ## ✅ 체크리스트 17 | - [ ] 기술적 정확성 검토 18 | - [ ] 맞춤법/문법 검토 19 | - [ ] 포맷팅 검토 20 | - [ ] 예제 코드 검증 21 | 22 | ## 🔍 참고 자료 23 | 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/📦-epic.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F4E6 Epic" 3 | about: 에픽 이슈 생성 4 | title: "[EPIC] " 5 | labels: epic 6 | assignees: '' 7 | 8 | --- 9 | ## 📋 에픽 개요 10 | 11 | 12 | ## 🎯 목표 13 | 14 | 15 | ## 📑 포함된 스토리 16 | 17 | 18 | 19 | ## 📊 완료 기준 20 | 21 | 22 | ## ⏰ 일정 23 | - 시작 예정일: YYYY-MM-DD 24 | - 종료 예정일: YYYY-MM-DD 25 | 26 | ## 📎 관련 자료 27 | 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/🔧-refactor.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F527 Refactor" 3 | about: 코드 리팩터링 제안 4 | title: "[REFACTOR]" 5 | labels: refactor 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 🔧 리팩토링 대상 11 | 12 | 13 | ## 🎯 개선 목적 14 | 15 | 16 | ## 📝 개선 방안 17 | 18 | 19 | ## ✅ 체크리스트 20 | - [ ] 기존 기능 동작 보장 21 | - [ ] 테스트 코드 작성/수정 22 | - [ ] 성능 영향도 검토 23 | 24 | ## 📊 예상 결과 25 | 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/🚀-feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F680 Feature Request" 3 | about: 새로운 기능 제안 4 | title: "[FEAT] " 5 | labels: feature, task 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 📝 기능 설명 11 | 12 | 13 | ## 🎯 해결하려는 문제 14 | 15 | 16 | ## 🛠 구현 방안 17 | 18 | 19 | ## ✅ 작업 체크리스트 20 | - [ ] 작업 1 21 | - [ ] 작업 2 22 | 23 | ## 📎 참고 자료 24 | 25 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ### 👀 관련 이슈 2 | 3 | # 4 | 5 | ### ✨ 작업한 내용 6 | 7 | 8 | 9 | ### 🌀 PR Point 10 | 11 | 12 | 13 | ### 🍰 참고사항 14 | 15 | 16 | 17 | ### 📷 스크린샷 또는 GIF 18 | -------------------------------------------------------------------------------- /backend/console-server/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | .git 4 | .gitignore 5 | Dockerfile 6 | docker-compose.yml 7 | .env -------------------------------------------------------------------------------- /backend/console-server/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "printWidth": 100, 6 | "tabWidth": 4 7 | } -------------------------------------------------------------------------------- /backend/console-server/Dockerfile: -------------------------------------------------------------------------------- 1 | # 빌드 스테이지 2 | FROM node:22-alpine AS build 3 | 4 | WORKDIR /usr/src/app 5 | 6 | # 패키지 파일 복사 및 의존성 설치 7 | COPY package*.json ./ 8 | RUN npm install 9 | 10 | # 소스 코드 복사 및 빌드 11 | COPY . . 12 | RUN npm run build 13 | 14 | # 런타임 스테이지 15 | FROM node:22-alpine 16 | 17 | WORKDIR /usr/src/app 18 | 19 | RUN apk add --no-cache tzdata 20 | 21 | # 프로덕션 의존성 설치 22 | COPY package*.json ./ 23 | RUN npm install --production 24 | 25 | # 빌드 결과물 복사 26 | COPY --from=build /usr/src/app/dist ./dist 27 | 28 | CMD ["node", "./dist/main.js"] -------------------------------------------------------------------------------- /backend/console-server/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | 4 | redis: 5 | image: redis:latest 6 | container_name: app-redis 7 | restart: always 8 | ports: 9 | - "6379:6379" 10 | networks: 11 | - app-network 12 | 13 | nginx: 14 | image: nginx:latest 15 | ports: 16 | - "80:80" 17 | - "443:443" 18 | volumes: 19 | - ./nginx.conf:/etc/nginx/nginx.conf 20 | - /etc/letsencrypt:/etc/letsencrypt 21 | depends_on: 22 | - server-blue 23 | - server-green 24 | networks: 25 | - app-network 26 | 27 | server-blue: 28 | image: ghcr.io/boostcampwm-2024/web35-watchducks/backend/console-server:latest 29 | container_name: app-server-blue-1 30 | environment: 31 | - TZ=Asia/Seoul 32 | - NODE_ENV=production 33 | - PORT=3001 34 | restart: always 35 | volumes: 36 | - ./.env:/usr/src/app/.env 37 | healthcheck: 38 | test: ["CMD", "nc", "-z", "localhost", "3001"] 39 | interval: 10s 40 | timeout: 2s 41 | retries: 5 42 | networks: 43 | - app-network 44 | 45 | server-green: 46 | image: ghcr.io/boostcampwm-2024/web35-watchducks/backend/console-server:latest 47 | container_name: app-server-green-1 48 | environment: 49 | - TZ=Asia/Seoul 50 | - NODE_ENV=production 51 | - PORT=3002 52 | restart: always 53 | volumes: 54 | - ./.env:/usr/src/app/.env 55 | healthcheck: 56 | test: ["CMD", "nc", "-z", "localhost", "3002"] 57 | interval: 10s 58 | timeout: 2s 59 | retries: 5 60 | networks: 61 | - app-network 62 | 63 | networks: 64 | app-network: 65 | name: app-network 66 | driver: bridge -------------------------------------------------------------------------------- /backend/console-server/ecosystem.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | apps: [ 3 | { 4 | name: 'console-server', 5 | script: 'dist/main.js', // 빌드된 애플리케이션 진입점 6 | instances: 'max', // CPU 코어 수에 따라 인스턴스 수 자동 조절 7 | exec_mode: 'cluster', 8 | }, 9 | ], 10 | }; 11 | -------------------------------------------------------------------------------- /backend/console-server/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true, 7 | "assets": [ 8 | { 9 | "include": "mail/templates/**/*" 10 | } 11 | ] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /backend/console-server/nginx.conf: -------------------------------------------------------------------------------- 1 | http { 2 | upstream console_server { 3 | server server-blue:3001 weight=1; 4 | server server-green:3002 weight=1; 5 | } 6 | 7 | # http를 https로 리디렉션 8 | server { 9 | listen 80; 10 | server_name watchducks-test.store; 11 | 12 | location / { 13 | return 301 https://$host$request_uri; 14 | } 15 | } 16 | 17 | server { 18 | listen 443 ssl; 19 | server_name watchducks-test.store; 20 | 21 | ssl_certificate /etc/letsencrypt/live/watchducks-test.store/fullchain.pem; 22 | ssl_certificate_key /etc/letsencrypt/live/watchducks-test.store/privkey.pem; 23 | ssl_protocols TLSv1.2 TLSv1.3; 24 | ssl_ciphers HIGH:!aNULL:!MD5; 25 | 26 | location / { 27 | proxy_pass http://console_server; 28 | proxy_set_header Host $host; 29 | proxy_set_header X-Real-IP $remote_addr; 30 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 31 | proxy_set_header X-Forwarded-Proto $scheme; 32 | } 33 | } 34 | } 35 | 36 | events{} -------------------------------------------------------------------------------- /backend/console-server/src/app.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import type { TestingModule } from '@nestjs/testing'; 2 | import { Test } from '@nestjs/testing'; 3 | import { AppController } from './app.controller'; 4 | import { AppService } from './app.service'; 5 | 6 | describe('AppController', () => { 7 | let appController: AppController; 8 | 9 | beforeEach(async () => { 10 | const app: TestingModule = await Test.createTestingModule({ 11 | controllers: [AppController], 12 | providers: [AppService], 13 | }).compile(); 14 | 15 | appController = app.get(AppController); 16 | }); 17 | 18 | describe('root', () => { 19 | it('should return "Hello World!"', () => { 20 | expect(appController.getHello()).toBe('Hello World!'); 21 | }); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /backend/console-server/src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { AppService } from './app.service'; 3 | 4 | @Controller() 5 | export class AppController { 6 | constructor(private readonly appService: AppService) {} 7 | 8 | @Get() 9 | getHello() { 10 | return this.appService.getHello(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /backend/console-server/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { ProjectModule } from './project/project.module'; 5 | import { AppController } from './app.controller'; 6 | import { AppService } from './app.service'; 7 | import { MailModule } from './mail/mail.module'; 8 | import typeOrmConfig from './config/typeorm.config'; 9 | import mailerConfig from './config/mailer.config'; 10 | import { ClickhouseModule } from './clickhouse/clickhouse.module'; 11 | import { LogModule } from './log/log.module'; 12 | import clickhouseConfig from './config/clickhouse.config'; 13 | import { CacheModule } from '@nestjs/cache-manager'; 14 | import redisConfig from './config/redis.config'; 15 | 16 | @Module({ 17 | imports: [ 18 | ConfigModule.forRoot({ isGlobal: true, load: [mailerConfig, clickhouseConfig] }), 19 | TypeOrmModule.forRootAsync(typeOrmConfig.asProvider()), 20 | ClickhouseModule, 21 | CacheModule.registerAsync({ 22 | isGlobal: true, 23 | ...redisConfig.asProvider(), 24 | }), 25 | MailModule, 26 | ProjectModule, 27 | LogModule, 28 | ], 29 | controllers: [AppController], 30 | providers: [AppService], 31 | }) 32 | export class AppModule {} 33 | -------------------------------------------------------------------------------- /backend/console-server/src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class AppService { 5 | getHello(): string { 6 | return 'Hello World!'; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /backend/console-server/src/clickhouse/clickhouse.module.ts: -------------------------------------------------------------------------------- 1 | import { Clickhouse } from './clickhouse'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { Module } from '@nestjs/common'; 4 | 5 | @Module({ 6 | imports: [ConfigModule], 7 | providers: [Clickhouse], 8 | exports: [Clickhouse], 9 | }) 10 | export class ClickhouseModule {} 11 | -------------------------------------------------------------------------------- /backend/console-server/src/clickhouse/core/clickhouse.error.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '@nestjs/common'; 2 | 3 | export class ClickhouseError extends Error { 4 | constructor( 5 | message: string, 6 | originalError?: Error, 7 | private readonly logger = new Logger(ClickhouseError.name, { timestamp: true }), 8 | ) { 9 | super(message); 10 | 11 | this.logger.error( 12 | `${this.name}: ${message}`, 13 | originalError ? { originalError } : undefined, 14 | ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /backend/console-server/src/clickhouse/query-builder/time-series-query-builder.error.ts: -------------------------------------------------------------------------------- 1 | import { ClickhouseError } from '../core/clickhouse.error'; 2 | 3 | export class TimeSeriesQueryBuilderError extends ClickhouseError { 4 | constructor(message: string, error?: Error) { 5 | super(message, error); 6 | super.name = 'TimeSeriesQueryBuilderError'; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /backend/console-server/src/clickhouse/util/clickhouse-client.error.ts: -------------------------------------------------------------------------------- 1 | import { ClickhouseError } from '../core/clickhouse.error'; 2 | 3 | export class ClickhouseClientError extends ClickhouseError { 4 | constructor(message: string, error?: Error) { 5 | super(message, error); 6 | super.name = 'ClickhouseClientError'; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /backend/console-server/src/clickhouse/util/map-filter-condition.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web35-watchducks/b94a0865ef9d8b824d58938dedbc3c85e68aa4d4/backend/console-server/src/clickhouse/util/map-filter-condition.ts -------------------------------------------------------------------------------- /backend/console-server/src/clickhouse/util/metric-expressions.ts: -------------------------------------------------------------------------------- 1 | export const metricExpressions: Record = { 2 | avg: (metric: string) => `avg(${metric}) as avg_${metric}`, 3 | count: () => `toUInt32(count()) as count`, 4 | sum: (metric: string) => `sum(${metric}) as sum_${metric}`, 5 | min: (metric: string) => `min(${metric}) as min_${metric}`, 6 | max: (metric: string) => `max(${metric}) as max_${metric}`, 7 | rate: (metric: string) => `(sum(${metric}) / count(*)) * 100 as ${metric}_rate`, 8 | }; 9 | 10 | export type MetricAggregationType = keyof typeof metricExpressions; 11 | 12 | export type MetricFunction = (metric: string) => string; 13 | -------------------------------------------------------------------------------- /backend/console-server/src/common/cache/cache.constant.ts: -------------------------------------------------------------------------------- 1 | export const CACHE_REFRESH_THRESHOLD_METADATA = 'CACHE_REFRESH_THRESHOLD'; 2 | 3 | export const FIFTEEN_SECONDS = 15 * 1000; 4 | export const THIRTY_SECONDS = 30 * 1000; 5 | export const ONE_MINUTE = 60 * 1000; 6 | export const ONE_MINUTE_HALF = 3 * 30 * 1000; 7 | export const THREE_MINUTES = 3 * 60 * 1000; 8 | export const FIVE_MINUTES = 5 * 60 * 1000; 9 | export const TEN_MINUTES = 10 * 60 * 1000; 10 | export const HALF_HOUR = 30 * 60 * 1000; 11 | export const ONE_HOUR = 60 * 60 * 1000; 12 | -------------------------------------------------------------------------------- /backend/console-server/src/common/cache/cache.decorator.ts: -------------------------------------------------------------------------------- 1 | import type { ExecutionContext } from '@nestjs/common'; 2 | import { SetMetadata } from '@nestjs/common'; 3 | import { CacheTTL } from '@nestjs/cache-manager'; 4 | import { CACHE_REFRESH_THRESHOLD_METADATA } from './cache.constant'; 5 | 6 | const calculateMillisecondsUntilMidnight = () => { 7 | const now = new Date(); 8 | const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1); 9 | return midnight.getTime() - now.getTime(); 10 | }; 11 | 12 | export const CacheTTLUntilMidnight = () => { 13 | return CacheTTL((_ctx: ExecutionContext) => calculateMillisecondsUntilMidnight()); 14 | }; 15 | 16 | type CacheRefreshThresholdFactory = (ctx: ExecutionContext) => Promise | number; 17 | 18 | export const CacheRefreshThreshold = (threshold: number | CacheRefreshThresholdFactory) => 19 | SetMetadata(CACHE_REFRESH_THRESHOLD_METADATA, threshold); 20 | 21 | export const CacheRefreshAtMidnight = () => { 22 | return CacheRefreshThreshold((_ctx: ExecutionContext) => calculateMillisecondsUntilMidnight()); 23 | }; 24 | -------------------------------------------------------------------------------- /backend/console-server/src/common/cache/index.ts: -------------------------------------------------------------------------------- 1 | export * from './cache.constant'; 2 | export * from './cache.decorator'; 3 | export * from './cache.interceptor'; 4 | -------------------------------------------------------------------------------- /backend/console-server/src/config/clickhouse.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | 3 | export default registerAs('clickhouse', () => ({ 4 | clickhouse: { 5 | url: process.env.CLICKHOUSE_URL ?? 'http://localhost:8123', 6 | username: process.env.CLICKHOUSE_USER ?? 'default', 7 | database: process.env.CLICKHOUSE_DATABASE ?? 'default', 8 | password: process.env.CLICKHOUSE_PASSWORD ?? '', 9 | request_timeout: 30 * 1000, // 30s 10 | }, 11 | })); 12 | -------------------------------------------------------------------------------- /backend/console-server/src/config/mailer.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter'; 3 | import { join } from 'path'; 4 | 5 | export default registerAs('mailer', () => ({ 6 | transport: { 7 | host: process.env.EMAIL_HOST, 8 | port: Number(process.env.EMAIL_PORT) || 587, 9 | secure: false, 10 | auth: { 11 | user: process.env.EMAIL_USER, 12 | pass: process.env.EMAIL_PASS, 13 | }, 14 | }, 15 | defaults: { 16 | from: process.env.EMAIL_FROM, 17 | }, 18 | template: { 19 | dir: join(__dirname, '../mail/templates'), 20 | adapter: new HandlebarsAdapter(), 21 | options: { 22 | strict: true, 23 | }, 24 | }, 25 | nameServers: (process.env.NAME_SERVERS || '').split(',').map((item) => item.trim()), 26 | })); 27 | -------------------------------------------------------------------------------- /backend/console-server/src/config/redis.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | import { redisStore } from 'cache-manager-redis-yet'; 3 | 4 | export default registerAs('redisConfig', async () => { 5 | const store = await redisStore({ 6 | socket: { 7 | host: process.env.REDIS_HOST || 'localhost', 8 | port: Number(process.env.REDIS_PORT) || 6379, 9 | }, 10 | ttl: 60 * 1000, 11 | }); 12 | 13 | return { 14 | store: store, 15 | }; 16 | }); 17 | -------------------------------------------------------------------------------- /backend/console-server/src/config/typeorm.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | import type { TypeOrmModuleOptions } from '@nestjs/typeorm'; 3 | 4 | export default registerAs('typeOrmConfig', () => { 5 | const isDevEnv = ['development', 'test', 'debug'].includes(process.env.NODE_ENV as string); 6 | return ( 7 | isDevEnv 8 | ? { 9 | type: 'mysql', 10 | host: process.env.DEV_MYSQL_HOST, 11 | port: process.env.DEV_MYSQL_PORT, 12 | username: process.env.DEV_MYSQL_USERNAME, 13 | password: process.env.DEV_MYSQL_PASSWORD, 14 | database: process.env.DEV_MYSQL_DATABASE, 15 | autoLoadEntities: true, 16 | synchronize: false, 17 | } 18 | : { 19 | type: 'mysql', 20 | host: process.env.DB_HOST, 21 | port: process.env.DB_PORT, 22 | username: process.env.DB_USERNAME, 23 | password: process.env.DB_PASSWORD, 24 | database: process.env.DB_NAME, 25 | autoLoadEntities: true, 26 | synchronize: false, 27 | } 28 | ) as TypeOrmModuleOptions; 29 | }); 30 | -------------------------------------------------------------------------------- /backend/console-server/src/log/analytics/analytics.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, HttpCode, HttpStatus, Query, UseInterceptors } from '@nestjs/common'; 2 | import { ApiOperation, ApiResponse } from '@nestjs/swagger'; 3 | import { GetProjectDauResponseDto } from './dto/get-project-dau-response.dto'; 4 | import { GetDAUsByProjectDto } from './dto/get-project-dau.dto'; 5 | import { AnalyticsService } from './analytics.service'; 6 | import { 7 | CacheRefreshThreshold, 8 | CustomCacheInterceptor, 9 | ONE_MINUTE, 10 | THREE_MINUTES, 11 | } from '../../common/cache'; 12 | import { CacheTTL } from '@nestjs/cache-manager'; 13 | 14 | @Controller('log/analytics') 15 | @UseInterceptors(CustomCacheInterceptor) 16 | @CacheTTL(THREE_MINUTES) 17 | @CacheRefreshThreshold(ONE_MINUTE) 18 | export class AnalyticsController { 19 | constructor(private readonly analyticService: AnalyticsService) {} 20 | 21 | @Get('/dau') 22 | @HttpCode(HttpStatus.OK) 23 | @ApiOperation({ 24 | summary: '프로젝트별 최근 30일 DAU 조회', 25 | description: '이름을 받은 프로젝트의 최근 30일간 DAU를 반환합니다.', 26 | }) 27 | @ApiResponse({ 28 | status: HttpStatus.OK, 29 | description: '프로젝트의 30일간 DAU 정보가 정상적으로 반환됨.', 30 | type: GetProjectDauResponseDto, 31 | }) 32 | async getDAUsByProject(@Query() getDAUsByProjectDto: GetDAUsByProjectDto) { 33 | return await this.analyticService.getDAUsByProject(getDAUsByProjectDto); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /backend/console-server/src/log/analytics/analytics.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { Project } from '../../project/entities/project.entity'; 4 | import { ClickhouseModule } from '../../clickhouse/clickhouse.module'; 5 | import { Clickhouse } from '../../clickhouse/clickhouse'; 6 | import { AnalyticsController } from './analytics.controller'; 7 | import { AnalyticsService } from './analytics.service'; 8 | import { AnalyticsRepository } from './analytics.repository'; 9 | 10 | @Module({ 11 | imports: [TypeOrmModule.forFeature([Project]), ClickhouseModule], 12 | providers: [AnalyticsService, AnalyticsRepository, Clickhouse], 13 | controllers: [AnalyticsController], 14 | }) 15 | export class AnalyticsModule {} 16 | -------------------------------------------------------------------------------- /backend/console-server/src/log/analytics/analytics.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Clickhouse } from '../../clickhouse/clickhouse'; 3 | import { TimeSeriesQueryBuilder } from '../../clickhouse/query-builder/time-series.query-builder'; 4 | import { DauMetric } from './metric/dau.metric'; 5 | 6 | @Injectable() 7 | export class AnalyticsRepository { 8 | constructor(private readonly clickhouse: Clickhouse) {} 9 | async findDAUsByProject(domain: string, start: Date, end: Date) { 10 | const { query, params } = new TimeSeriesQueryBuilder() 11 | .metrics([ 12 | { name: `toDate(timestamp) as date` }, 13 | { name: `count(DISTINCT user_ip) as dau` }, 14 | ]) 15 | .from('http_log') 16 | .filter({ host: domain }) 17 | .timeBetween(start, end, 'date') 18 | .groupBy(['date']) 19 | .orderBy(['date']) 20 | .build(); 21 | 22 | return await this.clickhouse.query(query, params); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /backend/console-server/src/log/analytics/dto/get-project-dau-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { Exclude, Expose, Type } from 'class-transformer'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | 4 | export class DauRecord { 5 | @ApiProperty({ 6 | example: '2023-10-01', 7 | description: '해당하는 날짜', 8 | }) 9 | @Expose() 10 | @Type(() => String) 11 | date: string; 12 | 13 | @ApiProperty({ 14 | example: 12345, 15 | description: '해당 날짜의 DAU 값', 16 | }) 17 | @Expose() 18 | @Type(() => Number) 19 | dau: number = 0; 20 | } 21 | 22 | @Exclude() 23 | export class GetProjectDauResponseDto { 24 | @ApiProperty({ 25 | example: 'my-project', 26 | description: '프로젝트 이름', 27 | }) 28 | @Expose() 29 | projectName: string; 30 | 31 | @ApiProperty({ 32 | type: [DauRecord], 33 | description: '최근 30일간의 날짜와 DAU 값', 34 | }) 35 | @Expose() 36 | @Type(() => DauRecord) 37 | dauRecords: DauRecord[]; 38 | } 39 | -------------------------------------------------------------------------------- /backend/console-server/src/log/analytics/dto/get-project-dau.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsString } from 'class-validator'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | 4 | export class GetDAUsByProjectDto { 5 | @ApiProperty({ 6 | example: 'watchducks', 7 | description: '프로젝트 이름', 8 | }) 9 | @IsNotEmpty() 10 | @IsString() 11 | projectName: string; 12 | } 13 | -------------------------------------------------------------------------------- /backend/console-server/src/log/analytics/metric/dau.metric.ts: -------------------------------------------------------------------------------- 1 | import { Expose, Type } from 'class-transformer'; 2 | import { IsNumber } from 'class-validator'; 3 | 4 | export class DauMetric { 5 | @Expose() 6 | @Type(() => Date) 7 | date: Date; 8 | 9 | @Expose() 10 | @Type(() => Number) 11 | @IsNumber() 12 | dau: number; 13 | } 14 | -------------------------------------------------------------------------------- /backend/console-server/src/log/elapsed-time/dto/get-avg-elapsed-time-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Expose } from 'class-transformer'; 3 | 4 | export class GetAvgElapsedTimeResponseDto { 5 | @ApiProperty({ 6 | example: '35.353', 7 | description: '해당 기수의 전체 트래픽 평균 응답시간', 8 | }) 9 | @Expose() 10 | avgResponseTime: number; 11 | } 12 | -------------------------------------------------------------------------------- /backend/console-server/src/log/elapsed-time/dto/get-avg-elapsed-time.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsNumber } from 'class-validator'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { Expose, Type } from 'class-transformer'; 4 | 5 | export class GetAvgElapsedTimeDto { 6 | @IsNotEmpty() 7 | @IsNumber() 8 | @ApiProperty({ 9 | example: 9, 10 | description: '기수', 11 | type: 'number', 12 | }) 13 | @Type(() => Number) 14 | @Expose() 15 | generation: number; 16 | } 17 | -------------------------------------------------------------------------------- /backend/console-server/src/log/elapsed-time/dto/get-path-elapsed-time-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { Exclude, Expose, Type } from 'class-transformer'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | 4 | export class PathResponseTime { 5 | @ApiProperty({ 6 | example: '/api/v1/resource', 7 | description: '사용자의 요청 경로', 8 | }) 9 | @Expose() 10 | path: string; 11 | 12 | @ApiProperty({ 13 | example: 123.45, 14 | description: '해당 경로의 평균 응답 소요 시간 (ms).', 15 | }) 16 | @Expose() 17 | avgResponseTime: number; 18 | } 19 | 20 | @Exclude() 21 | export class GetPathElapsedTimeResponseDto { 22 | @ApiProperty({ 23 | example: 'watchducks', 24 | description: '프로젝트 이름', 25 | }) 26 | @Expose() 27 | projectName: string; 28 | 29 | @ApiProperty({ 30 | type: [PathResponseTime], 31 | description: '프로젝트의 가장 빠른 응답 경로 배열', 32 | }) 33 | @Expose() 34 | @Type(() => PathResponseTime) 35 | fastestPaths: PathResponseTime[]; 36 | 37 | @ApiProperty({ 38 | type: [PathResponseTime], 39 | description: '프로젝트의 가장 느린 응답 경로 배열', 40 | }) 41 | @Expose() 42 | @Type(() => PathResponseTime) 43 | slowestPaths: PathResponseTime[]; 44 | } 45 | -------------------------------------------------------------------------------- /backend/console-server/src/log/elapsed-time/dto/get-path-elapsed-time.rank.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsString } from 'class-validator'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | 4 | export class GetPathElapsedTimeRank { 5 | @IsNotEmpty() 6 | @IsString() 7 | @ApiProperty({ 8 | description: '프로젝트명', 9 | example: 'watchducks', 10 | required: true, 11 | }) 12 | projectName: string; 13 | } 14 | -------------------------------------------------------------------------------- /backend/console-server/src/log/elapsed-time/dto/get-top5-elapsed-time.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Type } from 'class-transformer'; 3 | import { IsNumber } from 'class-validator'; 4 | 5 | export class GetTop5ElapsedTimeDto { 6 | @IsNumber() 7 | @Type(() => Number) 8 | @ApiProperty({ 9 | description: '기수', 10 | example: 5, 11 | required: true, 12 | }) 13 | generation: number; 14 | } 15 | -------------------------------------------------------------------------------- /backend/console-server/src/log/elapsed-time/dto/get-top5-elapsed.time.ts: -------------------------------------------------------------------------------- 1 | import { Expose, Type } from 'class-transformer'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | 4 | export class ProjectElapsedTime { 5 | @ApiProperty({ 6 | example: 'watchducks', 7 | description: '해당 프로젝트명', 8 | }) 9 | @Expose() 10 | projectName: string; 11 | 12 | @ApiProperty({ 13 | example: 123.45, 14 | description: '평균 응답 소요 시간 (ms).', 15 | }) 16 | @Expose() 17 | @Type(() => Number) 18 | avgResponseTime: number; 19 | } 20 | 21 | export class GetTop5ElapsedTime { 22 | @ApiProperty({ 23 | type: [ProjectElapsedTime], 24 | description: '프로젝트별 응답 속도 배열', 25 | }) 26 | @Type(() => ProjectElapsedTime) 27 | projectSpeedRank: ProjectElapsedTime[]; 28 | } 29 | -------------------------------------------------------------------------------- /backend/console-server/src/log/elapsed-time/elapsed-time.module.ts: -------------------------------------------------------------------------------- 1 | import { ElapsedTimeController } from './elapsed-time.controller'; 2 | import { ElapsedTimeService } from './elapsed-time.service'; 3 | import { Module } from '@nestjs/common'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { Project } from '../../project/entities/project.entity'; 6 | import { ClickhouseModule } from '../../clickhouse/clickhouse.module'; 7 | import { Clickhouse } from '../../clickhouse/clickhouse'; 8 | import { ElapsedTimeRepository } from './elapsed-time.repository'; 9 | 10 | @Module({ 11 | imports: [TypeOrmModule.forFeature([Project]), ClickhouseModule], 12 | controllers: [ElapsedTimeController], 13 | providers: [ElapsedTimeService, ElapsedTimeRepository, Clickhouse], 14 | }) 15 | export class ElapsedTimeModule {} 16 | -------------------------------------------------------------------------------- /backend/console-server/src/log/elapsed-time/metric/avg-elapsed-time.metric.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber } from 'class-validator'; 2 | import { Type } from 'class-transformer'; 3 | 4 | export class AvgElapsedTimeMetric { 5 | @Type(() => Number) 6 | @IsNumber() 7 | avg_elapsed_time: number; 8 | } 9 | -------------------------------------------------------------------------------- /backend/console-server/src/log/elapsed-time/metric/host-avg-elapsed-time.metric.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber, IsString } from 'class-validator'; 2 | import { Type } from 'class-transformer'; 3 | 4 | export class HostAvgElapsedTimeMetric { 5 | @IsString() 6 | host: string; 7 | 8 | @Type(() => Number) 9 | @IsNumber() 10 | avg_elapsed_time: number; 11 | } 12 | -------------------------------------------------------------------------------- /backend/console-server/src/log/elapsed-time/metric/path-elapsed-time.metric.ts: -------------------------------------------------------------------------------- 1 | import { IsString } from 'class-validator'; 2 | 3 | export class PathElapsedTimeMetric { 4 | @IsString() 5 | avg_elapsed_time: string; 6 | 7 | @IsString() 8 | path: string; 9 | } 10 | -------------------------------------------------------------------------------- /backend/console-server/src/log/log.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { Project } from '../project/entities/project.entity'; 4 | import { ClickhouseModule } from '../clickhouse/clickhouse.module'; 5 | import { ElapsedTimeModule } from './elapsed-time/elapsed-time.module'; 6 | import { TrafficModule } from './traffic/traffic.module'; 7 | import { SuccessRateModule } from './success-rate/success-rate.module'; 8 | import { AnalyticsModule } from './analytics/analytics.module'; 9 | import { RankModule } from './rank/rank.module'; 10 | 11 | @Module({ 12 | imports: [ 13 | TypeOrmModule.forFeature([Project]), 14 | ClickhouseModule, 15 | ElapsedTimeModule, 16 | TrafficModule, 17 | SuccessRateModule, 18 | AnalyticsModule, 19 | RankModule, 20 | ], 21 | }) 22 | export class LogModule {} 23 | -------------------------------------------------------------------------------- /backend/console-server/src/log/rank/dto/get-dau-rank-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsNumber, IsString } from 'class-validator'; 3 | import { Expose, Type } from 'class-transformer'; 4 | 5 | export class DAURank { 6 | @IsString() 7 | projectName: string; 8 | 9 | @Type(() => Number) 10 | @IsNumber() 11 | value: number; 12 | } 13 | 14 | export class GetDAURankResponseDto { 15 | @ApiProperty({ 16 | description: '총 갯수', 17 | example: 30, 18 | }) 19 | @IsNumber() 20 | total: number; 21 | 22 | @ApiProperty({ 23 | description: 'dau 순위', 24 | example: [ 25 | { 26 | projectName: 'test059', 27 | value: 12345, 28 | }, 29 | { 30 | projectName: 'test007', 31 | value: 234234, 32 | }, 33 | { 34 | projectName: 'test079', 35 | value: 21212, 36 | }, 37 | ], 38 | }) 39 | @Expose() 40 | rank: DAURank[]; 41 | } 42 | -------------------------------------------------------------------------------- /backend/console-server/src/log/rank/dto/get-dau-rank.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsNotEmpty } from 'class-validator'; 3 | import { Type } from 'class-transformer'; 4 | 5 | export class GetDAURankDto { 6 | @ApiProperty({ description: '기수', example: 9 }) 7 | @Type(() => Number) 8 | @IsNotEmpty() 9 | generation: number; 10 | } 11 | -------------------------------------------------------------------------------- /backend/console-server/src/log/rank/dto/get-elapsed-time-rank-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Expose } from 'class-transformer'; 3 | import { IsNumber, IsString } from 'class-validator'; 4 | 5 | export class ElapsedTimeRank { 6 | @IsString() 7 | @Expose() 8 | projectName: string; 9 | 10 | @IsNumber() 11 | @Expose() 12 | value: number; 13 | } 14 | 15 | export class GetElapsedTimeRankResponseDto { 16 | @ApiProperty({ 17 | description: '총 갯수', 18 | example: 30, 19 | }) 20 | @IsNumber() 21 | total: number; 22 | 23 | @ApiProperty({ 24 | description: '응답 소요 시간 짧은 순으로 정렬된 프로젝트명과 시간(ms)', 25 | example: [ 26 | { 27 | projectName: 'test059', 28 | value: 100, 29 | }, 30 | { 31 | projectName: 'test007', 32 | value: 110, 33 | }, 34 | { 35 | projectName: 'test079', 36 | value: 120, 37 | }, 38 | ], 39 | }) 40 | @Expose() 41 | rank: ElapsedTimeRank[]; 42 | } 43 | -------------------------------------------------------------------------------- /backend/console-server/src/log/rank/dto/get-elapsed-time-rank.dto.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer'; 2 | import { IsNumber } from 'class-validator'; 3 | import { ApiProperty } from '@nestjs/swagger'; 4 | 5 | export class GetElapsedTimeRankDto { 6 | @ApiProperty({ description: '기수', example: 9 }) 7 | @Type(() => Number) 8 | @IsNumber() 9 | generation: number; 10 | } 11 | -------------------------------------------------------------------------------- /backend/console-server/src/log/rank/dto/get-success-rate-rank-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Expose, Transform, Type } from 'class-transformer'; 3 | import { IsNumber, IsString } from 'class-validator'; 4 | 5 | export class SuccessRateRank { 6 | @IsString() 7 | projectName: string; 8 | 9 | @Type(() => Number) 10 | @Transform(({ value }) => Math.floor(value)) 11 | @IsNumber() 12 | value: number; 13 | } 14 | 15 | export class GetSuccessRateRankResponseDto { 16 | @ApiProperty({ 17 | description: '총 갯수', 18 | example: 30, 19 | }) 20 | @IsNumber() 21 | total: number; 22 | 23 | @ApiProperty({ 24 | description: '응답 성공률 순위', 25 | example: [ 26 | { 27 | projectName: 'test059', 28 | value: 98, 29 | }, 30 | { 31 | projectName: 'test007', 32 | value: 98, 33 | }, 34 | { 35 | projectName: 'test079', 36 | value: 98, 37 | }, 38 | ], 39 | }) 40 | @Expose() 41 | rank: SuccessRateRank[]; 42 | } 43 | -------------------------------------------------------------------------------- /backend/console-server/src/log/rank/dto/get-success-rate-rank.dto.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer'; 2 | import { IsNumber } from 'class-validator'; 3 | import { ApiProperty } from '@nestjs/swagger'; 4 | 5 | export class GetSuccessRateRankDto { 6 | @ApiProperty({ description: '기수', example: 9 }) 7 | @Type(() => Number) 8 | @IsNumber() 9 | generation: number; 10 | } 11 | -------------------------------------------------------------------------------- /backend/console-server/src/log/rank/dto/get-traffic-rank-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber, IsString } from 'class-validator'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | 4 | export class TrafficRank { 5 | @IsString() 6 | projectName: string; 7 | 8 | @IsNumber() 9 | value: number; 10 | } 11 | 12 | export class GetTrafficRankResponseDto { 13 | @ApiProperty({ 14 | description: '총 갯수', 15 | example: 30, 16 | }) 17 | @IsNumber() 18 | total: number; 19 | 20 | @ApiProperty({ 21 | description: '응답 성공률 순위', 22 | example: [ 23 | { 24 | projectName: 'test059', 25 | value: 10000, 26 | }, 27 | { 28 | projectName: 'test007', 29 | value: 9999, 30 | }, 31 | { 32 | projectName: 'test079', 33 | value: 9898, 34 | }, 35 | ], 36 | }) 37 | rank: TrafficRank[]; 38 | } 39 | -------------------------------------------------------------------------------- /backend/console-server/src/log/rank/dto/get-traffic-rank.dto.ts: -------------------------------------------------------------------------------- 1 | import { Expose, Type } from 'class-transformer'; 2 | import { IsNumber } from 'class-validator'; 3 | import { ApiProperty } from '@nestjs/swagger'; 4 | 5 | export class GetTrafficRankDto { 6 | @ApiProperty({ 7 | example: '9', 8 | }) 9 | @Type(() => Number) 10 | @IsNumber() 11 | @Expose() 12 | generation: number; 13 | } 14 | -------------------------------------------------------------------------------- /backend/console-server/src/log/rank/metric/host-count.metric.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer'; 2 | import { IsNumber, IsString } from 'class-validator'; 3 | 4 | export class HostCountMetric { 5 | @IsString() 6 | host: string; 7 | 8 | @Type(() => Number) 9 | @IsNumber() 10 | count: number; 11 | } 12 | -------------------------------------------------------------------------------- /backend/console-server/src/log/rank/metric/host-dau.metric.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber, IsString } from 'class-validator'; 2 | import { Expose, Type } from 'class-transformer'; 3 | 4 | export class HostDauMetric { 5 | @IsString() 6 | @Expose() 7 | host: string; 8 | 9 | @Type(() => Number) 10 | @IsNumber() 11 | @Expose() 12 | dau: number; 13 | } 14 | -------------------------------------------------------------------------------- /backend/console-server/src/log/rank/metric/host-elapsed-time.metric.ts: -------------------------------------------------------------------------------- 1 | export type HostElapsedTimeMetric = { 2 | host: string; 3 | avg_elapsed_time: number; 4 | }; 5 | -------------------------------------------------------------------------------- /backend/console-server/src/log/rank/metric/host-error-rate.metric.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer'; 2 | import { IsNumber, IsString } from 'class-validator'; 3 | 4 | export class HostErrorRateMetric { 5 | @IsString() 6 | host: string; 7 | 8 | @Type(() => Number) 9 | @IsNumber() 10 | is_error_rate: number; 11 | } 12 | -------------------------------------------------------------------------------- /backend/console-server/src/log/rank/rank.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ClickhouseModule } from '../../clickhouse/clickhouse.module'; 3 | import { RankService } from './rank.service'; 4 | import { RankRepository } from './rank.repository'; 5 | import { Clickhouse } from '../../clickhouse/clickhouse'; 6 | import { RankController } from './rank.controller'; 7 | import { TypeOrmModule } from '@nestjs/typeorm'; 8 | import { Project } from '../../project/entities/project.entity'; 9 | 10 | @Module({ 11 | imports: [TypeOrmModule.forFeature([Project]), ClickhouseModule], 12 | controllers: [RankController], 13 | providers: [RankService, RankRepository, Clickhouse], 14 | }) 15 | export class RankModule {} 16 | -------------------------------------------------------------------------------- /backend/console-server/src/log/success-rate/dto/get-project-success-rate-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { Expose, Type } from 'class-transformer'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | 4 | export class GetProjectSuccessRateResponseDto { 5 | @ApiProperty({ 6 | description: '프로젝트의 이름', 7 | example: 'watchducks', 8 | }) 9 | @Expose() 10 | projectName: string; 11 | @ApiProperty({ 12 | description: '프로젝트의 응답 성공률', 13 | example: 85.5, 14 | }) 15 | @Expose() 16 | @Type(() => Number) 17 | success_rate: number; 18 | } 19 | -------------------------------------------------------------------------------- /backend/console-server/src/log/success-rate/dto/get-project-success-rate.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString } from 'class-validator'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | 4 | export class GetProjectSuccessRateDto { 5 | @IsString() 6 | @ApiProperty({ 7 | description: '프로젝트 이름', 8 | example: 'watchducks', 9 | required: true, 10 | }) 11 | projectName: string; 12 | } 13 | -------------------------------------------------------------------------------- /backend/console-server/src/log/success-rate/dto/get-success-rate-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Expose } from 'class-transformer'; 3 | 4 | export class GetSuccessRateResponseDto { 5 | @ApiProperty({ 6 | example: 95.5, 7 | description: '응답 성공률 (%)', 8 | type: Number, 9 | }) 10 | @Expose() 11 | success_rate: number; 12 | } 13 | -------------------------------------------------------------------------------- /backend/console-server/src/log/success-rate/dto/get-success-rate.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Type } from 'class-transformer'; 3 | import { IsNumber } from 'class-validator'; 4 | 5 | export class GetSuccessRateDto { 6 | @IsNumber() 7 | @Type(() => Number) 8 | @ApiProperty({ 9 | description: '기수', 10 | example: 5, 11 | required: true, 12 | }) 13 | generation: number; 14 | } 15 | -------------------------------------------------------------------------------- /backend/console-server/src/log/success-rate/metric/error-rate.metric.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer'; 2 | import { IsNumber } from 'class-validator'; 3 | 4 | export class ErrorRateMetric { 5 | @Type(() => Number) 6 | @IsNumber() 7 | is_error_rate: number; 8 | } 9 | -------------------------------------------------------------------------------- /backend/console-server/src/log/success-rate/metric/success-rate.metric.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer'; 2 | import { IsNumber } from 'class-validator'; 3 | 4 | export class SuccessRateMetric { 5 | @Type(() => Number) 6 | @IsNumber() 7 | success_rate: number; 8 | } 9 | -------------------------------------------------------------------------------- /backend/console-server/src/log/success-rate/success-rate.module.ts: -------------------------------------------------------------------------------- 1 | import { SuccessRateController } from './success-rate.controller'; 2 | import { SuccessRateService } from './success-rate.service'; 3 | import { Module } from '@nestjs/common'; 4 | import { ClickhouseModule } from '../../clickhouse/clickhouse.module'; 5 | import { TypeOrmModule } from '@nestjs/typeorm'; 6 | import { Project } from '../../project/entities/project.entity'; 7 | import { SuccessRateRepository } from './success-rate.repository'; 8 | import { Clickhouse } from 'src/clickhouse/clickhouse'; 9 | 10 | @Module({ 11 | imports: [TypeOrmModule.forFeature([Project]), ClickhouseModule], 12 | controllers: [SuccessRateController], 13 | providers: [SuccessRateService, SuccessRateRepository, Clickhouse], 14 | }) 15 | export class SuccessRateModule {} 16 | -------------------------------------------------------------------------------- /backend/console-server/src/log/traffic/dto/get-traffic-by-generation-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Expose } from 'class-transformer'; 3 | 4 | export class GetTrafficByGenerationResponseDto { 5 | @ApiProperty({ 6 | example: 1500, 7 | description: '기수 별 트래픽 수', 8 | type: Number, 9 | }) 10 | @Expose() 11 | count: number; 12 | } 13 | -------------------------------------------------------------------------------- /backend/console-server/src/log/traffic/dto/get-traffic-by-generation.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber } from 'class-validator'; 2 | import { Type } from 'class-transformer'; 3 | import { ApiProperty } from '@nestjs/swagger'; 4 | 5 | export class GetTrafficByGenerationDto { 6 | @IsNumber() 7 | @Type(() => Number) 8 | @ApiProperty({ 9 | description: '기수', 10 | example: 5, 11 | required: true, 12 | }) 13 | generation: number; 14 | } 15 | -------------------------------------------------------------------------------- /backend/console-server/src/log/traffic/dto/get-traffic-by-project-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { Expose, Type } from 'class-transformer'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { ValidateNested } from 'class-validator'; 4 | 5 | export class TrafficCountByTimeUnit { 6 | @ApiProperty({ 7 | example: '2024-11-07 23:00:00', 8 | description: '시간 단위 별 타임스탬프', 9 | }) 10 | @Expose() 11 | timestamp: string; 12 | 13 | @ApiProperty({ 14 | example: 1500, 15 | description: '해당 타임스탬프의 트래픽 총량', 16 | }) 17 | @Expose() 18 | @Type(() => Number) 19 | count: number; 20 | } 21 | 22 | export class GetTrafficByProjectResponseDto { 23 | @ApiProperty({ 24 | example: 'watchducks', 25 | description: '프로젝트 이름', 26 | }) 27 | @Expose() 28 | projectName: string; 29 | 30 | @ApiProperty({ 31 | example: 'www.example.com', 32 | description: '도메인 주소', 33 | }) 34 | @Expose() 35 | domain: string; 36 | 37 | @ApiProperty({ 38 | example: 'day', 39 | description: '데이터 범위', 40 | }) 41 | @Expose() 42 | timeRange: string; 43 | 44 | @ApiProperty({ 45 | example: '30', 46 | description: '해당 기간 트래픽 총합', 47 | }) 48 | @Expose() 49 | total: number; 50 | 51 | @ApiProperty({ 52 | type: [TrafficCountByTimeUnit], 53 | description: '시간 범위 별 트래픽 데이터', 54 | }) 55 | @Expose() 56 | @Type(() => TrafficCountByTimeUnit) 57 | @ValidateNested() 58 | trafficData: TrafficCountByTimeUnit[]; 59 | } 60 | -------------------------------------------------------------------------------- /backend/console-server/src/log/traffic/dto/get-traffic-by-project.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsString } from 'class-validator'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { TIME_RANGE, TimeRange } from '../traffic.constant'; 4 | 5 | export class GetTrafficByProjectDto { 6 | @ApiProperty({ 7 | example: 'watchducks', 8 | description: '프로젝트 이름', 9 | }) 10 | @IsNotEmpty() 11 | @IsString() 12 | projectName: string; 13 | 14 | @ApiProperty({ 15 | example: 'day', 16 | description: '데이터 범위 (day, week, month)', 17 | enum: Object.values(TIME_RANGE), 18 | }) 19 | @IsNotEmpty() 20 | timeRange: TimeRange; 21 | } 22 | -------------------------------------------------------------------------------- /backend/console-server/src/log/traffic/dto/get-traffic-daily-difference-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Expose } from 'class-transformer'; 3 | import { IsNotEmpty, IsString } from 'class-validator'; 4 | 5 | export class GetTrafficDailyDifferenceResponseDto { 6 | @ApiProperty({ 7 | example: '+9100', 8 | description: '전일 대비 총 트래픽 증감량', 9 | type: String, 10 | }) 11 | @IsString() 12 | @IsNotEmpty() 13 | @Expose() 14 | traffic_daily_difference: string; 15 | } 16 | -------------------------------------------------------------------------------- /backend/console-server/src/log/traffic/dto/get-traffic-daily-difference.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber } from 'class-validator'; 2 | import { Type } from 'class-transformer'; 3 | import { ApiProperty } from '@nestjs/swagger'; 4 | 5 | export class GetTrafficDailyDifferenceDto { 6 | @IsNumber() 7 | @Type(() => Number) 8 | @ApiProperty({ 9 | description: '기수', 10 | example: 9, 11 | required: true, 12 | }) 13 | generation: number; 14 | } 15 | -------------------------------------------------------------------------------- /backend/console-server/src/log/traffic/dto/get-traffic-top5-chart-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { Expose } from 'class-transformer'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | 4 | export class TrafficTop5Chart { 5 | name: string; 6 | traffic: [string, string][]; 7 | } 8 | 9 | export class GetTrafficTop5ChartResponseDto { 10 | @ApiProperty({ 11 | example: [ 12 | { 13 | name: 'watchducks', 14 | traffic: [ 15 | ['2024-01-01 11:12:00', '100'], 16 | ['2024-01-02 11:13:00', '100'], 17 | ['2024-01-02 11:14:00', '100'], 18 | ['2024-01-02 11:15:00', '100'], 19 | ], 20 | }, 21 | ], 22 | description: '해당 기수의 트래픽 Top5 프로젝트에 대한 작일 차트 데이터', 23 | }) 24 | @Expose() 25 | trafficCharts: TrafficTop5Chart[]; 26 | } 27 | -------------------------------------------------------------------------------- /backend/console-server/src/log/traffic/dto/get-traffic-top5-chart.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber } from 'class-validator'; 2 | import { Type } from 'class-transformer'; 3 | import { ApiProperty } from '@nestjs/swagger'; 4 | 5 | export class GetTrafficTop5ChartDto { 6 | @IsNumber() 7 | @Type(() => Number) 8 | @ApiProperty({ 9 | description: '기수', 10 | example: 9, 11 | required: true, 12 | }) 13 | generation: number; 14 | } 15 | -------------------------------------------------------------------------------- /backend/console-server/src/log/traffic/dto/get-traffic-top5-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Expose } from 'class-transformer'; 3 | import { IsNotEmpty, IsNumber, IsString, ValidateNested } from 'class-validator'; 4 | 5 | export class TrafficRankData { 6 | @IsNotEmpty() 7 | @IsString() 8 | projectName: string; 9 | 10 | @IsNotEmpty() 11 | @IsNumber() 12 | count: number; 13 | } 14 | 15 | export class GetTrafficTop5ResponseDto { 16 | @ApiProperty({ 17 | example: [ 18 | { projectName: 'watchducks01', count: 100 }, 19 | { projectName: 'watchducks02', count: 99 }, 20 | { projectName: 'watchducks03', count: 98 }, 21 | { projectName: 'watchducks04', count: 97 }, 22 | { projectName: 'watchducks05', count: 96 }, 23 | ], 24 | description: '트래픽 수가 가장 많은 Top5 호스트, 트래픽 수', 25 | }) 26 | @Expose() 27 | @ValidateNested() 28 | rank: TrafficRankData[]; 29 | } 30 | -------------------------------------------------------------------------------- /backend/console-server/src/log/traffic/dto/get-traffic-top5.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Expose, Type } from 'class-transformer'; 3 | import { IsNotEmpty, IsNumber } from 'class-validator'; 4 | 5 | export class GetTrafficTop5Dto { 6 | @ApiProperty({ 7 | example: 9, 8 | description: '기수', 9 | type: 'number', 10 | }) 11 | @Type(() => Number) 12 | @IsNumber() 13 | @IsNotEmpty() 14 | @Expose() 15 | generation: number; 16 | } 17 | -------------------------------------------------------------------------------- /backend/console-server/src/log/traffic/metric/traffic-chart.metric.ts: -------------------------------------------------------------------------------- 1 | import { IsString } from 'class-validator'; 2 | 3 | export class TrafficChartMetric { 4 | @IsString() 5 | host: string; 6 | 7 | @IsString() 8 | traffic: string[][]; 9 | } 10 | -------------------------------------------------------------------------------- /backend/console-server/src/log/traffic/metric/traffic-count-by-project.metric.ts: -------------------------------------------------------------------------------- 1 | export type TrafficCountByProjectMetric = { 2 | timestamp: string; 3 | count: number; 4 | }; 5 | -------------------------------------------------------------------------------- /backend/console-server/src/log/traffic/metric/traffic-count-by-timeunit.metric.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer'; 2 | import { IsNumber, IsString } from 'class-validator'; 3 | 4 | export class TrafficCountByTimeunitMetric { 5 | @Type(() => Number) 6 | @IsNumber() 7 | count: number; 8 | 9 | @IsString() 10 | timestamp: string; 11 | } 12 | -------------------------------------------------------------------------------- /backend/console-server/src/log/traffic/metric/traffic-count.metric.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber } from 'class-validator'; 2 | import { Type } from 'class-transformer'; 3 | 4 | export class TrafficCountMetric { 5 | @Type(() => Number) 6 | @IsNumber() 7 | count: number; 8 | } 9 | -------------------------------------------------------------------------------- /backend/console-server/src/log/traffic/metric/traffic-rank.metric.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber, IsString } from 'class-validator'; 2 | import { Type } from 'class-transformer'; 3 | 4 | export class TrafficRankMetric { 5 | @IsString() 6 | host: string; 7 | 8 | @Type(() => Number) 9 | @IsNumber() 10 | count: number; 11 | } 12 | -------------------------------------------------------------------------------- /backend/console-server/src/log/traffic/traffic.constant.ts: -------------------------------------------------------------------------------- 1 | export const TIME_RANGE = { 2 | DAY: 'day', 3 | WEEK: 'week', 4 | MONTH: 'month', 5 | } as const; 6 | 7 | export const CLICKHOUSE_TIME_UNIT = { 8 | ONE_MINUTE: 'Minute', 9 | FIFTEEN_MINUTES: 'FifteenMinutes', 10 | ONE_HOUR: 'Hour', 11 | }; 12 | 13 | export const TIME_RANGE_UNIT_MAP = { 14 | [TIME_RANGE.DAY]: CLICKHOUSE_TIME_UNIT.ONE_MINUTE, 15 | [TIME_RANGE.WEEK]: CLICKHOUSE_TIME_UNIT.FIFTEEN_MINUTES, 16 | [TIME_RANGE.MONTH]: CLICKHOUSE_TIME_UNIT.ONE_HOUR, 17 | } as const; 18 | 19 | export type TimeRange = keyof typeof TIME_RANGE_UNIT_MAP; 20 | export type TimeUnit = (typeof TIME_RANGE_UNIT_MAP)[TimeRange]; 21 | -------------------------------------------------------------------------------- /backend/console-server/src/log/traffic/traffic.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TrafficService } from './traffic.service'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { Project } from '../../project/entities/project.entity'; 5 | import { TrafficRepository } from './traffic.repository'; 6 | import { TrafficController } from './traffic.controller'; 7 | import { ClickhouseModule } from '../../clickhouse/clickhouse.module'; 8 | import { Clickhouse } from '../../clickhouse/clickhouse'; 9 | 10 | @Module({ 11 | imports: [TypeOrmModule.forFeature([Project]), ClickhouseModule], 12 | controllers: [TrafficController], 13 | providers: [TrafficService, TrafficRepository, Clickhouse], 14 | }) 15 | export class TrafficModule {} 16 | -------------------------------------------------------------------------------- /backend/console-server/src/mail/mail.module.ts: -------------------------------------------------------------------------------- 1 | import { MailerModule, MailerOptions } from '@nestjs-modules/mailer'; 2 | import { Module } from '@nestjs/common'; 3 | import { MailService } from './mail.service'; 4 | import { ConfigModule, ConfigService } from '@nestjs/config'; 5 | 6 | @Module({ 7 | imports: [ 8 | ConfigModule, 9 | MailerModule.forRootAsync({ 10 | imports: [ConfigModule], 11 | inject: [ConfigService], 12 | useFactory: (configService: ConfigService) => 13 | configService.get('mailer') as MailerOptions, 14 | }), 15 | ], 16 | providers: [MailService], 17 | exports: [MailService], 18 | }) 19 | export class MailModule {} 20 | -------------------------------------------------------------------------------- /backend/console-server/src/mail/mail.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { MailerService } from '@nestjs-modules/mailer'; 3 | import { ConfigService } from '@nestjs/config'; 4 | 5 | @Injectable() 6 | export class MailService { 7 | private readonly nameServers: string[]; 8 | 9 | constructor( 10 | private readonly mailerService: MailerService, 11 | private readonly configService: ConfigService, 12 | ) { 13 | this.nameServers = this.configService.get('mailer.nameServers') as string[]; 14 | } 15 | 16 | async sendNameServerInfo(email: string, projectName: string) { 17 | await this.mailerService.sendMail({ 18 | to: email, 19 | subject: '[와치덕스] 네임서버 정보를 전송합니다.', 20 | template: './nameserver', 21 | context: { 22 | projectName: projectName, 23 | nameServers: this.nameServers, 24 | }, 25 | }); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /backend/console-server/src/mail/templates/nameserver.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 왓치덕스 네임서버 정보 5 | 6 | 7 |

"{{projectName}}" 서비스를 위한 왓치덕스의 네임서버입니다.

8 |

프로젝트를 등록해주셔서 감사합니다! DNS 등록 절차까지 마무리해주세요 :)

9 |

네임 서버 정보:

10 |
    11 | {{#each nameServers}} 12 |
  • {{this}}
  • 13 | {{/each}} 14 |
15 |

Best regards,
왓치덕스

16 | 17 | -------------------------------------------------------------------------------- /backend/console-server/src/main.ts: -------------------------------------------------------------------------------- 1 | import { ValidationPipe } from '@nestjs/common'; 2 | import { NestFactory } from '@nestjs/core'; 3 | import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; 4 | import { AppModule } from './app.module'; 5 | 6 | async function bootstrap() { 7 | const app = await NestFactory.create(AppModule, { 8 | logger: ['error', 'warn'], 9 | }); 10 | 11 | app.useGlobalPipes( 12 | new ValidationPipe({ 13 | transform: true, 14 | }), 15 | ); 16 | app.setGlobalPrefix('api'); 17 | app.enableCors(); 18 | 19 | const config = new DocumentBuilder() 20 | .setTitle('Watchducks API') 21 | .setDescription('초 훌륭한! 오직 성호님만을 위한! 그런! 완벽한! API 문서!') 22 | .setVersion('1.0') 23 | .build(); 24 | 25 | const document = SwaggerModule.createDocument(app, config); 26 | SwaggerModule.setup('api-docs', app, document); 27 | 28 | const port = process.env.PORT || 3000; 29 | const server = await app.listen(port); 30 | 31 | console.log(`Application is running on: ${await app.getUrl()}`); 32 | 33 | process.on('SIGTERM', () => { 34 | console.log('SIGTERM signal received: closing HTTP server'); 35 | server.close(() => { 36 | console.log('HTTP server closed'); 37 | process.exit(0); 38 | }); 39 | }); 40 | 41 | process.on('SIGINT', () => { 42 | console.log('SIGINT signal received: closing HTTP server'); 43 | server.close(() => { 44 | console.log('HTTP server closed'); 45 | process.exit(0); 46 | }); 47 | }); 48 | } 49 | bootstrap(); 50 | -------------------------------------------------------------------------------- /backend/console-server/src/project/dto/count-project-by-generation-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Type } from 'class-transformer'; 3 | 4 | export class CountProjectByGenerationResponseDto { 5 | @ApiProperty({ 6 | example: '42', 7 | description: '해당 기수의 프로젝트 총 개수', 8 | }) 9 | @Type(() => Number) 10 | count: number; 11 | } 12 | -------------------------------------------------------------------------------- /backend/console-server/src/project/dto/count-project-by-generation.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsNotEmpty, IsNumber } from 'class-validator'; 3 | import { Type } from 'class-transformer'; 4 | 5 | export class CountProjectByGenerationDto { 6 | @ApiProperty({ 7 | example: '5', 8 | description: '부스트캠프 기수', 9 | }) 10 | @IsNotEmpty() 11 | @Type(() => Number) 12 | @IsNumber() 13 | generation: number; 14 | } 15 | -------------------------------------------------------------------------------- /backend/console-server/src/project/dto/create-project-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { Exclude, Expose } from 'class-transformer'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | 4 | @Exclude() 5 | export class ProjectResponseDto { 6 | @ApiProperty({ 7 | example: '1', 8 | }) 9 | @Expose() 10 | id: number; 11 | } 12 | -------------------------------------------------------------------------------- /backend/console-server/src/project/dto/create-project.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, IsIP, IsNotEmpty, IsNumber, IsString } from 'class-validator'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { Type } from 'class-transformer'; 4 | 5 | export class CreateProjectDto { 6 | @ApiProperty({ 7 | example: 'watchducks', 8 | description: '프로젝트 명', 9 | }) 10 | @IsString() 11 | @IsNotEmpty() 12 | name: string; 13 | 14 | @ApiProperty({ 15 | example: '123.123.78.90', 16 | description: '프로젝트 IP (0.0.0.0 ~ 255.255.255.255 사이의 값이어야 합니다)', 17 | }) 18 | @IsIP() 19 | @IsNotEmpty() 20 | ip: string; 21 | 22 | @ApiProperty({ 23 | example: 'www.watchdukcs.com', 24 | description: '프로젝트 도메인 네임', 25 | }) 26 | @IsString() 27 | @IsNotEmpty() 28 | domain: string; 29 | 30 | @ApiProperty({ 31 | example: 'watchducks@gmail.com', 32 | description: '프로젝트 소유자 이메일', 33 | }) 34 | @IsEmail() 35 | @IsNotEmpty() 36 | email: string; 37 | 38 | @ApiProperty({ 39 | example: 9, 40 | description: '부스트캠프 기수', 41 | }) 42 | @Type(() => Number) 43 | @IsNumber() 44 | @IsNotEmpty() 45 | generation: number; 46 | } 47 | -------------------------------------------------------------------------------- /backend/console-server/src/project/dto/exists-project-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Expose } from 'class-transformer'; 3 | import { IsBoolean } from 'class-validator'; 4 | 5 | export class ExistsProjectResponseDto { 6 | @ApiProperty({ 7 | example: true, 8 | }) 9 | @IsBoolean() 10 | @Expose() 11 | exists: boolean; 12 | } 13 | -------------------------------------------------------------------------------- /backend/console-server/src/project/dto/exists-project.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString } from 'class-validator'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | 4 | export class ExistsProjectDto { 5 | @IsString() 6 | @ApiProperty({ 7 | example: 'watchducks', 8 | description: '유효성 검사할 프로젝트 명', 9 | }) 10 | projectName: string; 11 | } 12 | -------------------------------------------------------------------------------- /backend/console-server/src/project/dto/find-by-generation-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty } from 'class-validator'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { Expose } from 'class-transformer'; 4 | 5 | export class FindByGenerationResponseDto { 6 | @ApiProperty({ 7 | example: 'watchducks', 8 | }) 9 | @IsNotEmpty() 10 | @Expose() 11 | value: string; 12 | } 13 | -------------------------------------------------------------------------------- /backend/console-server/src/project/dto/find-by-generation.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsNumber } from 'class-validator'; 2 | import { Type } from 'class-transformer'; 3 | import { ApiProperty } from '@nestjs/swagger'; 4 | 5 | export class FindByGenerationDto { 6 | @ApiProperty({ 7 | example: '9', 8 | description: '부스트캠프 기수', 9 | }) 10 | @IsNotEmpty() 11 | @Type(() => Number) 12 | @IsNumber() 13 | generation: number; 14 | } 15 | -------------------------------------------------------------------------------- /backend/console-server/src/project/entities/project.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, Unique } from 'typeorm'; 2 | import { Column, PrimaryGeneratedColumn } from 'typeorm'; 3 | 4 | @Entity('project') 5 | @Unique(['domain']) 6 | export class Project { 7 | @PrimaryGeneratedColumn() 8 | id: number; 9 | 10 | @Column({ type: 'varchar', length: 255 }) 11 | name: string; 12 | 13 | @Column({ type: 'varchar', length: 255 }) 14 | ip: string; 15 | 16 | @Column({ type: 'varchar', length: 255, unique: true }) 17 | domain: string; 18 | 19 | @Column({ type: 'varchar', length: 255 }) 20 | email: string; 21 | 22 | @Column({ type: 'int' }) 23 | generation: number; 24 | } 25 | -------------------------------------------------------------------------------- /backend/console-server/src/project/project.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ProjectService } from './project.service'; 3 | import { ProjectController } from './project.controller'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { Project } from './entities/project.entity'; 6 | import { MailModule } from '../mail/mail.module'; 7 | 8 | @Module({ 9 | imports: [TypeOrmModule.forFeature([Project]), MailModule], 10 | controllers: [ProjectController], 11 | providers: [ProjectService], 12 | }) 13 | export class ProjectModule {} 14 | -------------------------------------------------------------------------------- /backend/console-server/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import type { TestingModule } from '@nestjs/testing'; 2 | import { Test } from '@nestjs/testing'; 3 | import type { INestApplication } from '@nestjs/common'; 4 | import * as request from 'supertest'; 5 | import { AppModule } from '../src/app.module'; 6 | 7 | describe('AppController (e2e)', () => { 8 | let app: INestApplication; 9 | 10 | beforeEach(async () => { 11 | const moduleFixture: TestingModule = await Test.createTestingModule({ 12 | imports: [AppModule], 13 | }).compile(); 14 | 15 | app = moduleFixture.createNestApplication(); 16 | await app.init(); 17 | }); 18 | 19 | it('/ (GET)', () => { 20 | return request(app.getHttpServer()).get('/').expect(200).expect('Hello World!'); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /backend/console-server/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /backend/console-server/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /backend/console-server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "ES2021", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": true, 16 | "noImplicitAny": true, 17 | "strictBindCallApply": true, 18 | "forceConsistentCasingInFileNames": true, 19 | "noFallthroughCasesInSwitch": true 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /backend/name-server/.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | build/ -------------------------------------------------------------------------------- /backend/name-server/.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | build/ -------------------------------------------------------------------------------- /backend/name-server/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "all", 4 | "singleQuote": true, 5 | "printWidth": 100, 6 | "tabWidth": 4 7 | } -------------------------------------------------------------------------------- /backend/name-server/Dockerfile: -------------------------------------------------------------------------------- 1 | # 빌드 스테이지 2 | FROM node:22-alpine AS build 3 | 4 | WORKDIR /usr/src/app 5 | 6 | # 패키지 파일 복사 및 의존성 설치 7 | COPY package*.json ./ 8 | RUN npm install 9 | 10 | # 소스 코드 복사 및 빌드 11 | COPY . . 12 | RUN npm run build 13 | 14 | # 런타임 스테이지 15 | FROM node:22-alpine 16 | 17 | WORKDIR /usr/src/app 18 | 19 | RUN apk add --no-cache tzdata 20 | 21 | # 프로덕션 의존성 설치 22 | COPY package*.json ./ 23 | RUN npm install --production 24 | 25 | # 빌드 결과물 복사 26 | COPY --from=build /usr/src/app/dist ./dist 27 | 28 | CMD ["node", "./dist/index.js"] 29 | 30 | ## PM2 설치 (옵션) 31 | #RUN npm install pm2 -g 32 | # 33 | ## PM2 설정 파일 복사 (옵션) 34 | #COPY ecosystem.config.cjs ./ 35 | # 36 | ## 애플리케이션 실행 37 | #CMD ["pm2-runtime", "ecosystem.config.cjs"] -------------------------------------------------------------------------------- /backend/name-server/babel.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | presets: [ 3 | [ 4 | '@babel/preset-env', 5 | { 6 | targets: { node: 'current' }, // 현재 Node.js 버전을 타겟팅 7 | modules: 'auto', // 모듈 시스템 자동 결정 8 | }, 9 | ], 10 | '@babel/preset-typescript', // TypeScript 지원 11 | ], 12 | }; -------------------------------------------------------------------------------- /backend/name-server/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | redis: 4 | image: redis:latest 5 | container_name: app-redis 6 | restart: always 7 | ports: 8 | - "6379:6379" 9 | 10 | nginx: 11 | image: nginx:latest 12 | ports: 13 | - "5353:5353/udp" 14 | volumes: 15 | - ./nginx.conf:/etc/nginx/nginx.conf 16 | depends_on: 17 | - server-blue 18 | - server-green 19 | 20 | server-blue: 21 | image: ghcr.io/boostcampwm-2024/web35-watchducks/backend/name-server:latest 22 | container_name: app-server-blue-1 23 | environment: 24 | - TZ=Asia/Seoul 25 | - NODE_ENV=production 26 | - NAME_SERVER_PORT=3001 27 | restart: always 28 | volumes: 29 | - ./.env:/usr/src/app/.env 30 | healthcheck: 31 | test: ["CMD", "nc", "-zu", "localhost", "3001"] 32 | interval: 10s 33 | timeout: 2s 34 | retries: 5 35 | 36 | server-green: 37 | image: ghcr.io/boostcampwm-2024/web35-watchducks/backend/name-server:latest 38 | container_name: app-server-green-1 39 | environment: 40 | - TZ=Asia/Seoul 41 | - NODE_ENV=production 42 | - NAME_SERVER_PORT=3002 43 | restart: always 44 | volumes: 45 | - ./.env:/usr/src/app/.env 46 | healthcheck: 47 | test: ["CMD", "nc", "-zu", "localhost", "3002"] 48 | interval: 10s 49 | timeout: 2s 50 | retries: 5 -------------------------------------------------------------------------------- /backend/name-server/ecosystem.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | apps: [ 3 | { 4 | name: 'name-server', 5 | script: 'dist/index.js', // 빌드된 애플리케이션 진입점 6 | instances: 'max', // CPU 코어 수에 따라 인스턴스 수 자동 조절 7 | exec_mode: 'cluster', 8 | }, 9 | ], 10 | }; 11 | -------------------------------------------------------------------------------- /backend/name-server/jest.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | preset: 'ts-jest/presets/default-esm', // ES 모듈 지원을 위한 프리셋 3 | testEnvironment: 'node', // 테스트 환경 설정 4 | extensionsToTreatAsEsm: ['.ts'], // ESM으로 취급할 파일 확장자 5 | transform: { 6 | '^.+\\.ts$': [ 7 | 'ts-jest', 8 | { 9 | useESM: true, // ts-jest에서 ESM 사용 설정 10 | }, 11 | ], 12 | }, 13 | moduleNameMapper: { 14 | '^(\\.{1,2}/.*)\\.js$': '$1', // 모듈 경로 매핑 15 | }, 16 | }; -------------------------------------------------------------------------------- /backend/name-server/nginx.conf: -------------------------------------------------------------------------------- 1 | stream { 2 | upstream name_server { 3 | server server-blue:3001 weight=1; 4 | server server-green:3002 backup; 5 | } 6 | 7 | server { 8 | listen 5353 udp; 9 | proxy_pass name_server; 10 | 11 | # DNS 응답을 위한 설정 12 | proxy_timeout 3s; 13 | proxy_responses 1; 14 | 15 | # UDP 패킷 보존 설정 16 | proxy_requests 1; 17 | } 18 | } 19 | 20 | events{} -------------------------------------------------------------------------------- /backend/name-server/src/app.ts: -------------------------------------------------------------------------------- 1 | import { config } from 'dotenv'; 2 | import type { ServerConfig } from 'common/utils/validator/configuration.validator'; 3 | import { ConfigurationValidator } from 'common/utils/validator/configuration.validator'; 4 | import { Server } from 'server/server'; 5 | import { db } from 'database/mysql/mysql-database'; 6 | import { logger } from 'common/utils/logger/console.logger'; 7 | import { ProjectQuery } from 'database/query/project.query'; 8 | import { CacheQuery } from 'database/query/cache.query'; 9 | 10 | config(); 11 | 12 | export class Application { 13 | constructor() {} 14 | 15 | public async initialize(): Promise { 16 | await this.initializeDatabase(); 17 | 18 | const config = await this.initializeConfig(); 19 | const projectQuery = new ProjectQuery(); 20 | const cacheQuery = new CacheQuery(); 21 | 22 | return new Server(config, projectQuery, cacheQuery); 23 | } 24 | 25 | public async cleanup(): Promise { 26 | try { 27 | await db.end(); 28 | logger.info('Database connections cleaned up'); 29 | } catch (error) { 30 | logger.error('Cleanup failed:', error); 31 | throw error; 32 | } 33 | } 34 | 35 | private async initializeConfig(): Promise { 36 | return ConfigurationValidator.validate(); 37 | } 38 | 39 | private async initializeDatabase(): Promise { 40 | await db.connect(); 41 | logger.info('MySql Database connection established'); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /backend/name-server/src/common/core/custom.error.ts: -------------------------------------------------------------------------------- 1 | import { logger } from '../utils/logger/console.logger'; 2 | 3 | export class CustomError extends Error { 4 | constructor(message: string, details?: Error | unknown) { 5 | super(message); 6 | 7 | logger.error(message, details); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /backend/name-server/src/common/error/configuration.error.ts: -------------------------------------------------------------------------------- 1 | import { CustomError } from '../core/custom.error'; 2 | 3 | export class ConfigurationError extends CustomError { 4 | constructor(message: string, details?: unknown) { 5 | super(message, details); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /backend/name-server/src/common/utils/logger/console.logger.ts: -------------------------------------------------------------------------------- 1 | import type { Logger } from './logger'; 2 | import type { RemoteInfo } from 'dgram'; 3 | 4 | class ConsoleLogger implements Logger { 5 | async info(message: string): Promise { 6 | console.log(message); 7 | } 8 | 9 | async error(message: string, error?: Error | unknown): Promise { 10 | console.error(message, error); 11 | } 12 | 13 | async logQuery(domain: string, remoteInfo: RemoteInfo): Promise { 14 | console.log(`Received query for ${domain} from ${remoteInfo.address}:${remoteInfo.port}`); 15 | } 16 | } 17 | 18 | export const logger = new ConsoleLogger(); 19 | -------------------------------------------------------------------------------- /backend/name-server/src/common/utils/logger/logger.ts: -------------------------------------------------------------------------------- 1 | import type { RemoteInfo } from 'dgram'; 2 | 3 | export interface Logger { 4 | info(message: string): Promise; 5 | error(message: string, error: Error): Promise; 6 | logQuery(domain: string, remoteInfo: RemoteInfo): Promise; 7 | } 8 | -------------------------------------------------------------------------------- /backend/name-server/src/common/utils/validator/configuration.validator.ts: -------------------------------------------------------------------------------- 1 | import { ConfigurationError } from '../../error/configuration.error'; 2 | import * as process from 'node:process'; 3 | 4 | export interface ServerConfig { 5 | proxyServerIp: string; 6 | nameServerPort: number; 7 | ttl: number; 8 | authoritativeNameServers: string[]; 9 | nameServerIp: string; 10 | proxyHealthCheckEndpoint: string; 11 | healthCheckIp:string; 12 | } 13 | 14 | export class ConfigurationValidator { 15 | static validate(): ServerConfig { 16 | const { PROXY_SERVER_IP, NAME_SERVER_PORT } = process.env; 17 | const { TTL, AUTHORITATIVE_NAME_SERVERS, NAME_SERVER_IP, PROXY_HEALTH_CHECK_ENDPOINT, PROXY_HEALTH_CHECK_IP } = process.env; 18 | 19 | if ( 20 | !PROXY_SERVER_IP || 21 | !NAME_SERVER_PORT || 22 | !TTL || 23 | !AUTHORITATIVE_NAME_SERVERS || 24 | !NAME_SERVER_IP || 25 | !PROXY_HEALTH_CHECK_ENDPOINT || 26 | !PROXY_HEALTH_CHECK_IP 27 | ) { 28 | throw new ConfigurationError('Missing required environment variables'); 29 | } 30 | 31 | return { 32 | proxyServerIp: PROXY_SERVER_IP, 33 | nameServerPort: parseInt(NAME_SERVER_PORT, 10), 34 | ttl: parseInt(TTL, 10), 35 | authoritativeNameServers: AUTHORITATIVE_NAME_SERVERS.split(',').map((s) => s.trim()), 36 | nameServerIp: NAME_SERVER_IP, 37 | proxyHealthCheckEndpoint: PROXY_HEALTH_CHECK_ENDPOINT, 38 | healthCheckIp :PROXY_HEALTH_CHECK_IP 39 | }; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /backend/name-server/src/database/error/database.error.ts: -------------------------------------------------------------------------------- 1 | import { CustomError } from '../../common/core/custom.error'; 2 | 3 | export class DatabaseError extends CustomError { 4 | constructor(message: string, details?: unknown) { 5 | super(message, details); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /backend/name-server/src/database/mysql/config.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | import type { PoolOptions } from 'mysql2/promise'; 3 | 4 | dotenv.config(); 5 | 6 | export const poolConfig: PoolOptions = 7 | process.env.NODE_ENV === 'development' 8 | ? { 9 | host: process.env.DEV_DB_HOST, 10 | port: Number(process.env.DEV_DB_PORT), 11 | user: process.env.DEV_DB_USERNAME, 12 | password: process.env.DEV_DB_PASSWORD, 13 | database: process.env.DEV_DB_NAME, 14 | } 15 | : { 16 | host: process.env.DB_HOST, 17 | port: Number(process.env.DB_PORT), 18 | user: process.env.DB_USERNAME, 19 | password: process.env.DB_PASSWORD, 20 | database: process.env.DB_NAME, 21 | }; 22 | -------------------------------------------------------------------------------- /backend/name-server/src/database/query/cache.query.interface.ts: -------------------------------------------------------------------------------- 1 | import { RedisClient } from 'database/redis/redis-database'; 2 | 3 | export interface CacheQueryInterface { 4 | findIpByDomain(domain: string): Promise; 5 | cacheIpByDomain(domain: string, ip: string): Promise; 6 | } 7 | -------------------------------------------------------------------------------- /backend/name-server/src/database/query/cache.query.ts: -------------------------------------------------------------------------------- 1 | import { RedisClient } from 'database/redis/redis-database'; 2 | 3 | export class CacheQuery { 4 | readonly TTL_DAY = 24 * 60 * 60; 5 | private readonly redisClient: RedisClient; 6 | 7 | constructor() { 8 | this.redisClient = RedisClient.getInstance(); 9 | } 10 | 11 | async findIpByDomain(domain: string): Promise { 12 | return await this.redisClient.get(domain); 13 | } 14 | 15 | async cacheIpByDomain(domain: string, ip: string): Promise { 16 | this.redisClient.set(domain, ip, this.TTL_DAY); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /backend/name-server/src/database/query/project.query.interface.ts: -------------------------------------------------------------------------------- 1 | export interface ProjectQueryInterface { 2 | existsByDomain(domain: string): Promise; 3 | getClientIpByDomain(domain:string): Promise; 4 | } 5 | -------------------------------------------------------------------------------- /backend/name-server/src/database/query/project.query.ts: -------------------------------------------------------------------------------- 1 | import { db } from '../mysql/mysql-database'; 2 | import type { RowDataPacket } from 'mysql2/promise'; 3 | import type { ProjectQueryInterface } from './project.query.interface'; 4 | 5 | interface ProjectExists extends RowDataPacket { 6 | exists_flag: number; 7 | } 8 | 9 | interface ProjectClientIp extends RowDataPacket { 10 | ip: string; 11 | } 12 | 13 | export class ProjectQuery implements ProjectQueryInterface { 14 | private readonly db = db; 15 | private readonly EXIST = 1; 16 | 17 | constructor() {} 18 | 19 | async existsByDomain(name: string): Promise { 20 | const sql = `SELECT EXISTS(SELECT 1 21 | FROM project 22 | WHERE domain = ?) as exists_flag`; 23 | const params = [name]; 24 | const rows = await this.db.query(sql, params); 25 | 26 | return rows[0].exists_flag === this.EXIST; 27 | } 28 | 29 | async getClientIpByDomain(domain: string): Promise { 30 | const sql = `SELECT ip 31 | FROM project 32 | WHERE domain = ?`; 33 | const params = [domain]; 34 | const [rows] = await this.db.query(sql, params); 35 | 36 | if (rows.length === 0) { 37 | throw new Error(`No client IP found for domain: ${domain}`); 38 | } 39 | 40 | return rows.ip; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /backend/name-server/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Application } from 'app'; 2 | import { logger } from 'common/utils/logger/console.logger'; 3 | 4 | async function main(): Promise { 5 | const initializer = new Application(); 6 | 7 | try { 8 | const server = await initializer.initialize(); 9 | server.start(); 10 | 11 | process.on('SIGINT', async () => { 12 | try { 13 | await initializer.cleanup(); 14 | process.exit(0); 15 | } catch (error) { 16 | logger.error('exiting process ', error as Error); 17 | process.exit(1); 18 | } 19 | }); 20 | 21 | process.on('SIGTERM', async () => { 22 | try { 23 | await initializer.cleanup(); 24 | 25 | process.exit(0); 26 | } catch (error) { 27 | logger.error('exiting process ', error as Error); 28 | process.exit(1); 29 | } 30 | }); 31 | } catch (error) { 32 | logger.error('exiting process ', error as Error); 33 | process.exit(1); 34 | } 35 | } 36 | 37 | main().catch((error) => { 38 | console.error('Fatal error:', error); 39 | process.exit(1); 40 | }); 41 | -------------------------------------------------------------------------------- /backend/name-server/src/server/constant/dns-packet.constant.ts: -------------------------------------------------------------------------------- 1 | export const DNS_FLAGS = { 2 | QUERY_RESPONSE: 0x8000, // Query/Response 3 | AUTHORITATIVE_ANSWER: 0x0400, // 권한 있는 응답 (네임서버가 해당 도메인의 공식 서버일 때) 4 | TRUNCATED_RESPONSE: 0x0200, // 응답이 잘린 경우 (UDP 크기 제한 초과) 5 | RECURSION_DESIRED: 0x0100, // 재귀적 쿼리 요청 (클라이언트가 설정) 6 | RECURSION_AVAILABLE: 0x0080, // 재귀 쿼리 지원 여부 7 | AUTHENTIC_DATA: 0x0020, // DNSSEC 검증된 데이터 8 | CHECKING_DISABLED: 0x0010, // DNSSEC 검증 비활성화 9 | } as const; 10 | 11 | export const RESPONSE_CODE = { 12 | NOERROR: 0, // 정상 응답 13 | NXDOMAIN: 3, // 도메인이 존재하지 않음 14 | SERVFAIL: 2, // 서버 에러 15 | } as const; 16 | 17 | export const RESPONSE_CODE_MASK = 0x000f; 18 | 19 | export const RECORD_TYPE = { 20 | ADDRESS: 'A', 21 | NAME_SERVER: 'NS', 22 | } as const; 23 | 24 | export const RECORD_CLASS = { 25 | INTERNET: 'IN', 26 | } as const; 27 | 28 | export const PACKET_TYPE = { 29 | RESPONSE: 'response', 30 | } as const; 31 | 32 | export type ResponseCodeType = (typeof RESPONSE_CODE)[keyof typeof RESPONSE_CODE]; 33 | -------------------------------------------------------------------------------- /backend/name-server/src/server/constant/message-type.constants.ts: -------------------------------------------------------------------------------- 1 | export const MESSAGE_TYPE = { 2 | DNS: 'DNS', 3 | HEALTH_CHECK: 'HEALTH_CHECK', 4 | } as const; 5 | 6 | export const MIN_DNS_MESSAGE_LENGTH = 12; 7 | 8 | export type MessageType = (typeof MESSAGE_TYPE)[keyof typeof MESSAGE_TYPE]; 9 | -------------------------------------------------------------------------------- /backend/name-server/src/server/error/server.error.ts: -------------------------------------------------------------------------------- 1 | import { CustomError } from '../../common/core/custom.error'; 2 | 3 | export class ServerError extends CustomError { 4 | constructor(message: string, details?: Error | unknown) { 5 | super(message, details); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /backend/name-server/src/server/utils/packet.validator.ts: -------------------------------------------------------------------------------- 1 | import type { Packet, Question } from 'dns-packet'; 2 | import type { MessageType } from '../../server/constant/message-type.constants'; 3 | import { MESSAGE_TYPE, MIN_DNS_MESSAGE_LENGTH } from '../../server/constant/message-type.constants'; 4 | 5 | type TypeGuardResult = T extends Packet ? T & { questions: Question[] } : never; 6 | 7 | export class PacketValidator { 8 | static hasQuestions(packet: Packet): packet is TypeGuardResult { 9 | return Array.isArray(packet.questions) && packet.questions.length > 0; 10 | } 11 | 12 | static hasFlags(packet: Packet): packet is Packet & { flags: number } { 13 | return typeof packet.flags === 'number'; 14 | } 15 | 16 | static validatePacket(packet: Packet): boolean { 17 | return this.hasQuestions(packet) && this.hasFlags(packet); 18 | } 19 | 20 | static validateMessageType(msg: Buffer): MessageType { 21 | if (msg.length >= MIN_DNS_MESSAGE_LENGTH) { 22 | const flags = msg.readUInt16BE(2); 23 | const isQuery = (flags & 0x8000) === 0; 24 | 25 | if (isQuery) { 26 | const questionCount = msg.readUInt16BE(4); 27 | if (questionCount > 0) { 28 | return MESSAGE_TYPE.DNS; 29 | } 30 | } 31 | } 32 | 33 | return MESSAGE_TYPE.HEALTH_CHECK; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /backend/name-server/test/constant/packet.ts: -------------------------------------------------------------------------------- 1 | import type { Packet } from 'dns-packet'; 2 | 3 | export const NORMAL_PACKET: Packet = { 4 | id: 1, 5 | type: 'query', 6 | flags: 0, 7 | questions: [ 8 | { 9 | name: 'example.com', 10 | type: 'A', 11 | class: 'IN', 12 | }, 13 | ], 14 | }; 15 | export const NOT_EXIST_DOMAIN_PACKET: Packet = { 16 | id: 1, 17 | type: 'query', 18 | flags: 0, 19 | questions: [ 20 | { 21 | name: 'not_exist_example.com', 22 | type: 'A', 23 | class: 'IN', 24 | }, 25 | ], 26 | }; 27 | export const EMPTY_PACKET: Packet = {}; 28 | export const NONE_QUESTION_PACKET: Packet = { 29 | id: 1, 30 | type: 'query', 31 | flags: 0, 32 | questions: [], 33 | }; 34 | -------------------------------------------------------------------------------- /backend/name-server/test/database/test-cache.query.ts: -------------------------------------------------------------------------------- 1 | import type { CacheQueryInterface } from '../../src/database/query/cache.query.interface'; 2 | 3 | export class TestCacheQuery implements CacheQueryInterface { 4 | private cache: Map; 5 | 6 | constructor(initialData?: Record) { 7 | this.cache = new Map(initialData ? Object.entries(initialData) : []); 8 | } 9 | 10 | async findIpByDomain(domain: string): Promise { 11 | return this.cache.get(domain) || null; 12 | } 13 | 14 | async cacheIpByDomain(domain: string, ip: string): Promise { 15 | this.cache.set(domain, ip); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /backend/name-server/test/database/test-project.query.ts: -------------------------------------------------------------------------------- 1 | import type { RowDataPacket } from 'mysql2/promise'; 2 | import { TestDatabase } from './test-database'; 3 | import type { ProjectQueryInterface } from '../../src/database/query/project.query.interface'; 4 | 5 | interface ProjectExists extends RowDataPacket { 6 | exists_flag: number; 7 | } 8 | 9 | interface ProjectClientIp extends RowDataPacket { 10 | ip: string; 11 | } 12 | 13 | export class TestProjectQuery implements ProjectQueryInterface { 14 | private readonly db = new TestDatabase(); 15 | private readonly EXIST = 1; 16 | 17 | constructor() {} 18 | 19 | async existsByDomain(name: string): Promise { 20 | const sql = `SELECT EXISTS(SELECT 1 21 | FROM project 22 | WHERE domain = ?) as exists_flag`; 23 | const params = [name]; 24 | const rows = await this.db.query(sql, params); 25 | 26 | return rows[0].exists_flag === this.EXIST; 27 | } 28 | 29 | 30 | async getClientIpByDomain(domain: string): Promise { 31 | const sql = `SELECT ip 32 | FROM project 33 | WHERE domain = ?`; 34 | const params = [domain]; 35 | const rows = await this.db.query(sql, params); 36 | 37 | if (rows.length === 0) { 38 | throw new Error(`No client IP found for domain: ${domain}`); 39 | } 40 | 41 | return rows[0].ip; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /backend/name-server/test/packet.validator.test.ts: -------------------------------------------------------------------------------- 1 | import { PacketValidator } from '../src/server/utils/packet.validator'; 2 | import { EMPTY_PACKET, NONE_QUESTION_PACKET, NORMAL_PACKET } from './constant/packet'; 3 | 4 | describe('PacketValidator의', () => { 5 | describe('validatePacket()는', () => { 6 | it('유효한 패킷에 true를 반환합니다.', () => { 7 | expect(PacketValidator.validatePacket(NORMAL_PACKET)).toBe(true); 8 | }); 9 | 10 | it('유효하지 않은 패킷에 false를 반환합니다.', () => { 11 | expect(PacketValidator.validatePacket(EMPTY_PACKET)).toBe(false); 12 | }); 13 | }); 14 | 15 | describe('hasQuestions()는', () => { 16 | it('패킷에 question이 있다면 true를 반환합니다.', () => { 17 | expect(PacketValidator.hasQuestions(NORMAL_PACKET)).toBe(true); 18 | }); 19 | 20 | it('패킷에 question이 없다면 false를 반환합니다.', () => { 21 | expect(PacketValidator.hasQuestions(NONE_QUESTION_PACKET)).toBe(false); 22 | }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /backend/name-server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "node", 6 | "outDir": "dist", 7 | "rootDir": "src", 8 | "esModuleInterop": true, 9 | "strict": true, 10 | "allowSyntheticDefaultImports": true, 11 | "skipLibCheck": true, 12 | "resolveJsonModule": true, 13 | "declaration": true, 14 | "typeRoots": ["./node_modules/@types"], 15 | "baseUrl": "./", 16 | "paths": { 17 | "*": ["src/*"] 18 | } 19 | }, 20 | "include": ["src/**/*"], 21 | "exclude": ["node_modules", "dist", "test"], 22 | "tsc-alias": { 23 | "resolveFullPaths": true, 24 | "resolveFullExtension": ".js" 25 | } 26 | } -------------------------------------------------------------------------------- /backend/proxy-server/.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | build/ -------------------------------------------------------------------------------- /backend/proxy-server/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "all", 4 | "singleQuote": true, 5 | "printWidth": 100, 6 | "tabWidth": 4 7 | } -------------------------------------------------------------------------------- /backend/proxy-server/Dockerfile: -------------------------------------------------------------------------------- 1 | # 빌드 스테이지 2 | FROM node:22-alpine AS build 3 | 4 | WORKDIR /usr/src/app 5 | 6 | # 패키지 파일 복사 및 의존성 설치 7 | COPY package*.json ./ 8 | RUN npm install 9 | 10 | # 소스 코드 복사 및 빌드 11 | COPY . . 12 | RUN npm run build 13 | 14 | # 런타임 스테이지 15 | FROM node:22-alpine 16 | 17 | WORKDIR /usr/src/app 18 | 19 | RUN apk add --no-cache tzdata 20 | 21 | # 프로덕션 의존성 설치 22 | COPY package*.json ./ 23 | RUN npm install --production 24 | 25 | # 빌드 결과물 복사 26 | COPY --from=build /usr/src/app/dist ./dist 27 | 28 | CMD ["node", "./dist/index.js"] 29 | 30 | ## PM2 설치 (옵션) 31 | #RUN npm install pm2 -g 32 | # 33 | ## PM2 설정 파일 복사 (옵션) 34 | #COPY ecosystem.config.cjs ./ 35 | # 36 | ## 애플리케이션 실행 37 | #CMD ["pm2-runtime", "ecosystem.config.cjs"] -------------------------------------------------------------------------------- /backend/proxy-server/babel.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | presets: [ 3 | [ 4 | '@babel/preset-env', 5 | { 6 | targets: { node: 'current' }, // 현재 Node.js 버전을 타겟팅 7 | modules: 'auto', // 모듈 시스템 자동 결정 8 | }, 9 | ], 10 | '@babel/preset-typescript', // TypeScript 지원 11 | ], 12 | }; -------------------------------------------------------------------------------- /backend/proxy-server/clickhouse-local.yml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web35-watchducks/b94a0865ef9d8b824d58938dedbc3c85e68aa4d4/backend/proxy-server/clickhouse-local.yml -------------------------------------------------------------------------------- /backend/proxy-server/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | 4 | redis: 5 | image: redis:latest 6 | container_name: app-redis 7 | restart: always 8 | ports: 9 | - "6379:6379" 10 | 11 | server-blue: 12 | image: ghcr.io/boostcampwm-2024/web35-watchducks/backend/proxy-server:latest 13 | container_name: app-server-blue-1 14 | environment: 15 | - NODE_ENV=production 16 | - PORT=3001 17 | - TZ=Asia/Seoul 18 | restart: always 19 | volumes: 20 | - ./.env:/usr/src/app/.env 21 | network_mode: "host" 22 | healthcheck: 23 | test: [ "CMD", "nc", "-z", "localhost", "3001" ] 24 | interval: 10s 25 | timeout: 2s 26 | retries: 5 27 | 28 | server-green: 29 | image: ghcr.io/boostcampwm-2024/web35-watchducks/backend/proxy-server:latest 30 | container_name: app-server-green-1 31 | environment: 32 | - NODE_ENV=production 33 | - PORT=3002 34 | - TZ=Asia/Seoul 35 | restart: always 36 | volumes: 37 | - ./.env:/usr/src/app/.env 38 | network_mode: "host" 39 | healthcheck: 40 | test: [ "CMD", "nc", "-z", "localhost", "3002" ] 41 | interval: 10s 42 | timeout: 2s 43 | retries: 5 44 | -------------------------------------------------------------------------------- /backend/proxy-server/ecosystem.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | apps: [ 3 | { 4 | name: 'proxy-server', 5 | script: 'dist/index.js', // 빌드된 애플리케이션 진입점 6 | instances: 'max', // CPU 코어 수에 따라 인스턴스 수 자동 조절 7 | exec_mode: 'cluster', 8 | }, 9 | ], 10 | }; 11 | -------------------------------------------------------------------------------- /backend/proxy-server/jest.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | preset: 'ts-jest/presets/default-esm', // ES 모듈 지원을 위한 프리셋 3 | testEnvironment: 'node', // 테스트 환경 설정 4 | extensionsToTreatAsEsm: ['.ts'], // ESM으로 취급할 파일 확장자 5 | transform: { 6 | '^.+\\.ts$': [ 7 | 'ts-jest', 8 | { 9 | useESM: true, // ts-jest에서 ESM 사용 설정 10 | tsconfig: 'tsconfig.test.json', 11 | }, 12 | ], 13 | }, 14 | moduleNameMapper: { 15 | '^(\\.{1,2}/.*)\\.js$': '$1', // 모듈 경로 매핑 16 | '^domain/(.*)$': '/src/domain/$1', 17 | }, 18 | // 모듈 경로 해석을 위한 설정 추가 19 | moduleDirectories: ['node_modules', 'src'], 20 | }; 21 | -------------------------------------------------------------------------------- /backend/proxy-server/src/common/constant/error-message.constant.ts: -------------------------------------------------------------------------------- 1 | export const ErrorMessage = { 2 | DATABASE: { 3 | QUERY_FAILED: '데이터베이스 쿼리 중 문제가 발생했습니다.' 4 | }, 5 | DOMAIN: { 6 | NOT_FOUND: (domain: string) => `도메인 ${domain}에 대한 IP를 찾을 수 없습니다.` 7 | }, 8 | VALIDATION: { 9 | MISSING_HOST_HEADER: '요청에 Host 헤더가 없습니다.' 10 | } 11 | } as const; -------------------------------------------------------------------------------- /backend/proxy-server/src/common/constant/http-status.constant.ts: -------------------------------------------------------------------------------- 1 | export const HttpStatus = { 2 | BAD_REQUEST: 400, 3 | NOT_FOUND: 404, 4 | INTERNAL_SERVER_ERROR: 500 5 | } as const; -------------------------------------------------------------------------------- /backend/proxy-server/src/common/constant/http.constant.ts: -------------------------------------------------------------------------------- 1 | export const HTTP_PROTOCOL = 'http://'; 2 | export const HOST_HEADER = 'host'; 3 | -------------------------------------------------------------------------------- /backend/proxy-server/src/common/core/proxy-error.type.guard.ts: -------------------------------------------------------------------------------- 1 | import type { ProxyError } from './proxy.error'; 2 | 3 | export function isProxyError(error: unknown): error is ProxyError { 4 | return error instanceof Error && 'statusCode' in error; 5 | } 6 | -------------------------------------------------------------------------------- /backend/proxy-server/src/common/core/proxy.error.ts: -------------------------------------------------------------------------------- 1 | export class ProxyError extends Error { 2 | public readonly statusCode; 3 | public readonly originalError; 4 | private readonly NAME = 'ProxyError'; 5 | 6 | constructor(message: string, statusCode: number, originalError?: Error) { 7 | super(message); 8 | 9 | this.statusCode = statusCode; 10 | this.originalError = originalError; 11 | this.name = this.NAME; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /backend/proxy-server/src/common/error/database-query.error.ts: -------------------------------------------------------------------------------- 1 | import { ProxyError } from '../core/proxy.error'; 2 | import { ErrorMessage } from 'common/constant/error-message.constant'; 3 | import { HttpStatus } from 'common/constant/http-status.constant'; 4 | 5 | export class DatabaseQueryError extends ProxyError { 6 | constructor(originalError?: Error) { 7 | super(ErrorMessage.DATABASE.QUERY_FAILED, HttpStatus.NOT_FOUND, originalError); 8 | this.name = 'DatabaseQueryError'; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /backend/proxy-server/src/common/error/domain-not-found.error.ts: -------------------------------------------------------------------------------- 1 | import { ProxyError } from '../core/proxy.error'; 2 | import { ErrorMessage } from 'common/constant/error-message.constant'; 3 | import { HttpStatus } from 'common/constant/http-status.constant'; 4 | 5 | export class DomainNotFoundError extends ProxyError { 6 | constructor(domain: string, originalError?: Error) { 7 | super(ErrorMessage.DOMAIN.NOT_FOUND(domain), HttpStatus.NOT_FOUND, originalError); 8 | this.name = 'DomainNotFoundError'; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /backend/proxy-server/src/common/error/missing-host-header.error.ts: -------------------------------------------------------------------------------- 1 | import { ProxyError } from '../core/proxy.error'; 2 | import { ErrorMessage } from 'common/constant/error-message.constant'; 3 | import { HttpStatus } from 'common/constant/http-status.constant'; 4 | 5 | export class MissingHostHeaderError extends ProxyError { 6 | constructor() { 7 | super(ErrorMessage.VALIDATION.MISSING_HOST_HEADER, HttpStatus.BAD_REQUEST); 8 | this.name = 'MissingHostHeaderError'; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /backend/proxy-server/src/common/error/system.error.ts: -------------------------------------------------------------------------------- 1 | import type { SystemErrorLogContext } from 'common/error/types/system-error.type'; 2 | import type { ErrorLog } from 'common/logger/logger.interface'; 3 | 4 | export const createErrorLog = (context: SystemErrorLogContext): ErrorLog => { 5 | return { 6 | method: 'SYSTEM', 7 | host: 'localhost', 8 | path: context.path, 9 | request: { 10 | method: 'SYSTEM', 11 | host: 'localhost', 12 | headers: {}, 13 | }, 14 | error: { 15 | message: context.message, 16 | name: context.originalError instanceof Error ? context.originalError.name : 'Error', 17 | stack: context.originalError instanceof Error ? context.originalError.stack : undefined, 18 | originalError: context.originalError, 19 | }, 20 | }; 21 | }; 22 | -------------------------------------------------------------------------------- /backend/proxy-server/src/common/error/types/system-error.type.ts: -------------------------------------------------------------------------------- 1 | export interface SystemErrorLogContext { 2 | path: string; 3 | message: string; 4 | originalError: unknown; 5 | } 6 | -------------------------------------------------------------------------------- /backend/proxy-server/src/common/logger/createFastifyLogger.ts: -------------------------------------------------------------------------------- 1 | import type { FastifyInstance } from 'fastify'; 2 | import type { ErrorLog } from './logger.interface'; 3 | import type { HttpLogEntity } from 'domain/entity/http-log.entity'; 4 | 5 | export interface Logger { 6 | info(log: HttpLogEntity | { message: string }): void; 7 | 8 | error(log: ErrorLog): void; 9 | } 10 | 11 | export const createFastifyLogger = (server: FastifyInstance): Logger => { 12 | const logInfo = (log: HttpLogEntity | { message: string }): void => { 13 | server.log.info(log); 14 | }; 15 | 16 | const logError = (log: ErrorLog): void => { 17 | server.log.error(log); 18 | }; 19 | 20 | return { 21 | info: logInfo, 22 | error: logError, 23 | }; 24 | }; 25 | -------------------------------------------------------------------------------- /backend/proxy-server/src/common/logger/error-log.repository.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs'; 3 | import type { ErrorLog } from './logger.interface'; 4 | 5 | export class ErrorLogRepository { 6 | private readonly logDir = 'logs'; 7 | private readonly errorLogFile = 'error.log'; 8 | 9 | constructor() { 10 | this.initializeLogDirectory(); 11 | } 12 | 13 | public async saveErrorLog(log: ErrorLog): Promise { 14 | const logEntry = JSON.stringify({ 15 | ...log, 16 | timestamp: new Date(), 17 | }); 18 | await this.appendToErrorLog(logEntry); 19 | } 20 | 21 | private async appendToErrorLog(data: string): Promise { 22 | const filePath = path.join(this.logDir, this.errorLogFile); 23 | try { 24 | await fs.promises.appendFile(filePath, data + '\n'); 25 | } catch (error) { 26 | console.error('Failed to write error log:', error); 27 | } 28 | } 29 | private initializeLogDirectory(): void { 30 | try { 31 | if (!fs.existsSync(this.logDir)) { 32 | fs.mkdirSync(this.logDir); 33 | } 34 | } catch (error) { 35 | console.error('Failed to create log directory:', error); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /backend/proxy-server/src/common/logger/logger.interface.ts: -------------------------------------------------------------------------------- 1 | export interface ErrorLog { 2 | method: string; 3 | host: string; 4 | path: string; 5 | request: { 6 | method: string; 7 | host: string; 8 | path?: string; 9 | headers: { 10 | 'user-agent'?: string | undefined; 11 | 'content-type'?: string | undefined; 12 | 'x-forwarded-for'?: string | string[] | undefined; 13 | }; 14 | }; 15 | error: { 16 | message: string; 17 | name: string; 18 | stack?: string; 19 | originalError?: unknown; 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /backend/proxy-server/src/common/utils/date.util.ts: -------------------------------------------------------------------------------- 1 | export function formatDateTime(date: Date): string { 2 | const year = date.getFullYear(); 3 | const month = String(date.getMonth() + 1).padStart(2, '0'); 4 | const day = String(date.getDate()).padStart(2, '0'); 5 | const hours = String(date.getHours()).padStart(2, '0'); 6 | const minutes = String(date.getMinutes()).padStart(2, '0'); 7 | const seconds = String(date.getSeconds()).padStart(2, '0'); 8 | 9 | return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; 10 | } -------------------------------------------------------------------------------- /backend/proxy-server/src/database/clickhouse/clickhouse-database.ts: -------------------------------------------------------------------------------- 1 | import { createClient, ClickHouseClient } from '@clickhouse/client'; 2 | import { clickhouseConfig } from './config/clickhouse.config'; 3 | 4 | export class ClickhouseDatabase { 5 | private static instance: ClickHouseClient; 6 | 7 | public static getInstance(): ClickHouseClient { 8 | if (!ClickhouseDatabase.instance) { 9 | ClickhouseDatabase.instance = createClient(clickhouseConfig); 10 | } 11 | return ClickhouseDatabase.instance; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /backend/proxy-server/src/database/clickhouse/config/buffer.config.ts: -------------------------------------------------------------------------------- 1 | export interface BufferConfig { 2 | maxSize: number; 3 | flushIntervalSecond: number; 4 | } 5 | 6 | export const logBufferConfig: BufferConfig = { 7 | maxSize: 1000, 8 | flushIntervalSecond: 5, 9 | }; 10 | -------------------------------------------------------------------------------- /backend/proxy-server/src/database/clickhouse/config/clickhouse.config.ts: -------------------------------------------------------------------------------- 1 | import { ClickHouseClientConfigOptions } from '@clickhouse/client'; 2 | 3 | export const clickhouseConfig: ClickHouseClientConfigOptions = { 4 | url: process.env.CLICKHOUSE_URL || 'http://localhost:8123', 5 | username: process.env.CLICKHOUSE_USERNAME || 'default', 6 | password: process.env.CLICKHOUSE_PASSWORD || '', 7 | database: process.env.CLICKHOUSE_DATABASE, 8 | clickhouse_settings: { 9 | async_insert: 1, 10 | wait_for_async_insert: 0, 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /backend/proxy-server/src/database/mysql/config/pool.config.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | import type { PoolOptions } from 'mysql2/promise'; 3 | 4 | dotenv.config(); 5 | 6 | export const poolConfig: PoolOptions = { 7 | host: process.env.DB_HOST, 8 | port: Number(process.env.DB_PORT), 9 | user: process.env.DB_USERNAME, 10 | password: process.env.DB_PASSWORD, 11 | database: process.env.DB_NAME, 12 | }; 13 | -------------------------------------------------------------------------------- /backend/proxy-server/src/database/query/project-cache.repository.redis.ts: -------------------------------------------------------------------------------- 1 | import { RedisClient } from 'database/redis/redis-database'; 2 | import { ProjectCacheRepository } from 'domain/port/output/project-cache.repository'; 3 | 4 | export class ProjectCacheRepositoryRedis implements ProjectCacheRepository { 5 | readonly TTL_DAY = 24 * 60 * 60; 6 | private readonly redisClient: RedisClient; 7 | 8 | constructor() { 9 | this.redisClient = RedisClient.getInstance(); 10 | } 11 | 12 | async findIpByDomain(domain: string): Promise { 13 | return await this.redisClient.get(domain); 14 | } 15 | 16 | async cacheIpByDomain(domain: string, ip: string): Promise { 17 | this.redisClient.set(domain, ip, this.TTL_DAY); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /backend/proxy-server/src/database/query/project.repository.mysql.ts: -------------------------------------------------------------------------------- 1 | import { MysqlDatabase } from '../mysql/mysql-database'; 2 | import type { RowDataPacket } from 'mysql2/promise'; 3 | import { ProjectRepository } from 'domain/port/output/project.repository'; 4 | import { ProjectEntity } from 'domain/entity/project.entity'; 5 | 6 | export interface ProjectRow extends RowDataPacket { 7 | ip: string; 8 | } 9 | 10 | export class ProjectRepositoryMysql implements ProjectRepository { 11 | private readonly mysqlDatabase: MysqlDatabase; 12 | 13 | constructor() { 14 | this.mysqlDatabase = MysqlDatabase.getInstance(); 15 | } 16 | 17 | async findIpByDomain(domain: string): Promise { 18 | const sql = `SELECT ip 19 | FROM project 20 | WHERE domain = ?`; 21 | const params = [domain]; 22 | const rows = await this.mysqlDatabase.query(sql, params); 23 | 24 | return rows[0].ip; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /backend/proxy-server/src/domain/entity/http-log.entity.test.ts: -------------------------------------------------------------------------------- 1 | import { HttpLogEntity } from 'domain/entity/http-log.entity'; 2 | import type { HttpLogType } from 'domain/port/input/log.use-case'; 3 | 4 | describe('HttpLogEntity 테스트', () => { 5 | let log: HttpLogType; 6 | 7 | beforeEach(() => { 8 | log = { 9 | method: 'GET', 10 | host: 'api.example.com', 11 | path: '/users', 12 | statusCode: 200, 13 | responseTime: 100, 14 | userIp: '127.0.0.1', 15 | }; 16 | }); 17 | 18 | it('모든 속성을 가지고 인스턴스를 생성해야 한다.', () => { 19 | const httpLogEntity = new HttpLogEntity(log); 20 | 21 | expect(httpLogEntity.method).toBe(log.method); 22 | expect(httpLogEntity.host).toBe(log.host); 23 | expect(httpLogEntity.path).toBe(log.path); 24 | expect(httpLogEntity.statusCode).toBe(log.statusCode); 25 | expect(httpLogEntity.responseTime).toBe(log.responseTime); 26 | }); 27 | 28 | it('path 속성이 undefined인 경우에 올바르게 처리해야 한다.', () => { 29 | log.path = undefined; 30 | 31 | const httpLogEntity = new HttpLogEntity(log); 32 | 33 | expect(httpLogEntity.path).toBeUndefined(); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /backend/proxy-server/src/domain/entity/http-log.entity.ts: -------------------------------------------------------------------------------- 1 | interface HttpLog { 2 | method: string; 3 | host: string; 4 | path?: string; 5 | statusCode: number; 6 | responseTime: number; 7 | userIp: string; 8 | } 9 | 10 | export class HttpLogEntity { 11 | readonly method: string; 12 | readonly host: string; 13 | readonly path: string | undefined; 14 | readonly statusCode: number; 15 | readonly responseTime: number; 16 | readonly userIp: string; 17 | 18 | constructor(log: HttpLog) { 19 | this.method = log.method; 20 | this.host = log.host; 21 | this.path = log.path; 22 | this.statusCode = log.statusCode; 23 | this.responseTime = log.responseTime; 24 | this.userIp = log.userIp; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /backend/proxy-server/src/domain/entity/project.entity.ts: -------------------------------------------------------------------------------- 1 | import type { ProjectRow } from 'database/query/project.repository.mysql'; 2 | 3 | type Project = ProjectRow; 4 | 5 | export class ProjectEntity { 6 | readonly ip: string; 7 | 8 | constructor(project: Project) { 9 | this.ip = project.ip; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /backend/proxy-server/src/domain/port/input/log.use-case.ts: -------------------------------------------------------------------------------- 1 | export type HttpLogType = { 2 | method: string; 3 | host: string; 4 | path: string | undefined; 5 | statusCode: number; 6 | responseTime: number; 7 | userIp: string; 8 | }; 9 | 10 | export interface LogUseCase { 11 | saveHttpLog(log: HttpLogType): Promise; 12 | } 13 | -------------------------------------------------------------------------------- /backend/proxy-server/src/domain/port/input/project.use-case.ts: -------------------------------------------------------------------------------- 1 | export interface ProjectUseCase { 2 | resolveTargetUrl(host: string, url: string, protocol: string): Promise; 3 | } 4 | -------------------------------------------------------------------------------- /backend/proxy-server/src/domain/port/output/log.repository.ts: -------------------------------------------------------------------------------- 1 | import type { HttpLogEntity } from 'domain/entity/http-log.entity'; 2 | 3 | export interface LogRepository { 4 | insertHttpLog(log: HttpLogEntity): Promise; 5 | } 6 | -------------------------------------------------------------------------------- /backend/proxy-server/src/domain/port/output/project-cache.repository.ts: -------------------------------------------------------------------------------- 1 | export interface ProjectCacheRepository { 2 | findIpByDomain(domain: string): Promise; 3 | cacheIpByDomain(domain: string, ip: string): Promise; 4 | } 5 | -------------------------------------------------------------------------------- /backend/proxy-server/src/domain/port/output/project.repository.ts: -------------------------------------------------------------------------------- 1 | export interface ProjectRepository { 2 | findIpByDomain(domain: string): Promise; 3 | } 4 | -------------------------------------------------------------------------------- /backend/proxy-server/src/domain/service/log.service.ts: -------------------------------------------------------------------------------- 1 | import type { HttpLogEntity } from 'domain/entity/http-log.entity'; 2 | import type { LogRepository } from 'domain/port/output/log.repository'; 3 | import type { LogUseCase } from 'domain/port/input/log.use-case'; 4 | 5 | export class LogService implements LogUseCase { 6 | constructor(private readonly logRepository: LogRepository) {} 7 | 8 | async saveHttpLog(log: HttpLogEntity): Promise { 9 | return this.logRepository.insertHttpLog(log); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /backend/proxy-server/src/domain/util/utils.ts: -------------------------------------------------------------------------------- 1 | import { DomainNotFoundError } from 'common/error/domain-not-found.error'; 2 | import { MissingHostHeaderError } from 'common/error/missing-host-header.error'; 3 | import { ProxyError } from 'common/core/proxy.error'; 4 | 5 | export function validateHost(host: string | undefined): string { 6 | if (!host) { 7 | throw new MissingHostHeaderError(); 8 | } 9 | return host; 10 | } 11 | 12 | export function validateIp(host: string, ip: string): void { 13 | if (!ip) { 14 | throw new DomainNotFoundError(host); 15 | } 16 | } 17 | 18 | export function buildTargetUrl(ip: string, path: string, protocol: string): string { 19 | try { 20 | return `${protocol}${ip}${path || '/'}`; 21 | } catch (error) { 22 | throw new ProxyError('대상 URL 생성 중 오류가 발생했습니다.', 500, error as Error); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /backend/proxy-server/src/index.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import { fastifyServer } from 'server/fastify.server'; 3 | import type { FastifyInstance } from 'fastify'; 4 | import type { Logger } from 'common/logger/createFastifyLogger'; 5 | 6 | const SIGNALS = ['SIGINT', 'SIGTERM']; 7 | 8 | async function main() { 9 | const { server, logger } = await fastifyServer.listen(); 10 | 11 | SIGNALS.forEach((signal) => { 12 | process.on(signal, async () => await handleShutdown(signal, server, logger)); 13 | }); 14 | } 15 | 16 | async function handleShutdown(signal: string, server: FastifyInstance, logger: Logger) { 17 | await fastifyServer.stop(server, logger); 18 | 19 | console.log({ message: `Server stopped on ${signal}` }); 20 | process.exit(0); 21 | } 22 | 23 | main().catch((error) => { 24 | console.error('Fatal error:', error); 25 | process.exit(1); 26 | }); 27 | -------------------------------------------------------------------------------- /backend/proxy-server/src/server/adapter/log.adapter.ts: -------------------------------------------------------------------------------- 1 | import type { HttpLogType, LogUseCase } from 'domain/port/input/log.use-case'; 2 | 3 | export class LogAdapter { 4 | constructor(private readonly logUseCase: LogUseCase) {} 5 | 6 | async saveHttpLog(httpLog: HttpLogType): Promise { 7 | this.logUseCase.saveHttpLog(httpLog); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /backend/proxy-server/src/server/adapter/project.adapter.ts: -------------------------------------------------------------------------------- 1 | import type { ProjectUseCase } from 'domain/port/input/project.use-case'; 2 | import { HOST_HEADER } from 'common/constant/http.constant'; 3 | import type { FastifyRequest } from 'fastify'; 4 | 5 | export class ProjectAdapter { 6 | constructor(private readonly projectUseCase: ProjectUseCase) {} 7 | 8 | async resolveTargetUrl(request: FastifyRequest) { 9 | return await this.projectUseCase.resolveTargetUrl( 10 | request.headers[HOST_HEADER] as string, 11 | request.url, 12 | request.protocol, 13 | ); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /backend/proxy-server/src/server/config/server.configuration.ts: -------------------------------------------------------------------------------- 1 | interface ListenConfig { 2 | port: number; 3 | host: string | undefined; 4 | } 5 | 6 | export const listenConfig: ListenConfig = { 7 | port: Number(process.env.PORT), 8 | host: process.env.LISTENING_HOST, 9 | }; 10 | -------------------------------------------------------------------------------- /backend/proxy-server/src/server/handler/health-check.handler.ts: -------------------------------------------------------------------------------- 1 | import type { FastifyReply } from 'fastify/types/reply'; 2 | import type { FastifyRequest } from 'fastify/types/request'; 3 | 4 | type HealthResponse = { 5 | status: string; 6 | timestamp: string; 7 | }; 8 | 9 | export const healthCheck = async (_request: FastifyRequest, reply: FastifyReply) => { 10 | const response: HealthResponse = { 11 | status: 'healthy', 12 | timestamp: new Date().toISOString(), 13 | }; 14 | 15 | return reply.code(200).send(response); 16 | }; 17 | -------------------------------------------------------------------------------- /backend/proxy-server/src/server/handler/log.handler.ts: -------------------------------------------------------------------------------- 1 | import { createErrorLog } from 'common/error/system.error'; 2 | import type { FastifyReply, FastifyRequest } from 'fastify'; 3 | import type { Logger } from 'common/logger/createFastifyLogger'; 4 | import type { LogAdapter } from 'server/adapter/log.adapter'; 5 | import type { HttpLogType } from 'domain/port/input/log.use-case'; 6 | 7 | export const logHandler = ( 8 | request: FastifyRequest, 9 | reply: FastifyReply, 10 | logger: Logger, 11 | logAdapter: LogAdapter, 12 | ) => { 13 | try { 14 | const ip = resolveClientIp(request); 15 | 16 | const httpLog: HttpLogType = { 17 | method: request.method, 18 | host: request.host, 19 | path: request.raw.url, 20 | statusCode: reply.statusCode, 21 | responseTime: reply.elapsedTime, 22 | userIp: ip, 23 | }; 24 | 25 | logger.info(httpLog); 26 | logAdapter.saveHttpLog(httpLog); 27 | } catch (error) { 28 | logger.error( 29 | createErrorLog({ 30 | originalError: error, 31 | path: '/log/response', 32 | message: 'Failed to save http log', 33 | }), 34 | ); 35 | } 36 | }; 37 | 38 | const resolveClientIp = (request: FastifyRequest) => { 39 | const originalIp = 40 | (request.headers['x-real-ip'] as string) || 41 | (request.headers['x-forwarded-for'] as string) || 42 | request.ip; 43 | 44 | return Array.isArray(originalIp) ? originalIp[0] : originalIp.split(',')[0].trim(); 45 | }; 46 | -------------------------------------------------------------------------------- /backend/proxy-server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "node", 6 | "outDir": "dist", 7 | "rootDir": "src", 8 | "esModuleInterop": true, 9 | "strict": true, 10 | "allowSyntheticDefaultImports": true, 11 | "experimentalDecorators": true, 12 | "emitDecoratorMetadata": true, 13 | "skipLibCheck": true, 14 | "resolveJsonModule": true, 15 | "declaration": true, 16 | "typeRoots": [ 17 | "./node_modules/@types" 18 | ], 19 | "baseUrl": "./", 20 | "paths": { 21 | "*": [ 22 | "src/*" 23 | ] 24 | } 25 | }, 26 | "include": [ 27 | "src/**/*", 28 | "test/**/*" 29 | ], 30 | "exclude": [ 31 | "node_modules", 32 | "dist", 33 | "test" 34 | ], 35 | "tsc-alias": { 36 | "resolveFullPaths": true, 37 | "resolveFullExtension": ".js" 38 | } 39 | } -------------------------------------------------------------------------------- /backend/proxy-server/tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "noEmit": true, 6 | "module": "ESNext", 7 | "moduleResolution": "node", 8 | "esModuleInterop": true, 9 | "allowSyntheticDefaultImports": true 10 | }, 11 | "include": ["test/**/*"], 12 | "exclude": ["node_modules", "dist"] 13 | } -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/react 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=react 3 | 4 | ### react ### 5 | .DS_* 6 | *.log 7 | logs 8 | **/*.backup.* 9 | **/*.back.* 10 | 11 | node_modules 12 | bower_components 13 | 14 | *.sublime* 15 | 16 | psd 17 | thumb 18 | sketch 19 | .env 20 | 21 | # End of https://www.toptal.com/developers/gitignore/api/react 22 | # Editor directories and files -------------------------------------------------------------------------------- /frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "bracketSameLine": true, 4 | "bracketSpacing": true, 5 | "semi": true, 6 | "singleQuote": true, 7 | "jsxSingleQuote": true, 8 | "quoteProps": "as-needed", 9 | "trailingComma": "none", 10 | "printWidth": 100, 11 | "tabWidth": 2, 12 | "useTabs": false, 13 | "plugins": ["prettier-plugin-tailwindcss"] 14 | } 15 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | ## 프론트엔드 2 | 3 | 안녕하세요. 여긴 와치독스 프론트엔드 디렉토리입니다. 4 | 5 | 프론트엔드 환경은 다음과 같습니다. 6 | 7 | Main : React + Typescript 8 | 9 | build : vite 10 | 11 | css : tailwindCSS 12 | 13 | ## 프로젝트 실행 방법 14 | 15 | ``` bash 16 | npm run dev 17 | ``` 18 | 19 | -------------------------------------------------------------------------------- /frontend/asset/image/CloudFlareDescription.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web35-watchducks/b94a0865ef9d8b824d58938dedbc3c85e68aa4d4/frontend/asset/image/CloudFlareDescription.png -------------------------------------------------------------------------------- /frontend/asset/image/Favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web35-watchducks/b94a0865ef9d8b824d58938dedbc3c85e68aa4d4/frontend/asset/image/Favicon.png -------------------------------------------------------------------------------- /frontend/asset/image/FaviconLight.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web35-watchducks/b94a0865ef9d8b824d58938dedbc3c85e68aa4d4/frontend/asset/image/FaviconLight.png -------------------------------------------------------------------------------- /frontend/asset/image/FirstMedal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web35-watchducks/b94a0865ef9d8b824d58938dedbc3c85e68aa4d4/frontend/asset/image/FirstMedal.png -------------------------------------------------------------------------------- /frontend/asset/image/GabiaDescription.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web35-watchducks/b94a0865ef9d8b824d58938dedbc3c85e68aa4d4/frontend/asset/image/GabiaDescription.png -------------------------------------------------------------------------------- /frontend/asset/image/Github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web35-watchducks/b94a0865ef9d8b824d58938dedbc3c85e68aa4d4/frontend/asset/image/Github.png -------------------------------------------------------------------------------- /frontend/asset/image/ProjectActive.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web35-watchducks/b94a0865ef9d8b824d58938dedbc3c85e68aa4d4/frontend/asset/image/ProjectActive.png -------------------------------------------------------------------------------- /frontend/asset/image/ProjectInactive.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web35-watchducks/b94a0865ef9d8b824d58938dedbc3c85e68aa4d4/frontend/asset/image/ProjectInactive.png -------------------------------------------------------------------------------- /frontend/asset/image/ProjectsActive.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /frontend/asset/image/ProjectsInactive.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web35-watchducks/b94a0865ef9d8b824d58938dedbc3c85e68aa4d4/frontend/asset/image/ProjectsInactive.png -------------------------------------------------------------------------------- /frontend/asset/image/ProjectsInactive.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /frontend/asset/image/RankingActive.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web35-watchducks/b94a0865ef9d8b824d58938dedbc3c85e68aa4d4/frontend/asset/image/RankingActive.png -------------------------------------------------------------------------------- /frontend/asset/image/RankingInactive.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web35-watchducks/b94a0865ef9d8b824d58938dedbc3c85e68aa4d4/frontend/asset/image/RankingInactive.png -------------------------------------------------------------------------------- /frontend/asset/image/SecondMedal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web35-watchducks/b94a0865ef9d8b824d58938dedbc3c85e68aa4d4/frontend/asset/image/SecondMedal.png -------------------------------------------------------------------------------- /frontend/asset/image/ThirdMedal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web35-watchducks/b94a0865ef9d8b824d58938dedbc3c85e68aa4d4/frontend/asset/image/ThirdMedal.png -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 11 | 16 | 17 | 18 | 22 | 23 | 27 | 31 | 32 | WatchDucks 33 | 34 | 35 |
36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /frontend/netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | command = "npm run build" 3 | publish = "dist" 4 | 5 | [[redirects]] 6 | from = "/*" 7 | to = "/index.html" 8 | status = 200 -------------------------------------------------------------------------------- /frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import MobileLayout from '@component/template/MobileLayout'; 2 | import useIsMobile from '@hook/useIsMobile'; 3 | import router from '@router/Router'; 4 | import { RouterProvider } from 'react-router-dom'; 5 | 6 | export default function App() { 7 | const isMobile = useIsMobile(); 8 | 9 | return ( 10 | <> 11 | {isMobile && } 12 | 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /frontend/src/api/README.md: -------------------------------------------------------------------------------- 1 | # 여기는 🖥️api 디렉토리입니다 2 | 3 | 백엔드와 통신하는 api함수들을 모아놓은 디렉토리 입니다. 4 | 5 | 구현방법 6 | 7 | ```ts 8 | const postFunctionName = async (): => { 9 | const response = await axios.post('/api/register', data); 10 | return response.data; 11 | }; 12 | ``` 13 | 14 | - 카멜케이스를 사용합니다. 15 | - http메소드명을 함수이름앞에 작성합니다. 16 | -------------------------------------------------------------------------------- /frontend/src/api/axios.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { AxiosResponse } from 'axios'; 3 | 4 | const $axios = axios.create({ 5 | baseURL: import.meta.env.VITE_API_URL 6 | }); 7 | 8 | const api = { 9 | get: async (url: string): Promise> => { 10 | return $axios.get>(url); 11 | }, 12 | 13 | post: async (url: string, data: P): Promise> => { 14 | return $axios.post(url, data); 15 | }, 16 | }; 17 | 18 | export { api }; 19 | -------------------------------------------------------------------------------- /frontend/src/api/get/ProjectPage.ts: -------------------------------------------------------------------------------- 1 | import { api } from '@api/axios'; 2 | import { 3 | ProjectElapsedTime, 4 | ProjectSuccessRate, 5 | ProjectDAU, 6 | ProjectTraffic, 7 | ProjectExist 8 | } from '@type/api'; 9 | import { GroupOption } from '@type/Navbar'; 10 | 11 | export const getGroupNames = async (generation: string) => { 12 | const response = await api.get(`/project?generation=${generation}`); 13 | return response.data.map((item) => ({ 14 | value: item.value, 15 | label: item.value 16 | })); 17 | }; 18 | 19 | export const getSuccessRate = async (project: string) => { 20 | const response = await api.get( 21 | `/log/success-rate/project?projectName=${project}` 22 | ); 23 | return response.data; 24 | }; 25 | 26 | export const getDAU = async (project: string) => { 27 | const response = await api.get(`/log/analytics/dau?projectName=${project}`); 28 | return response.data; 29 | }; 30 | 31 | export const getElapsedTime = async (project: string) => { 32 | const response = await api.get( 33 | `/log/elapsed-time/path-rank?projectName=${project}` 34 | ); 35 | return response.data; 36 | }; 37 | 38 | export const getTraffic = async (project: string, dateType: string) => { 39 | const response = await api.get( 40 | `/log/traffic/project?projectName=${project}&timeRange=${dateType}` 41 | ); 42 | return response.data; 43 | }; 44 | 45 | export const getIsExistProject = async (project: string) => { 46 | const response = await api.get(`/project/exists?projectName=${project}`); 47 | return response.data; 48 | }; 49 | -------------------------------------------------------------------------------- /frontend/src/api/get/RankingPage.ts: -------------------------------------------------------------------------------- 1 | import { api } from '@api/axios'; 2 | import { RankingData } from '@type/api'; 3 | 4 | export const getRankingSuccessRate = async (generation: string) => { 5 | const response = await api.get(`/log/rank/success-rate?generation=${generation}`); 6 | return response.data; 7 | }; 8 | 9 | export const getRankingElapsedTime = async (generation: string) => { 10 | const response = await api.get(`/log/rank/elapsed-time?generation=${generation}`); 11 | return response.data; 12 | }; 13 | 14 | export const getRankingTraffic = async (generation: string) => { 15 | const response = await api.get(`/log/rank/traffic?generation=${generation}`); 16 | return response.data; 17 | }; 18 | 19 | export const getRankingDAU = async (generation: string) => { 20 | const response = await api.get(`/log/rank/dau?generation=${generation}`); 21 | return response.data; 22 | }; 23 | -------------------------------------------------------------------------------- /frontend/src/api/post.ts: -------------------------------------------------------------------------------- 1 | import { FormState } from '@type/RegisterForm'; 2 | 3 | import { api } from './axios'; 4 | 5 | const postRegister = async (data: FormState) => { 6 | const response = await api.post('/project', data); 7 | return response.data; 8 | }; 9 | 10 | export { postRegister }; 11 | -------------------------------------------------------------------------------- /frontend/src/boundary/CustomErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import CustomErrorFallback from '@boundary/CustomErrorFallback'; 2 | import Loading from '@component/atom/Loading'; 3 | import { QueryErrorResetBoundary } from '@tanstack/react-query'; 4 | import { Suspense } from 'react'; 5 | import { ErrorBoundary } from 'react-error-boundary'; 6 | 7 | type Props = { 8 | children: React.ReactNode; 9 | }; 10 | 11 | export default function CustomErrorBoundary({ children }: Props) { 12 | return ( 13 | 14 | {({ reset }) => ( 15 | 16 | 19 | 20 | 21 | }> 22 | {children} 23 | 24 | 25 | )} 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /frontend/src/boundary/CustomErrorFallback.tsx: -------------------------------------------------------------------------------- 1 | import { useQueryErrorResetBoundary } from '@tanstack/react-query'; 2 | 3 | type Props = { 4 | resetErrorBoundary: () => void; 5 | }; 6 | 7 | export default function CustomErrorFallback({ resetErrorBoundary }: Props) { 8 | const { reset } = useQueryErrorResetBoundary(); 9 | 10 | const handleClickReset = () => { 11 | reset(); 12 | resetErrorBoundary(); 13 | }; 14 | 15 | return ( 16 |
17 |

요청이 만료됐습니다. 재시도해주세요

18 | 23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /frontend/src/boundary/CustomQueryProvider.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 2 | 3 | type Props = { 4 | children: React.ReactNode; 5 | }; 6 | 7 | const queryClient = new QueryClient({ 8 | defaultOptions: { 9 | queries: { 10 | staleTime: 5 * 60 * 1000 11 | } 12 | } 13 | }); 14 | 15 | export default function CustomQueryProvider({ children }: Props) { 16 | return {children}; 17 | } 18 | -------------------------------------------------------------------------------- /frontend/src/boundary/MainBoundary.tsx: -------------------------------------------------------------------------------- 1 | import NavbarLayout from '@component/template/NabvarLayout'; 2 | import useNavbarStore from '@store/NavbarStore'; 3 | import { motion } from 'framer-motion'; 4 | import { Outlet, useLocation } from 'react-router-dom'; 5 | 6 | export default function MainBoundary() { 7 | const { isNavbarOpen } = useNavbarStore(); 8 | const location = useLocation(); 9 | const hideNavbarPaths = ['/register', '*', '/404']; 10 | const showNavbar = !hideNavbarPaths.some((path) => location.pathname === path); 11 | 12 | return ( 13 |
14 | {showNavbar && } 15 | 21 | 22 | 23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /frontend/src/boundary/README.md: -------------------------------------------------------------------------------- 1 | # 여기는 🌫️boundary 디렉토리입니다 2 | 3 | 에러처리를 위한 컴포넌트들을 모아놓은 디렉토리입니다. 4 | 5 | 사용방법 6 | 7 | ```tsx 8 | import CustomErrorBoundary from 'boundary/CustomErrorBoundary'; 9 | 10 | 11 | 12 | ; 13 | ``` 14 | 15 | - UI관련 Suspense 처리를 하고싶을 때, CustomErrorBoundary컴포넌트로 감싸주면 됩니다. 16 | -------------------------------------------------------------------------------- /frontend/src/boundary/toastError.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError } from 'axios'; 2 | 3 | type ErrorCodeType = { 4 | [key: string]: { code: string; message: string }; 5 | }; 6 | 7 | const ERROR_CODE: ErrorCodeType = { 8 | default: { code: 'ERROR', message: '알 수 없는 오류가 발생했습니다.' }, 9 | 10 | ERR_NETWORK: { 11 | code: '통신 에러', 12 | message: '네트워크가 응답하지 않습니다.' 13 | }, 14 | 15 | ECONNABORTED: { code: '요청 시간 초과', message: '요청 시간을 초과했습니다.' }, 16 | 17 | 400: { code: '400', message: '잘못된 요청.' }, 18 | 404: { code: '404', message: '요청한 리소스를 찾을 수 없습니다.' }, 19 | 409: { code: '409', message: '중복된 도메인입니다!' } 20 | }; 21 | 22 | export const getErrorByCode = (error: AxiosError<{ code: number; message: string }>) => { 23 | const serverErrorCode = error?.response?.data?.code ?? ''; 24 | const httpErrorCode = error?.response?.status ?? ''; 25 | const axiosErrorCode = error?.code ?? ''; 26 | if (serverErrorCode in ERROR_CODE) { 27 | return ERROR_CODE[serverErrorCode as keyof typeof ERROR_CODE]; 28 | } 29 | if (httpErrorCode in ERROR_CODE) { 30 | return ERROR_CODE[httpErrorCode as keyof typeof ERROR_CODE]; 31 | } 32 | if (axiosErrorCode in ERROR_CODE) { 33 | return ERROR_CODE[axiosErrorCode as keyof typeof ERROR_CODE]; 34 | } 35 | return ERROR_CODE.default; 36 | }; 37 | -------------------------------------------------------------------------------- /frontend/src/chart/BarChart.tsx: -------------------------------------------------------------------------------- 1 | import { merge } from 'lodash-es'; 2 | 3 | import { Chart } from './Chart'; 4 | 5 | type Props = { 6 | series: ApexAxisChartSeries; 7 | options: ApexCharts.ApexOptions; 8 | }; 9 | 10 | export default function BarChart({ series, options: additionalOptions }: Props) { 11 | const barChartOptions: ApexCharts.ApexOptions = { 12 | chart: { 13 | height: '100%', 14 | type: 'bar' 15 | }, 16 | plotOptions: { 17 | bar: { 18 | borderRadius: 4, 19 | borderRadiusApplication: 'end', 20 | horizontal: true, 21 | dataLabels: { 22 | position: 'bottom' 23 | }, 24 | columnWidth: '100%', 25 | distributed: true 26 | } 27 | }, 28 | fill: { 29 | type: 'gradient' 30 | }, 31 | yaxis: { 32 | labels: { 33 | style: { 34 | colors: '#64748B', 35 | fontSize: '12px' 36 | } 37 | } 38 | }, 39 | legend: { 40 | labels: { 41 | colors: '#64748B' 42 | } 43 | }, 44 | grid: { 45 | padding: { 46 | left: 10, 47 | right: 10 48 | } 49 | } 50 | }; 51 | 52 | const options = merge({}, barChartOptions, additionalOptions); 53 | 54 | return ; 55 | } 56 | -------------------------------------------------------------------------------- /frontend/src/chart/Chart.tsx: -------------------------------------------------------------------------------- 1 | import { useDefaultOptions } from '@hook/useDefaultOption'; 2 | import { ApexOptions } from 'apexcharts'; 3 | import { merge } from 'lodash-es'; 4 | import ReactApexChart from 'react-apexcharts'; 5 | 6 | type ApexChartType = 7 | | 'line' 8 | | 'area' 9 | | 'bar' 10 | | 'pie' 11 | | 'donut' 12 | | 'radialBar' 13 | | 'scatter' 14 | | 'bubble' 15 | | 'heatmap' 16 | | 'treemap' 17 | | 'boxPlot' 18 | | 'candlestick' 19 | | 'radar' 20 | | 'polarArea' 21 | | 'rangeBar'; 22 | 23 | type Props = { 24 | type: ApexChartType; 25 | series: ApexOptions['series']; 26 | options: ApexCharts.ApexOptions; 27 | }; 28 | 29 | export function Chart({ type, options: chartOptions, series }: Props) { 30 | const { defaultOptions } = useDefaultOptions(); 31 | 32 | const options = merge({}, defaultOptions, chartOptions); 33 | 34 | return ( 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /frontend/src/chart/PieChart.tsx: -------------------------------------------------------------------------------- 1 | import { CHART_COLORS } from '@constant/Chart'; 2 | import { merge } from 'lodash-es'; 3 | 4 | import { Chart } from './Chart'; 5 | 6 | type Props = { 7 | series: number[]; 8 | options: ApexCharts.ApexOptions; 9 | }; 10 | 11 | export default function PieChart({ series, options: additionalOptions }: Props) { 12 | const donutChartOption: ApexCharts.ApexOptions = { 13 | chart: { 14 | height: 300, 15 | type: 'donut' 16 | }, 17 | legend: { 18 | position: 'bottom' 19 | }, 20 | colors: CHART_COLORS 21 | }; 22 | 23 | const options = merge({}, donutChartOption, additionalOptions); 24 | return ; 25 | } 26 | -------------------------------------------------------------------------------- /frontend/src/chart/PolarAreaChart.tsx: -------------------------------------------------------------------------------- 1 | import { merge } from 'lodash-es'; 2 | 3 | import { Chart } from './Chart'; 4 | 5 | type Props = { 6 | series: number[]; 7 | options: ApexCharts.ApexOptions; 8 | }; 9 | 10 | export default function PolarAreaChart({ series, options: additionalOptions }: Props) { 11 | const PolarChartOption: ApexCharts.ApexOptions = { 12 | chart: { 13 | type: 'polarArea', 14 | height: '100%', 15 | width: '100%', 16 | redrawOnParentResize: true, 17 | redrawOnWindowResize: true 18 | }, 19 | plotOptions: { 20 | polarArea: { 21 | rings: { 22 | strokeWidth: 1, 23 | strokeColor: '#e8e8e8' 24 | }, 25 | spokes: { 26 | strokeWidth: 1, 27 | connectorColors: '#e8e8e8' 28 | } 29 | } 30 | } 31 | }; 32 | 33 | const options = merge({}, PolarChartOption, additionalOptions); 34 | return ; 35 | } 36 | -------------------------------------------------------------------------------- /frontend/src/chart/README.md: -------------------------------------------------------------------------------- 1 | # 여기는 📊chart 디렉토리입니다 2 | 3 | 공통으로 차트들을 모아놓은 디렉토리 입니다. 4 | option을 다르게해, Chart컴포넌트를 호출하는식으로 사용합니다. 5 | 6 | 구현방법 7 | 8 | ```ts 9 | export default function BarChart({ series, options: additionalOptions }: Props) { 10 | const barChartOptions: ApexCharts.ApexOptions = { 11 | chart: { 12 | height: '100%', 13 | type: 'bar' 14 | }, 15 | }; 16 | const options = merge({}, barChartOptions, additionalOptions); 17 | return ; 18 | }; 19 | ``` 20 | 21 | - 차트종류에 맞게 파스칼케이스로 함수명을 짓습니다. 22 | - series는 차트에 표시할 데이터입니다. 23 | - options은 차트의 옵션입니다. 24 | - 차트의 옵션은 차트종류에 맞게 기본옵션을 정의하고, 추가옵션을 받아서 merge를 통해 합쳐서 사용합니다. 25 | - Chart컴포넌트를 호출할때 type, series, options를 넘겨줍니다. 26 | -------------------------------------------------------------------------------- /frontend/src/component/README.md: -------------------------------------------------------------------------------- 1 | # 여기는 🧩component 디렉토리입니다 2 | 3 | 공통으로 사용되는 컴포넌트들을 모아놓은 디렉토리 입니다. 4 | 5 | 구현방법 6 | 7 | ```tsx 8 | 9 | type Props = { 10 | ... 11 | } 12 | 13 | export default function Components({props}:Props) { //기명함수 선언식으로 린팅되어있습니다. 14 | return (
...
) 15 | } 16 | ``` 17 | 18 | - 아토믹디자인에 따라 컴포넌트를 구분합니다 19 | 20 | - atom, molecule, organism, page 단위로 나뉩니다. 21 | - 반드시 이전 단위뿐만 아니라 하위 단위도 포함하여 만들어도 됩니다 (ex. atom + molcule = organism) 22 | - atom: 최소단위의 컴포넌트 23 | - molecule: atom을 조합한 컴포넌트 24 | - organism: molecule을 조합한 컴포넌트 25 | - page: template, organism을 조합한 컴포넌트 26 | 27 | - 컴포넌트는 함수형 컴포넌트로 작성합니다 28 | - 컴포넌트 이름은 카멜케이스로 작성합니다 29 | - 컴포넌트 이름으로 디렉토리를 짓습니다 30 | - 컴포넌트의 props는 type으로 정의합니다 31 | - 컴포넌트의 props는 구조분해할당으로 받습니다 32 | - 컴포넌트의 props는 필수값은 defaultProps로 정의합니다 33 | -------------------------------------------------------------------------------- /frontend/src/component/atom/Alert.tsx: -------------------------------------------------------------------------------- 1 | type Props = { 2 | cssOption?: string; 3 | content: string; 4 | isVisible?: boolean; 5 | }; 6 | 7 | export default function Alert({ cssOption = '', content = '', isVisible = false }: Props) { 8 | const defaultStyle = 9 | 'fixed left-1/4 top-1/2 -translate-x-1/2 -translate-y-1/2 text-center whitespace-nowrap'; 10 | 11 | return ( 12 | <> 13 |
16 | 17 | 20 | {content} 21 | 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /frontend/src/component/atom/Button.tsx: -------------------------------------------------------------------------------- 1 | type ButtonProps = { 2 | cssOption?: string; 3 | content: string; 4 | onClick?: () => void; 5 | disabled?: boolean; 6 | }; 7 | 8 | export default function Button({ cssOption, content = '', onClick, disabled }: ButtonProps) { 9 | return ( 10 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /frontend/src/component/atom/H1.tsx: -------------------------------------------------------------------------------- 1 | type Props = { 2 | cssOption?: string; 3 | content: string; 4 | }; 5 | 6 | export default function H1({ cssOption, content = '' }: Props) { 7 | return

{content}

; 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/component/atom/H2.tsx: -------------------------------------------------------------------------------- 1 | type Props = { 2 | cssOption?: string; 3 | content: string; 4 | }; 5 | 6 | export default function H2({ cssOption, content = '' }: Props) { 7 | return

{content}

; 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/component/atom/Img.tsx: -------------------------------------------------------------------------------- 1 | type Props = { 2 | cssOption?: string; 3 | src: string; 4 | alt?: string; 5 | }; 6 | 7 | export default function Img({ cssOption, src = '', alt }: Props) { 8 | return {alt}; 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/component/atom/Input.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef, ForwardedRef } from 'react'; 2 | 3 | type InputProps = { 4 | cssOption?: string; 5 | type: string; 6 | value: string; 7 | placeholder?: string; 8 | onChange?: (e: React.ChangeEvent) => void; 9 | }; 10 | 11 | export default forwardRef(function Input( 12 | { cssOption, type = '', value = '', placeholder, onChange }: InputProps, 13 | ref?: ForwardedRef 14 | ) { 15 | return ( 16 | 24 | ); 25 | }); 26 | -------------------------------------------------------------------------------- /frontend/src/component/atom/Loading.tsx: -------------------------------------------------------------------------------- 1 | export default function Loading() { 2 | return ( 3 |
4 |
5 |
6 |
7 |
8 | Loading... 9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /frontend/src/component/atom/P.tsx: -------------------------------------------------------------------------------- 1 | type Props = { 2 | cssOption?: string; 3 | content?: string; 4 | onClick?: () => void; 5 | }; 6 | 7 | export default function P({ cssOption, content, onClick }: Props) { 8 | return ( 9 |

10 | {content} 11 |

12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/component/atom/Select.tsx: -------------------------------------------------------------------------------- 1 | type Props = { 2 | cssOption?: string; 3 | options?: ReadonlyArray<{ readonly value: T; readonly label: string }>; 4 | value: string; 5 | onChange?: (value: T) => void; 6 | }; 7 | 8 | export default function Select({ 9 | cssOption, 10 | options = [], 11 | value, 12 | onChange = () => {} 13 | }: Props) { 14 | return ( 15 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /frontend/src/component/atom/Span.tsx: -------------------------------------------------------------------------------- 1 | type Props = { 2 | cssOption?: string; 3 | content?: string; 4 | style?: React.CSSProperties; 5 | }; 6 | 7 | export default function Span({ cssOption, content, style }: Props) { 8 | return ( 9 | 10 | {content} 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/component/atom/TextMotionDiv.tsx: -------------------------------------------------------------------------------- 1 | import { motion } from 'framer-motion'; 2 | 3 | type Props = { 4 | cssOption?: string; 5 | content: string; 6 | }; 7 | 8 | export default function TextMotionDiv({ cssOption, content = '' }: Props) { 9 | return ( 10 | 15 | {content} 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /frontend/src/component/atom/ValidIcon.tsx: -------------------------------------------------------------------------------- 1 | type Props = { 2 | type: 'success' | 'fail'; 3 | }; 4 | 5 | export default function Icon({ type = 'fail' }: Props) { 6 | return ( 7 | 8 | {type === 'success' ? '✓' : '✕'} 9 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /frontend/src/component/molecule/DarkModeButton.tsx: -------------------------------------------------------------------------------- 1 | import { Moon, Sun } from 'lucide-react'; 2 | 3 | type Props = { 4 | isDark: boolean; 5 | toggleDarkMode: () => void; 6 | }; 7 | 8 | export default function DarkModeButton({ isDark, toggleDarkMode }: Props) { 9 | return ( 10 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /frontend/src/component/molecule/InformationButton.tsx: -------------------------------------------------------------------------------- 1 | import { Info } from 'lucide-react'; 2 | import { useState } from 'react'; 3 | 4 | type Props = { 5 | text: string; 6 | }; 7 | 8 | export default function InformationButton({ text }: Props) { 9 | const [showTooltip, setShowTooltip] = useState(false); 10 | 11 | return ( 12 |
setShowTooltip(true)} 15 | onMouseLeave={() => setShowTooltip(false)}> 16 | 21 | 22 | {showTooltip && ( 23 |
24 |
25 |
26 |
27 | {text} 28 |
29 |
30 |
31 | )} 32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /frontend/src/component/molecule/NavbarContact.tsx: -------------------------------------------------------------------------------- 1 | import GithubImg from '@asset/image/Github.png'; 2 | import Img from '@component/atom/Img'; 3 | import P from '@component/atom/P'; 4 | import { PATH } from '@constant/Path'; 5 | import { Link } from 'react-router-dom'; 6 | 7 | export default function NavbarContact() { 8 | return ( 9 |
10 | 11 | 깃허브 이미지 12 | 13 |

17 |

18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /frontend/src/component/molecule/NavbarCustomSelect.tsx: -------------------------------------------------------------------------------- 1 | import NavbarSelect from '@component/molecule/NavbarSelect'; 2 | import useGroupNames from '@hook/api/useGroupNames'; 3 | import useNavbarStore from '@store/NavbarStore'; 4 | import { useEffect, useRef } from 'react'; 5 | import { useLocation } from 'react-router-dom'; 6 | 7 | export default function NavbarCustomSelect() { 8 | const { generation, setSelectedGroup } = useNavbarStore(); 9 | const { data = [] } = useGroupNames(generation); 10 | const location = useLocation(); 11 | const prevProjectGroupRef = useRef(null); 12 | const projectGroup = decodeURIComponent(location.pathname.split('/project/')[1]); 13 | 14 | useEffect(() => { 15 | if (data.length > 0 && projectGroup !== prevProjectGroupRef.current) { 16 | const groupToSelect = 17 | projectGroup && data.some((item) => item.value === projectGroup) 18 | ? projectGroup 19 | : data[0].value; 20 | 21 | setSelectedGroup(groupToSelect); 22 | prevProjectGroupRef.current = projectGroup; 23 | } 24 | }, [data, setSelectedGroup, projectGroup]); 25 | 26 | return ; 27 | } 28 | -------------------------------------------------------------------------------- /frontend/src/component/molecule/NavbarDefaultSelect.tsx: -------------------------------------------------------------------------------- 1 | import { BOOST_CAMP_OPTION } from '@constant/NavbarSelect'; 2 | 3 | import NavbarSelect from './NavbarSelect'; 4 | 5 | export default function NavbarDefaultSelect() { 6 | return ; 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/component/molecule/NavbarMenu.tsx: -------------------------------------------------------------------------------- 1 | import H1 from '@component/atom/H1'; 2 | import Img from '@component/atom/Img'; 3 | import P from '@component/atom/P'; 4 | import { MENU_ITEMS } from '@constant/NavbarMenu'; 5 | import { MenuItem } from '@type/Navbar'; 6 | import { Link, useLocation } from 'react-router-dom'; 7 | 8 | export default function NavbarMenu() { 9 | const { pathname } = useLocation(); 10 | 11 | function MenuItem({ item, isActive }: { item: MenuItem; isActive: boolean }) { 12 | return ( 13 | 14 |
20 | {`${item.label} 25 |

26 |

27 | 28 | ); 29 | } 30 | 31 | return ( 32 |
33 |

37 | {MENU_ITEMS.map((item) => ( 38 | 43 | ))} 44 |

45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /frontend/src/component/molecule/NavbarSelect.tsx: -------------------------------------------------------------------------------- 1 | import Select from '@component/atom/Select'; 2 | import { GENERATION_OPTION } from '@constant/NavbarSelect'; 3 | import { PATH } from '@constant/Path'; 4 | import useNavbarStore from '@store/NavbarStore'; 5 | import { useLocation, useNavigate } from 'react-router-dom'; 6 | 7 | type Props = { 8 | groupOption: Array<{ label: string; value: string }>; 9 | }; 10 | 11 | export default function NavbarSelect({ groupOption }: Props) { 12 | const { generation, selectedGroup, setGeneration, setSelectedGroup } = useNavbarStore(); 13 | const { pathname } = useLocation(); 14 | const isProjectPath = pathname.includes(PATH.PROJECT); 15 | const navigate = useNavigate(); 16 | const handleSelect = (value: string) => { 17 | setSelectedGroup(value); 18 | navigate(PATH.PROJECT + '/' + value); 19 | }; 20 | 21 | return ( 22 |
23 | {isProjectPath && ( 24 | 37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /frontend/src/component/molecule/NavbarTitle.tsx: -------------------------------------------------------------------------------- 1 | import FaviconImg from '@asset/image/Favicon.png'; 2 | import FaviconLightImg from '@asset/image/FaviconLight.png'; 3 | import H1 from '@component/atom/H1'; 4 | import Img from '@component/atom/Img'; 5 | import DarkModeButton from '@component/molecule/DarkModeButton'; 6 | import useDarkMode from '@hook/useDarkMode'; 7 | import { useNavigate } from 'react-router-dom'; 8 | 9 | export default function NavbarTitle() { 10 | const navigate = useNavigate(); 11 | const [isDark, toggleDarkMode] = useDarkMode(); 12 | 13 | const navigateMain = () => { 14 | navigate('/'); 15 | }; 16 | 17 | return ( 18 |
19 |
20 | 다크모드 버튼 이미지 25 |

29 | 30 |

31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /frontend/src/component/molecule/NavigateButton.tsx: -------------------------------------------------------------------------------- 1 | import Button from '@component/atom/Button'; 2 | import { useNavigate } from 'react-router-dom'; 3 | 4 | type Props = { 5 | path: string; 6 | content: string; 7 | cssOption?: string; 8 | }; 9 | 10 | export default function NavigateButton({ path = '', content = '', cssOption }: Props) { 11 | const navigate = useNavigate(); 12 | const handleNavigate = () => { 13 | navigate(path); 14 | }; 15 | return ( 16 |
17 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /frontend/src/component/molecule/ProjectElapsedTimeLegend.tsx: -------------------------------------------------------------------------------- 1 | import H2 from '@component/atom/H2'; 2 | import Span from '@component/atom/Span'; 3 | import { RESPONSE_TIME_LEGENDS } from '@constant/Chart'; 4 | 5 | type Props = { 6 | averageTime: number; 7 | }; 8 | 9 | export default function ProjectElapsedTimeLegend({ averageTime }: Props) { 10 | const getColorByTime = (time: number) => { 11 | if (time <= 200) return '#4ADE80'; 12 | if (time <= 1000) return '#FFA500'; 13 | return '#FF4444'; 14 | }; 15 | 16 | return ( 17 |
18 |
19 |

23 |
24 | {averageTime || 0}ms 25 |
26 |

27 |
28 | {RESPONSE_TIME_LEGENDS.map((item, index) => ( 29 |
30 | 31 | 35 |
36 | ))} 37 |
38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /frontend/src/component/molecule/RankingTab.tsx: -------------------------------------------------------------------------------- 1 | import InformationButton from '@component/molecule/InformationButton'; 2 | import { RANK_OPTIONS } from '@constant/Rank'; 3 | import { RankType } from '@type/Rank'; 4 | 5 | type Props = { 6 | rankName: string; 7 | setRankType: (type: RankType) => void; 8 | }; 9 | 10 | export default function RankingTab({ rankName, setRankType }: Props) { 11 | return ( 12 |
13 |
14 | {RANK_OPTIONS.map((option) => ( 15 | 21 | ))} 22 |
23 |
24 | 25 |
26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /frontend/src/component/molecule/RegisterText.tsx: -------------------------------------------------------------------------------- 1 | import CloudFlareDescriptionImg from '@asset/image/CloudFlareDescription.png'; 2 | import GabiaDescriptionImg from '@asset/image/GabiaDescription.png'; 3 | import H1 from '@component/atom/H1'; 4 | import Img from '@component/atom/Img'; 5 | import P from '@component/atom/P'; 6 | 7 | export default function RegisterText() { 8 | return ( 9 |
10 |

11 |

12 |

13 |

14 |

15 |

16 | 가비아 dns 등록 설명 이미지 17 |

18 | 클라우드플레어 dns 등록 설명 이미지 23 |

24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /frontend/src/component/molecule/ValidateTextInput.tsx: -------------------------------------------------------------------------------- 1 | import Input from '@component/atom/Input'; 2 | import ValidIcon from '@component/atom/ValidIcon'; 3 | import { forwardRef, ForwardedRef } from 'react'; 4 | 5 | type Props = { 6 | type: string; 7 | value: string; 8 | isValid: boolean; 9 | placeholder?: string; 10 | onChange?: (e: React.ChangeEvent) => void; 11 | }; 12 | 13 | export default forwardRef(function ValidateTextInput( 14 | { type = '', value = '', isValid = false, placeholder, onChange }: Props, 15 | ref?: ForwardedRef 16 | ) { 17 | const validationStyle = isValid ? 'border-green' : 'border-red'; 18 | 19 | return ( 20 |
21 | 29 |
30 | 31 |
32 |
33 | ); 34 | }); 35 | -------------------------------------------------------------------------------- /frontend/src/component/organism/NavbarSelectWrapper.tsx: -------------------------------------------------------------------------------- 1 | import CustomErrorBoundary from '@boundary/CustomErrorBoundary'; 2 | import NavbarCustomSelect from '@component/molecule/NavbarCustomSelect'; 3 | import NavbarDefaultSelect from '@component/molecule/NavbarDefaultSelect'; 4 | import { PATH } from '@constant/Path'; 5 | import { useRef } from 'react'; 6 | import { useLocation } from 'react-router-dom'; 7 | 8 | export default function NavbarSelectWrapper() { 9 | const { pathname } = useLocation(); 10 | const isProjectPath = pathname.includes(PATH.PROJECT); 11 | const containerRef = useRef(null); 12 | return ( 13 |
14 |
15 | {!isProjectPath ? ( 16 | 17 | ) : ( 18 | 19 | 20 | 21 | )} 22 |
23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /frontend/src/component/organism/RankingList.tsx: -------------------------------------------------------------------------------- 1 | import RankingItem from '@component/molecule/RankingItem'; 2 | import RankingTab from '@component/molecule/RankingTab'; 3 | import DataLayout from '@component/template/DataLayout'; 4 | import useRankData from '@hook/api/useRankData'; 5 | import useNavbarStore from '@store/NavbarStore'; 6 | import { RankType } from '@type/Rank'; 7 | import { useState } from 'react'; 8 | 9 | export default function RankingList() { 10 | const [rankType, setRankType] = useState({ name: 'traffic', unit: '개' }); 11 | const { generation } = useNavbarStore(); 12 | const { data } = useRankData(rankType.name, generation); 13 | 14 | return ( 15 | 16 |
17 |
18 | 19 |
20 |
21 | 22 |
23 |
24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /frontend/src/component/organism/RegisterDescription.tsx: -------------------------------------------------------------------------------- 1 | import NavigateButton from '@component/molecule/NavigateButton'; 2 | import RegisterText from '@component/molecule/RegisterText'; 3 | 4 | export default function RegisterDescription() { 5 | return ( 6 |
7 |
8 | 9 |
10 | 15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /frontend/src/component/page/MainPage.tsx: -------------------------------------------------------------------------------- 1 | import CustomErrorBoundary from '@boundary/CustomErrorBoundary'; 2 | import MainData from '@component/organism/MainData'; 3 | import MainResponse from '@component/organism/MainResponse'; 4 | import MainTrafficChart from '@component/organism/MainTrafficChart'; 5 | import useNavbarStore from '@store/NavbarStore'; 6 | 7 | export default function MainPage() { 8 | const generation = useNavbarStore((state) => state.generation); 9 | 10 | return ( 11 |
12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 |
22 | 23 | 24 | 25 |
26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /frontend/src/component/page/ProjectDetailPage.tsx: -------------------------------------------------------------------------------- 1 | import CustomErrorBoundary from '@boundary/CustomErrorBoundary'; 2 | import ProjectDAU from '@component/organism/ProjectDAU'; 3 | import ProjectElapsedTime from '@component/organism/ProjectElapsedTime'; 4 | import ProjectSuccessRate from '@component/organism/ProjectSuccessRate'; 5 | import ProjectTrafficChart from '@component/organism/ProjectTrafficChart'; 6 | import useIsExistGroup from '@hook/api/useIsExistGroup'; 7 | import { Navigate, useParams } from 'react-router-dom'; 8 | 9 | export default function ProjectDetailPage() { 10 | const { id } = useParams(); 11 | 12 | if (id === undefined) { 13 | return ; 14 | } 15 | 16 | const { data, isLoading } = useIsExistGroup(id); 17 | if (isLoading) { 18 | return; 19 | } 20 | if (data?.exists === false) { 21 | return ; 22 | } 23 | 24 | return ( 25 |
26 |
27 |
28 | 29 | 30 | 31 |
32 |
33 | 34 | 35 | 36 |
37 |
38 | 39 | 40 | 41 |
42 |
43 | 44 |
45 | 46 | 47 | 48 |
49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /frontend/src/component/page/ProjectPage.tsx: -------------------------------------------------------------------------------- 1 | import Loading from '@component/atom/Loading'; 2 | import useGroupNames from '@hook/api/useGroupNames'; 3 | import useNavbarStore from '@store/NavbarStore'; 4 | import { Navigate } from 'react-router-dom'; 5 | 6 | export default function ProjectPage() { 7 | const { generation } = useNavbarStore(); 8 | 9 | const { data, isLoading } = useGroupNames(generation); 10 | if (isLoading) { 11 | return ; 12 | } 13 | 14 | if (!data?.length) { 15 | return ; 16 | } 17 | 18 | return ; 19 | } 20 | -------------------------------------------------------------------------------- /frontend/src/component/page/RankingPage.tsx: -------------------------------------------------------------------------------- 1 | import CustomErrorBoundary from '@boundary/CustomErrorBoundary'; 2 | import RankingList from '@component/organism/RankingList'; 3 | 4 | export default function RankingPage() { 5 | return ( 6 |
7 | 8 | 9 | 10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /frontend/src/component/page/RegisterPage.tsx: -------------------------------------------------------------------------------- 1 | import Alert from '@component/atom/Alert'; 2 | import RegisterDescription from '@component/organism/RegisterDescription'; 3 | import RegisterForm from '@component/organism/RegisterForm'; 4 | import useAlert from '@hook/useAlert'; 5 | 6 | export default function RegisterPage() { 7 | const { isVisible, message, showAlert } = useAlert({ time: 2000 }); 8 | 9 | return ( 10 | <> 11 |
12 | 13 | 14 |
15 | 16 | {isVisible && ( 17 | 22 | )} 23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /frontend/src/component/template/DataLayout.tsx: -------------------------------------------------------------------------------- 1 | type Props = { 2 | children: React.ReactNode; 3 | cssOption?: string; 4 | }; 5 | 6 | export default function DataLayout({ children, cssOption }: Props) { 7 | return ( 8 |
10 | {children} 11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/component/template/MobileLayout.tsx: -------------------------------------------------------------------------------- 1 | export default function MobileLayout() { 2 | return ( 3 |
4 |
5 |

데스크톱으로 접속해주세요!

6 |

7 | 이 서비스는 768px 이상의 화면에서 최적의 경험을 제공합니다. 8 |
9 | 데스크톱 환경에서 접속해주세요. 10 |

11 |
12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /frontend/src/constant/Chart.ts: -------------------------------------------------------------------------------- 1 | const CHART_COLORS = ['#FF6B6B', '#FF9F43', '#FECA57', '#4ECB71', '#4B7BEC']; 2 | const SUCCESS_FAIL_COLORS = ['#4ADE80', '#FF4444']; 3 | const POLAR_AREA_COLORS = ['#FF6B6B', '#FF9F43', '#FECA57', '#4ECB71', '#4B7BEC', '#4ADE80']; 4 | 5 | const RESPONSE_TIME_LEGENDS = [ 6 | { color: '#4ADE80', range: '0 ~ 200', description: 'great' }, 7 | { color: '#FFA500', range: '201 ~ 1000', description: 'good' }, 8 | { color: '#FF4444', range: '1001 ~', description: 'bad' } 9 | ]; 10 | 11 | export { CHART_COLORS, RESPONSE_TIME_LEGENDS, SUCCESS_FAIL_COLORS, POLAR_AREA_COLORS }; 12 | -------------------------------------------------------------------------------- /frontend/src/constant/Date.ts: -------------------------------------------------------------------------------- 1 | import { DateType } from '@type/Date'; 2 | 3 | const DAY_TO_MS_SECOND = 24 * 60 * 60 * 1000; 4 | 5 | const DATE_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = { 6 | year: 'numeric', 7 | month: '2-digit', 8 | day: '2-digit', 9 | hour: '2-digit', 10 | minute: '2-digit', 11 | hour12: false 12 | }; 13 | 14 | const TIME_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = { 15 | hour: '2-digit', 16 | minute: '2-digit', 17 | hour12: false 18 | }; 19 | 20 | const DATE_OPTIONS: { value: DateType; label: string }[] = [ 21 | { value: 'day', label: 'Day' }, 22 | { value: 'week', label: 'Week' }, 23 | { value: 'month', label: 'Month' } 24 | ]; 25 | 26 | export { DAY_TO_MS_SECOND, DATE_FORMAT_OPTIONS, TIME_FORMAT_OPTIONS, DATE_OPTIONS }; 27 | -------------------------------------------------------------------------------- /frontend/src/constant/Medals.ts: -------------------------------------------------------------------------------- 1 | import FirstMedal from '@asset/image/FirstMedal.png'; 2 | import SecondMedal from '@asset/image/SecondMedal.png'; 3 | import ThirdMedal from '@asset/image/ThirdMedal.png'; 4 | 5 | const MEDALS = { 6 | 0: { 7 | image: FirstMedal, 8 | color: 'text-amber-400' 9 | }, 10 | 1: { 11 | image: SecondMedal, 12 | color: 'text-zinc-500' 13 | }, 14 | 2: { 15 | image: ThirdMedal, 16 | color: 'text-amber-700' 17 | } 18 | } as const; 19 | 20 | export { MEDALS }; 21 | -------------------------------------------------------------------------------- /frontend/src/constant/NavbarMenu.ts: -------------------------------------------------------------------------------- 1 | import ProjectActiveImg from '@asset/image/ProjectActive.png'; 2 | import ProjectInactiveImg from '@asset/image/ProjectInactive.png'; 3 | import ProjectsActiveImg from '@asset/image/ProjectsActive.svg'; 4 | import ProjectsInactiveImg from '@asset/image/ProjectsInactive.svg'; 5 | import RankingActiveImg from '@asset/image/RankingActive.png'; 6 | import RankingInactiveImg from '@asset/image/RankingInactive.png'; 7 | import { MenuItem } from '@type/Navbar'; 8 | 9 | const MENU_ITEMS: MenuItem[] = [ 10 | { 11 | path: '/', 12 | label: '전체 프로젝트 보기', 13 | activeIcon: ProjectsActiveImg, 14 | inactiveIcon: ProjectsInactiveImg 15 | }, 16 | { 17 | path: '/project', 18 | label: '개별 프로젝트 보기', 19 | activeIcon: ProjectActiveImg, 20 | inactiveIcon: ProjectInactiveImg 21 | }, 22 | { 23 | path: '/ranking', 24 | label: '프로젝트 랭킹 보기', 25 | activeIcon: RankingActiveImg, 26 | inactiveIcon: RankingInactiveImg 27 | } 28 | ] as const; 29 | 30 | export { MENU_ITEMS }; 31 | -------------------------------------------------------------------------------- /frontend/src/constant/NavbarSelect.ts: -------------------------------------------------------------------------------- 1 | import { GroupOption } from '@type/Navbar'; 2 | 3 | const GENERATION_VALUE = { 4 | NINTH: '9', 5 | TENTH: '10' 6 | } as const; 7 | 8 | const BOOST_CAMP_VALUE = { 9 | NINTH: '부스트캠프 9기', 10 | Text: '부스트캠프 10기' 11 | } as const; 12 | 13 | const GENERATION_OPTION = [{ value: GENERATION_VALUE.NINTH, label: '9기' }] as const; 14 | 15 | const BOOST_CAMP_OPTION: GroupOption[] = [{ value: '9', label: BOOST_CAMP_VALUE.NINTH }]; 16 | 17 | export { GENERATION_OPTION, BOOST_CAMP_OPTION, GENERATION_VALUE, BOOST_CAMP_VALUE }; 18 | -------------------------------------------------------------------------------- /frontend/src/constant/Path.ts: -------------------------------------------------------------------------------- 1 | const PATH = { 2 | MAIN: '/main', 3 | PROJECT: '/project', 4 | REGISTER: '/register', 5 | RANKING: '/ranking', 6 | GITHUB: 'https://github.com/boostcampwm-2024/web35-watchducks' 7 | } as const; 8 | 9 | export { PATH }; 10 | -------------------------------------------------------------------------------- /frontend/src/constant/README.md: -------------------------------------------------------------------------------- 1 | # 여기는 📚constant 디렉토리입니다 2 | 3 | 공통으로 사용되는 상수들을 모아놓은 디렉토리 입니다. 4 | 5 | 구현방법 6 | 7 | ```ts 8 | export const PATH = { 9 | MAIN: '/main', 10 | }; 11 | ``` 12 | 13 | - 대문자로 이루어진 스네이크케이스로 변수명을 짓습니다. 14 | -------------------------------------------------------------------------------- /frontend/src/constant/Rank.ts: -------------------------------------------------------------------------------- 1 | import { RankType } from '@type/Rank'; 2 | 3 | const RANK_OPTIONS: { value: RankType; label: string }[] = [ 4 | { value: { name: 'traffic', unit: '개' }, label: '트래픽양' }, 5 | { value: { name: 'success-rate', unit: '%' }, label: '요청 성공률' }, 6 | { value: { name: 'elapsed-time', unit: 'ms' }, label: '평균 응답시간' }, 7 | { value: { name: 'dau', unit: '명' }, label: 'DAU' } 8 | ]; 9 | 10 | export { RANK_OPTIONS }; 11 | -------------------------------------------------------------------------------- /frontend/src/hook/README.md: -------------------------------------------------------------------------------- 1 | # 여기는 ⚒️hook 디렉토리입니다 2 | 3 | 리액트 커스텀 훅들을 모아놓은 디렉토리 입니다. 4 | 5 | 구현방법 6 | 7 | ```ts 8 | export default function useFetch(fetchFunction: () => Promise): FetchState {} 9 | ``` 10 | 11 | - export하는 function이름으로 파일이름을 짓습니다. 12 | - 리액트 커스텀훅 규칙에 따라 use + 카멜케이스를 사용합니다. 13 | -------------------------------------------------------------------------------- /frontend/src/hook/api/useGroupNames.ts: -------------------------------------------------------------------------------- 1 | import { getGroupNames } from '@api/get/ProjectPage'; 2 | import { useQuery } from '@tanstack/react-query'; 3 | 4 | export default function useGroupNames(generation: string) { 5 | return useQuery({ 6 | queryKey: ['groupNames', generation], 7 | queryFn: () => getGroupNames(generation) 8 | }); 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/hook/api/useIsExistGroup.ts: -------------------------------------------------------------------------------- 1 | import { getIsExistProject } from '@api/get/ProjectPage'; 2 | import { useQuery } from '@tanstack/react-query'; 3 | 4 | export default function useIsExistGroup(project: string) { 5 | return useQuery({ 6 | queryKey: ['isExistProject', project], 7 | queryFn: () => getIsExistProject(project) 8 | }); 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/hook/api/useProjectDAU.ts: -------------------------------------------------------------------------------- 1 | import { getDAU } from '@api/get/ProjectPage'; 2 | import { useSuspenseQuery } from '@tanstack/react-query'; 3 | 4 | export default function useProjectDAU(project: string) { 5 | return useSuspenseQuery({ 6 | queryKey: ['projectDAU', project], 7 | queryFn: () => getDAU(project) 8 | }); 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/hook/api/useProjectElapsedTime.ts: -------------------------------------------------------------------------------- 1 | import { getElapsedTime } from '@api/get/ProjectPage'; 2 | import { useSuspenseQuery } from '@tanstack/react-query'; 3 | 4 | export default function useProjectElapsedTime(project: string) { 5 | return useSuspenseQuery({ 6 | queryKey: ['projectElapsedTime', project], 7 | queryFn: () => getElapsedTime(project) 8 | }); 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/hook/api/useProjectSuccessRate.ts: -------------------------------------------------------------------------------- 1 | import { getSuccessRate } from '@api/get/ProjectPage'; 2 | import { useSuspenseQuery } from '@tanstack/react-query'; 3 | 4 | export default function useProjectSuccessRate(project: string) { 5 | return useSuspenseQuery({ 6 | queryKey: ['projectSuccessRate', project], 7 | queryFn: () => getSuccessRate(project) 8 | }); 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/hook/api/useProjectTraffic.ts: -------------------------------------------------------------------------------- 1 | import { getTraffic } from '@api/get/ProjectPage'; 2 | import { useSuspenseQuery } from '@tanstack/react-query'; 3 | 4 | export default function useProjectTraffic(project: string, dateType: string) { 5 | return useSuspenseQuery({ 6 | queryKey: ['projectTraffic', project, dateType], 7 | queryFn: () => getTraffic(project, dateType) 8 | }); 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/hook/api/useRankData.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getRankingTraffic, 3 | getRankingSuccessRate, 4 | getRankingElapsedTime, 5 | getRankingDAU 6 | } from '@api/get/RankingPage'; 7 | import { useSuspenseQuery } from '@tanstack/react-query'; 8 | 9 | export default function useRankData(rankType: string, generation: string) { 10 | const now = new Date(); 11 | const tomorrowMidnight = new Date(); 12 | tomorrowMidnight.setHours(24, 0, 0, 0); 13 | const timeUntilMidnight = tomorrowMidnight.getTime() - now.getTime(); 14 | 15 | const queryOptions = { 16 | staleTime: tomorrowMidnight.getTime(), 17 | gcTime: timeUntilMidnight 18 | }; 19 | 20 | switch (rankType) { 21 | case 'traffic': 22 | return useSuspenseQuery({ 23 | queryKey: ['rankingTraffic', rankType, generation], 24 | queryFn: () => getRankingTraffic(generation), 25 | ...queryOptions 26 | }); 27 | case 'success-rate': 28 | return useSuspenseQuery({ 29 | queryKey: ['rankingSuccessRate', rankType, generation], 30 | queryFn: () => getRankingSuccessRate(generation), 31 | ...queryOptions 32 | }); 33 | case 'elapsed-time': 34 | return useSuspenseQuery({ 35 | queryKey: ['rankingElapsedTime', rankType, generation], 36 | queryFn: () => getRankingElapsedTime(generation), 37 | ...queryOptions 38 | }); 39 | case 'dau': 40 | return useSuspenseQuery({ 41 | queryKey: ['rankingDAU', rankType, generation], 42 | queryFn: () => getRankingDAU(generation), 43 | ...queryOptions 44 | }); 45 | default: 46 | throw new Error('Invalid rank'); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /frontend/src/hook/api/useRankings.ts: -------------------------------------------------------------------------------- 1 | import { getRankings } from '@api/get/MainPage'; 2 | import { useSuspenseQuery } from '@tanstack/react-query'; 3 | 4 | export default function useRankings(generation: string) { 5 | return useSuspenseQuery({ 6 | queryKey: ['ranking', generation], 7 | queryFn: () => getRankings(generation) 8 | }); 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/hook/api/useTop5ResponseTime.ts: -------------------------------------------------------------------------------- 1 | import { getTop5ResponseTime } from '@api/get/MainPage'; 2 | import { useSuspenseQuery } from '@tanstack/react-query'; 3 | 4 | export default function useTop5ResponseTime(generation: string) { 5 | return useSuspenseQuery({ 6 | queryKey: ['Top5ResponseTime', generation], 7 | queryFn: () => getTop5ResponseTime(generation) 8 | }); 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/hook/api/useTop5Traffic.ts: -------------------------------------------------------------------------------- 1 | import { getTop5Traffic } from '@api/get/MainPage'; 2 | import { useSuspenseQuery } from '@tanstack/react-query'; 3 | 4 | export default function useTop5Traffic(generation: string) { 5 | return useSuspenseQuery({ 6 | queryKey: ['Top5Traffic', generation], 7 | queryFn: () => getTop5Traffic(generation) 8 | }); 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/hook/api/useTotalDatas.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getTotalTrafficCount, 3 | getTotalProjectCount, 4 | getTotalResponseRate, 5 | getDailyDifferenceTraffic, 6 | getTotalElapsedTime 7 | } from '@api/get/MainPage'; 8 | import { useSuspenseQueries } from '@tanstack/react-query'; 9 | 10 | export default function useTotalDatas(generation: string) { 11 | const [trafficResult, projectResult, responseResult, dailyDifferenceResult, elapsedTimeResult] = 12 | useSuspenseQueries({ 13 | queries: [ 14 | { 15 | queryKey: ['totalTraffic', generation], 16 | queryFn: () => getTotalTrafficCount(generation) 17 | }, 18 | { 19 | queryKey: ['totalProjectCount', generation], 20 | queryFn: () => getTotalProjectCount(generation) 21 | }, 22 | { 23 | queryKey: ['totalResponseRate', generation], 24 | queryFn: () => getTotalResponseRate(generation) 25 | }, 26 | { 27 | queryKey: ['dailyDifferenceTraffic', generation], 28 | queryFn: () => getDailyDifferenceTraffic(generation) 29 | }, 30 | { 31 | queryKey: ['elapsedTime', generation], 32 | queryFn: () => getTotalElapsedTime(generation) 33 | } 34 | ] 35 | }); 36 | 37 | return { 38 | totalTraffic: trafficResult.data.count, 39 | totalProjectCount: projectResult.data.count, 40 | totalResponseRate: responseResult.data.success_rate, 41 | dailyDifferenceTraffic: dailyDifferenceResult.data.traffic_daily_difference, 42 | elapsedTime: elapsedTimeResult.data.avg_elapsed_time 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /frontend/src/hook/useAlert.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | type Props = { 4 | time: number; 5 | }; 6 | 7 | export default function useAlert({ time }: Props) { 8 | const [isVisible, setIsVisible] = useState(false); 9 | const [message, setMessage] = useState(''); 10 | 11 | const showAlert = (alertMessage: string) => { 12 | setMessage(alertMessage); 13 | setIsVisible(true); 14 | }; 15 | 16 | useEffect(() => { 17 | if (isVisible) { 18 | const timer = setTimeout(() => { 19 | setIsVisible(false); 20 | setMessage(''); 21 | }, time); 22 | 23 | return () => clearTimeout(timer); 24 | } 25 | }, [isVisible]); 26 | 27 | return { isVisible, message, showAlert }; 28 | } 29 | -------------------------------------------------------------------------------- /frontend/src/hook/useCustomMutation.ts: -------------------------------------------------------------------------------- 1 | import { getErrorByCode } from '@boundary/toastError'; 2 | import { UseMutationOptions, useMutation } from '@tanstack/react-query'; 3 | import axios from 'axios'; 4 | import { toast } from 'react-toastify'; 5 | 6 | type ErrorResponse = { 7 | code: number; 8 | message: string; 9 | }; 10 | 11 | export default function useCustomMutation( 12 | options: UseMutationOptions 13 | ) { 14 | return useMutation({ 15 | ...options, 16 | onError: (error, variables, context) => { 17 | options.onError?.(error, variables, context); 18 | 19 | if (axios.isAxiosError(error)) { 20 | const errorData = getErrorByCode(error); 21 | toast.error(`[${errorData.code}] ${errorData.message}`); 22 | } else { 23 | toast.error('알 수 없는 오류가 발생했습니다.'); 24 | } 25 | } 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /frontend/src/hook/useDarkMode.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | function useDarkMode(): [boolean, () => void] { 4 | const localStorageChecker = (): boolean => { 5 | if (!localStorage.theme) return false; 6 | return localStorage.theme === 'dark' ? true : false; 7 | }; 8 | 9 | const [dark, setDark] = useState(localStorageChecker()); 10 | 11 | const toggleDarkMode = () => { 12 | setDark((state) => { 13 | const update = !state; 14 | if (update) { 15 | localStorage.theme = 'dark'; 16 | } else { 17 | localStorage.theme = 'light'; 18 | } 19 | return update; 20 | }); 21 | }; 22 | 23 | useEffect(() => { 24 | if ( 25 | localStorage.theme === 'dark' || 26 | (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches) 27 | ) { 28 | document.documentElement.classList.add('dark'); 29 | } else { 30 | document.documentElement.classList.remove('dark'); 31 | } 32 | }, [dark]); 33 | 34 | return [dark, toggleDarkMode]; 35 | } 36 | 37 | export default useDarkMode; 38 | -------------------------------------------------------------------------------- /frontend/src/hook/useDefaultOption.ts: -------------------------------------------------------------------------------- 1 | export const useDefaultOptions = () => { 2 | return { 3 | defaultOptions 4 | }; 5 | }; 6 | 7 | const defaultOptions: ApexCharts.ApexOptions = { 8 | chart: { 9 | animations: { 10 | enabled: false 11 | }, 12 | toolbar: { 13 | show: false 14 | } 15 | }, 16 | dataLabels: { 17 | enabled: false 18 | }, 19 | tooltip: { 20 | enabled: true, 21 | shared: true, 22 | followCursor: true, 23 | intersect: false, 24 | fixed: { 25 | enabled: false 26 | } 27 | }, 28 | states: { 29 | hover: { 30 | filter: { 31 | type: 'none' 32 | } 33 | }, 34 | active: { 35 | allowMultipleDataPointsSelection: false, 36 | filter: { 37 | type: 'none' 38 | } 39 | } 40 | } 41 | }; 42 | -------------------------------------------------------------------------------- /frontend/src/hook/useIsMobile.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | export default function useIsMobile(breakpoint: number = 768) { 4 | const [isMobile, setIsMobile] = useState(false); 5 | 6 | useEffect(() => { 7 | const mediaQuery = `(max-width: ${breakpoint}px)`; 8 | 9 | const mediaQueryList = window.matchMedia(mediaQuery); 10 | setIsMobile(mediaQueryList.matches); 11 | 12 | const handleChange = (event: MediaQueryListEvent) => { 13 | setIsMobile(event.matches); 14 | }; 15 | 16 | mediaQueryList.addEventListener('change', handleChange); 17 | 18 | return () => { 19 | mediaQueryList.removeEventListener('change', handleChange); 20 | }; 21 | }, [breakpoint]); 22 | 23 | return isMobile; 24 | } 25 | -------------------------------------------------------------------------------- /frontend/src/hook/useNavContext.ts: -------------------------------------------------------------------------------- 1 | import { useOutletContext } from 'react-router-dom'; 2 | 3 | export function useNavContext() { 4 | return useOutletContext(); 5 | } 6 | -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | html, 6 | body { 7 | width: 100%; 8 | height: 100%; 9 | } 10 | 11 | body { 12 | @apply bg-[#fcfcfc] dark:bg-[#00142B]; 13 | } 14 | 15 | .apexcharts-menu-item { 16 | color: black; 17 | } 18 | 19 | .apexcharts-xaxistooltip { 20 | display: none !important; 21 | } 22 | 23 | .apexcharts-yaxistooltip { 24 | display: none !important; 25 | } 26 | -------------------------------------------------------------------------------- /frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | import CustomQueryProvider from '@boundary/CustomQueryProvider.tsx'; 2 | import { StrictMode } from 'react'; 3 | import { createRoot } from 'react-dom/client'; 4 | import { ToastContainer } from 'react-toastify'; 5 | 6 | import App from './App.tsx'; 7 | import 'react-toastify/ReactToastify.css'; 8 | import './index.css'; 9 | 10 | createRoot(document.getElementById('root')!).render( 11 | 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | -------------------------------------------------------------------------------- /frontend/src/router/README.md: -------------------------------------------------------------------------------- 1 | # 여기는 🔃router 디렉토리입니다 2 | 3 | 라우팅 관련 컴포넌트를 모아놓은 디렉토리 입니다. 4 | 5 | 구현방법 6 | 7 | ```ts 8 | export default createBrowserRouter([ 9 | { 10 | path: '/', 11 | element: 12 | }, 13 | ]); 14 | ``` 15 | 16 | - path는 라우팅 경로입니다. 17 | - element는 해당 경로에 렌더링할 컴포넌트입니다. 18 | -------------------------------------------------------------------------------- /frontend/src/router/Router.tsx: -------------------------------------------------------------------------------- 1 | import MainBoundary from '@boundary/MainBoundary'; 2 | import ErrorPage from '@component/page/ErrorPage'; 3 | import MainPage from '@component/page/MainPage'; 4 | import ProjectDetailPage from '@component/page/ProjectDetailPage'; 5 | import ProjectPage from '@component/page/ProjectPage'; 6 | import RankingPage from '@component/page/RankingPage'; 7 | import RegisterPage from '@component/page/RegisterPage'; 8 | import { createBrowserRouter } from 'react-router-dom'; 9 | 10 | export default createBrowserRouter([ 11 | { 12 | element: , 13 | children: [ 14 | { 15 | path: '/', 16 | element: 17 | }, 18 | { 19 | path: '/project', 20 | element: 21 | }, 22 | { 23 | path: '/project/:id', 24 | element: 25 | }, 26 | { 27 | path: '/ranking', 28 | element: 29 | } 30 | ] 31 | }, 32 | { 33 | path: '/register', 34 | element: 35 | }, 36 | { 37 | path: '*', 38 | element: 39 | } 40 | ]); 41 | -------------------------------------------------------------------------------- /frontend/src/store/NavbarStore.ts: -------------------------------------------------------------------------------- 1 | import { BOOST_CAMP_OPTION, GENERATION_VALUE } from '@constant/NavbarSelect'; 2 | import { NavbarState } from '@type/Navbar'; 3 | import { create } from 'zustand'; 4 | 5 | const useNavbarStore = create((set) => ({ 6 | generation: GENERATION_VALUE.NINTH, 7 | selectedGroup: BOOST_CAMP_OPTION[0].value, 8 | isNavbarOpen: true, 9 | setGeneration: (generation) => set({ generation }), 10 | setSelectedGroup: (group) => set({ selectedGroup: group }), 11 | toggleNavbar: (isOpen) => set((state) => ({ isNavbarOpen: isOpen ?? !state.isNavbarOpen })) 12 | })); 13 | 14 | export default useNavbarStore; 15 | -------------------------------------------------------------------------------- /frontend/src/store/README.md: -------------------------------------------------------------------------------- 1 | # 여기는 🧳store 디렉토리입니다 2 | 3 | 스토어관련 코드를 모아놓은 디렉토리입니다. 4 | Zustand라이브러리를 사용해 스토어를 구현합니다. 5 | 6 | 구현방법 7 | 8 | ```ts 9 | const useNavbarStore = create((set) => ({ 10 | generation: GENERATION_VALUE.NINTH, 11 | setGeneration: (generation) => set({ generation }) 12 | })); 13 | ``` 14 | 15 | - 커스텀훅 규칙처럼 이름을 짓고, 마지막에 Store를 붙여줍니다. 16 | -------------------------------------------------------------------------------- /frontend/src/type/Date.ts: -------------------------------------------------------------------------------- 1 | type DateType = 'day' | 'week' | 'month'; 2 | 3 | export type { DateType }; 4 | -------------------------------------------------------------------------------- /frontend/src/type/Navbar.ts: -------------------------------------------------------------------------------- 1 | type Generation = 'all' | '9'; 2 | type GroupOption = { 3 | readonly value: string; 4 | readonly label: string; 5 | }; 6 | type MenuItem = { 7 | path: string; 8 | label: string; 9 | activeIcon: string; 10 | inactiveIcon: string; 11 | }; 12 | 13 | type NavbarState = { 14 | generation: string; 15 | selectedGroup: string; 16 | isNavbarOpen: boolean; 17 | setGeneration: (generation: string) => void; 18 | setSelectedGroup: (group: string) => void; 19 | toggleNavbar: (isOpen?: boolean) => void; 20 | }; 21 | 22 | type Dimensions = { 23 | width: number; 24 | height: number; 25 | }; 26 | 27 | export type { Generation, GroupOption, MenuItem, NavbarState, Dimensions }; 28 | -------------------------------------------------------------------------------- /frontend/src/type/README.md: -------------------------------------------------------------------------------- 1 | # 여기는 📕type 디렉토리입니다 2 | 3 | 타입들을 모아놓은 디렉토리 입니다. 4 | 5 | 구현방법 6 | 7 | ```ts 8 | export type Example = { 9 | id: number; 10 | media: string; 11 | title: string; 12 | }; 13 | ``` 14 | 15 | - interface대신 type을 사용합니다. 16 | - 파스칼케이스를 사용합니다. 17 | -------------------------------------------------------------------------------- /frontend/src/type/Rank.ts: -------------------------------------------------------------------------------- 1 | type RankType = 2 | | { name: 'traffic'; unit: '개' } 3 | | { name: 'success-rate'; unit: '%' } 4 | | { name: 'elapsed-time'; unit: 'ms' } 5 | | { name: 'dau'; unit: '명' }; 6 | 7 | export type { RankType }; 8 | -------------------------------------------------------------------------------- /frontend/src/type/RegisterForm.ts: -------------------------------------------------------------------------------- 1 | type FormState = { 2 | name: string; 3 | domain: string; 4 | ip: string; 5 | email: string; 6 | generation: string; 7 | }; 8 | 9 | type ValidationState = { 10 | isValidName: boolean; 11 | isValidDomain: boolean; 12 | isValidIp: boolean; 13 | isValidEmail: boolean; 14 | }; 15 | 16 | export type { FormState, ValidationState }; 17 | -------------------------------------------------------------------------------- /frontend/src/util/README.md: -------------------------------------------------------------------------------- 1 | # 여기는 👜util 디렉토리입니다 2 | 3 | 유틸리티 함수들을 모아놓은 디렉토리 입니다. 4 | 5 | 구현방법 6 | 7 | ```ts 8 | function utilFunction() { 9 | return a + b; 10 | } 11 | ``` 12 | 13 | - 카멜케이스를 사용합니다. 14 | -------------------------------------------------------------------------------- /frontend/src/util/Time.ts: -------------------------------------------------------------------------------- 1 | const generateTimeSlots = () => { 2 | const result: Array<[string, number]> = []; 3 | const today = new Date(); 4 | const yesterday = new Date(today); 5 | yesterday.setDate(today.getDate() - 1); 6 | const startDate = new Date(yesterday); 7 | startDate.setHours(0, 0, 0, 0); 8 | 9 | for (let i = 0; i < 144; i++) { 10 | const timestamp = new Date(startDate); 11 | timestamp.setMinutes(i * 10); 12 | const formattedTime = `${timestamp.getFullYear()}-${String(timestamp.getMonth() + 1).padStart(2, '0')}-${String(timestamp.getDate()).padStart(2, '0')} ${String(timestamp.getHours()).padStart(2, '0')}:${String(timestamp.getMinutes()).padStart(2, '0')}:00`; 13 | result.push([formattedTime, 0]); 14 | } 15 | return result; 16 | }; 17 | 18 | const fillEmptySlots = (rawData: Array<[string, string]>) => { 19 | const timeSlots = generateTimeSlots(); 20 | const dataMap = new Map(rawData.map(([timestamp, value]) => [timestamp, Number(value)])); 21 | return timeSlots.map(([timestamp]) => [timestamp, dataMap.get(timestamp) || 0]); 22 | }; 23 | 24 | export { fillEmptySlots }; 25 | -------------------------------------------------------------------------------- /frontend/src/util/Validate.ts: -------------------------------------------------------------------------------- 1 | import { ProjectDAU } from '@type/api'; 2 | 3 | function validateWebsite(value: string) { 4 | return value.length >= 2; 5 | } 6 | 7 | function validateDomain(value: string) { 8 | return /\..+/.test(value); 9 | } 10 | 11 | function validateIp(value: string) { 12 | const ipNumbers = value.split('.'); 13 | const isValidIp = 14 | ipNumbers.length === 4 && 15 | ipNumbers.every((num) => { 16 | const n = parseInt(num); 17 | return !isNaN(n) && n >= 0 && n <= 255; 18 | }); 19 | 20 | return isValidIp; 21 | } 22 | 23 | function validateEmail(value: string) { 24 | return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); 25 | } 26 | 27 | function validateDAU(data: ProjectDAU) { 28 | const count = data.dauRecords.reduce((acc, cur) => acc + cur.dau, 0); 29 | return count === 0; 30 | } 31 | 32 | export { validateWebsite, validateDomain, validateIp, validateEmail, validateDAU }; 33 | -------------------------------------------------------------------------------- /frontend/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /frontend/tailwind.config.js: -------------------------------------------------------------------------------- 1 | import scrollbarHide from 'tailwind-scrollbar-hide'; 2 | /** @type {import('tailwindcss').Config} */ 3 | export default { 4 | content: ['./src/**/*.{js,jsx,ts,tsx}'], 5 | darkMode: 'class', 6 | theme: { 7 | extend: { 8 | colors: { 9 | blue: '#0079FF', 10 | gray: '#91A3B7', 11 | green: '#00DD4B', 12 | red: '#FF7676', 13 | black: '#001940', 14 | lightblue: '#F3F8FC', 15 | darkblue: '#001F42' 16 | }, 17 | fontWeight: { 18 | light: '300', 19 | medium: '500', 20 | semibold: '600', 21 | bold: '700' 22 | } 23 | } 24 | }, 25 | plugins: [scrollbarHide] 26 | }; 27 | -------------------------------------------------------------------------------- /frontend/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "isolatedModules": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true, 23 | 24 | /* Path */ 25 | "composite": true, 26 | "baseUrl": ".", 27 | "paths": { 28 | "@/*": ["./src/*"], 29 | "@component/*": ["./src/component/*"], 30 | "@boundary/*": ["./src/boundary/*"], 31 | "@hook/*": ["./src/hook/*"], 32 | "@type/*": ["./src/type/*"], 33 | "@constant/*": ["./src/constant/*"], 34 | "@chart/*": ["./src/chart/*"], 35 | "@util/*": ["./src/util/*"], 36 | "@api/*": ["./src/api/*"], 37 | "@store/*": ["./src/store/*"], 38 | "@router/*": ["./src/router/*"], 39 | "@asset/*": ["./asset/*"] 40 | } 41 | }, 42 | "include": [ 43 | "src/**/*.ts", 44 | "src/**/*.tsx", 45 | "src/**/*.js", 46 | "src/**/*.jsx", 47 | "src/util/Navigate.tsx" 48 | ], 49 | "exclude": ["src/**/__tests__/*"] 50 | } 51 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }], 4 | "compilerOptions": { 5 | "strict": true, 6 | "baseUrl": ".", 7 | "paths": { 8 | "@/*": ["./src/*"], 9 | "@component/*": ["./src/component/*"], 10 | "@boundary/*": ["./src/boundary/*"], 11 | "@hook/*": ["./src/hook/*"], 12 | "@type/*": ["./src/type/*"], 13 | "@constant/*": ["./src/constant/*"], 14 | "@chart/*": ["./src/chart/*"], 15 | "@util/*": ["./src/util/*"], 16 | "@api/*": ["./src/api/*"], 17 | "@store/*": ["./src/store/*"], 18 | "@router/*": ["./src/router/*"], 19 | "@asset/*": ["./asset/*"] 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /frontend/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "lib": ["ES2023"], 5 | "module": "ESNext", 6 | "skipLibCheck": true, 7 | 8 | /* Bundler mode */ 9 | "moduleResolution": "bundler", 10 | "allowImportingTsExtensions": true, 11 | "isolatedModules": true, 12 | "moduleDetection": "force", 13 | "noEmit": true, 14 | 15 | /* Linting */ 16 | "strict": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noFallthroughCasesInSwitch": true 20 | }, 21 | "include": ["vite.config.ts"] 22 | } 23 | -------------------------------------------------------------------------------- /frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react-swc'; 2 | import { defineConfig } from 'vite'; 3 | 4 | export default defineConfig({ 5 | plugins: [react()], 6 | resolve: { 7 | alias: [ 8 | { find: '@', replacement: '/src' }, 9 | { find: '@component', replacement: '/src/component' }, 10 | { find: '@boundary', replacement: '/src/boundary' }, 11 | { find: '@hook', replacement: '/src/hook' }, 12 | { find: '@type', replacement: '/src/type' }, 13 | { find: '@constant', replacement: '/src/constant' }, 14 | { find: '@util', replacement: '/src/util' }, 15 | { find: '@api', replacement: '/src/api' }, 16 | { find: '@chart', replacement: '/src/chart' }, 17 | { find: '@store', replacement: '/src/store' }, 18 | { find: '@router', replacement: '/src/router' }, 19 | { find: '@asset', replacement: '/asset' } 20 | ] 21 | }, 22 | server: { 23 | port: 5173, 24 | open: true, 25 | host: true 26 | } 27 | }); 28 | --------------------------------------------------------------------------------