From c330be5a5247f9a7e000b870e731d5de20740e0f Mon Sep 17 00:00:00 2001 From: HYOJIN Date: Wed, 8 Apr 2026 13:44:23 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(phase5):=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20+=20=ED=86=B5=EA=B3=84=20+=20Service=20Status=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 백엔드: - DashboardService/Controller (요약, 시간별/서비스별/테넌트별 통계, 에러율, 상위API, 최근로그) - 헬스체크 1분 간격, 매 체크마다 로그 기록 (status page용) - ServiceStatusDetail API (90일 일별 uptime, 최근 체크 60건) - 통계 쿼리 최적화 인덱스 추가 - 테넌트별 요청/사용자 비율 API - 상위 API에 serviceName + apiName 표시 프론트엔드: - DashboardPage (요약 카드 4개, 하트비트 바, Recharts 차트 4개, 테넌트 차트 2개, 최근 로그 5건+더보기) - ServiceStatusPage (status.claude.com 스타일, 90일 uptime 바, Overall banner) - ServiceStatusDetailPage (서비스별 상세, 일별 uptime 바+툴팁, 최근 체크 테이블, 색상 범례) - 30초 자동 갱신 (대시보드), 60초 자동 갱신 (status) - Request Logs 배지 색상 대시보드와 통일 Closes #10 Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/schema/create_tables.sql | 8 + frontend/package-lock.json | 413 ++++++++++++++++- frontend/package.json | 3 +- frontend/src/App.tsx | 4 + frontend/src/layouts/MainLayout.tsx | 1 + frontend/src/pages/DashboardPage.tsx | 428 +++++++++++++++++- .../src/pages/monitoring/RequestLogsPage.tsx | 4 +- .../monitoring/ServiceStatusDetailPage.tsx | 179 ++++++++ .../pages/monitoring/ServiceStatusPage.tsx | 151 ++++++ frontend/src/services/dashboardService.ts | 14 + frontend/src/services/heartbeatService.ts | 4 +- frontend/src/types/dashboard.ts | 39 ++ frontend/src/types/service.ts | 26 ++ .../gateway/service/GatewayService.java | 28 +- .../controller/DashboardController.java | 115 +++++ .../controller/HeartbeatController.java | 20 + .../monitoring/dto/DailyUptimeResponse.java | 10 + .../dto/DashboardStatsResponse.java | 11 + .../monitoring/dto/ErrorTrendResponse.java | 3 + .../monitoring/dto/HourlyTrendResponse.java | 3 + .../monitoring/dto/RecentCheckResponse.java | 21 + .../monitoring/dto/ServiceRatioResponse.java | 3 + .../dto/ServiceStatusDetailResponse.java | 15 + .../monitoring/dto/TenantRatioResponse.java | 3 + .../monitoring/dto/TopApiResponse.java | 3 + .../SnpApiRequestLogRepository.java | 84 ++++ .../SnpServiceHealthLogRepository.java | 21 + .../monitoring/service/DashboardService.java | 180 ++++++++ .../monitoring/service/HeartbeatService.java | 85 +++- src/main/resources/application.yml | 2 +- 30 files changed, 1849 insertions(+), 32 deletions(-) create mode 100644 frontend/src/pages/monitoring/ServiceStatusDetailPage.tsx create mode 100644 frontend/src/pages/monitoring/ServiceStatusPage.tsx create mode 100644 frontend/src/services/dashboardService.ts create mode 100644 frontend/src/types/dashboard.ts create mode 100644 src/main/java/com/gcsc/connection/monitoring/controller/DashboardController.java create mode 100644 src/main/java/com/gcsc/connection/monitoring/dto/DailyUptimeResponse.java create mode 100644 src/main/java/com/gcsc/connection/monitoring/dto/DashboardStatsResponse.java create mode 100644 src/main/java/com/gcsc/connection/monitoring/dto/ErrorTrendResponse.java create mode 100644 src/main/java/com/gcsc/connection/monitoring/dto/HourlyTrendResponse.java create mode 100644 src/main/java/com/gcsc/connection/monitoring/dto/RecentCheckResponse.java create mode 100644 src/main/java/com/gcsc/connection/monitoring/dto/ServiceRatioResponse.java create mode 100644 src/main/java/com/gcsc/connection/monitoring/dto/ServiceStatusDetailResponse.java create mode 100644 src/main/java/com/gcsc/connection/monitoring/dto/TenantRatioResponse.java create mode 100644 src/main/java/com/gcsc/connection/monitoring/dto/TopApiResponse.java create mode 100644 src/main/java/com/gcsc/connection/monitoring/service/DashboardService.java diff --git a/docs/schema/create_tables.sql b/docs/schema/create_tables.sql index 6369579..df0af70 100644 --- a/docs/schema/create_tables.sql +++ b/docs/schema/create_tables.sql @@ -166,6 +166,14 @@ CREATE INDEX idx_snp_request_log_user ON snp_api_request_log (user_id); CREATE INDEX idx_snp_request_log_requested ON snp_api_request_log (requested_at); CREATE INDEX idx_snp_request_log_tenant ON snp_api_request_log (tenant_id); +-- 대시보드 통계 쿼리 최적화 인덱스 +CREATE INDEX idx_snp_request_log_daily_stats ON snp_api_request_log (requested_at, request_status); +CREATE INDEX idx_snp_request_log_daily_user ON snp_api_request_log (requested_at, user_id); +CREATE INDEX idx_snp_request_log_svc_stats ON snp_api_request_log (requested_at, service_id, request_status); +CREATE INDEX idx_snp_request_log_top_api ON snp_api_request_log (requested_at, request_url, request_method); +-- Gateway API Key 인증 인덱스 +CREATE INDEX idx_snp_api_key_prefix ON snp_api_key (api_key_prefix); + -- ----------------------------------------------------------- -- 9. snp_api_key_request (API 키 발급 요청) -- ----------------------------------------------------------- diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 87c8372..57ed006 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "react": "^19.2.0", "react-dom": "^19.2.0", - "react-router-dom": "^7.13.0" + "react-router-dom": "^7.13.0", + "recharts": "^3.8.1" }, "devDependencies": { "@eslint/js": "^9.39.1", @@ -1013,6 +1014,42 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://nexus.gc-si.dev/repository/npm-public/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -1370,6 +1407,18 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.2.2", "resolved": "https://nexus.gc-si.dev/repository/npm-public/@tailwindcss/node/-/node-4.2.2.tgz", @@ -1687,6 +1736,69 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://nexus.gc-si.dev/repository/npm-public/@types/estree/-/estree-1.0.8.tgz", @@ -1715,7 +1827,7 @@ "version": "19.2.14", "resolved": "https://nexus.gc-si.dev/repository/npm-public/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1731,6 +1843,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.58.0", "resolved": "https://nexus.gc-si.dev/repository/npm-public/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz", @@ -2223,6 +2341,15 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://nexus.gc-si.dev/repository/npm-public/color-convert/-/color-convert-2.0.1.tgz", @@ -2289,9 +2416,130 @@ "version": "3.2.3", "resolved": "https://nexus.gc-si.dev/repository/npm-public/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://nexus.gc-si.dev/repository/npm-public/debug/-/debug-4.4.3.tgz", @@ -2310,6 +2558,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://nexus.gc-si.dev/repository/npm-public/deep-is/-/deep-is-0.1.4.tgz", @@ -2348,6 +2602,16 @@ "node": ">=10.13.0" } }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://nexus.gc-si.dev/repository/npm-public/esbuild/-/esbuild-0.27.7.tgz", @@ -2597,6 +2861,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://nexus.gc-si.dev/repository/npm-public/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2782,6 +3052,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://nexus.gc-si.dev/repository/npm-public/import-fresh/-/import-fresh-3.3.1.tgz", @@ -2809,6 +3089,15 @@ "node": ">=0.8.19" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://nexus.gc-si.dev/repository/npm-public/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3486,6 +3775,36 @@ "react": "^19.2.4" } }, + "node_modules/react-is": { + "version": "19.2.4", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/react-is/-/react-is-19.2.4.tgz", + "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://nexus.gc-si.dev/repository/npm-public/react-refresh/-/react-refresh-0.18.0.tgz", @@ -3534,6 +3853,57 @@ "react-dom": ">=18" } }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://nexus.gc-si.dev/repository/npm-public/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3691,6 +4061,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://nexus.gc-si.dev/repository/npm-public/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -3820,6 +4196,37 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "7.3.2", "resolved": "https://nexus.gc-si.dev/repository/npm-public/vite/-/vite-7.3.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 6b95e8f..4617b29 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,7 +13,8 @@ "dependencies": { "react": "^19.2.0", "react-dom": "^19.2.0", - "react-router-dom": "^7.13.0" + "react-router-dom": "^7.13.0", + "recharts": "^3.8.1" }, "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8360226..8ce7397 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,8 @@ import LoginPage from './pages/LoginPage'; import DashboardPage from './pages/DashboardPage'; import RequestLogsPage from './pages/monitoring/RequestLogsPage'; import RequestLogDetailPage from './pages/monitoring/RequestLogDetailPage'; +import ServiceStatusPage from './pages/monitoring/ServiceStatusPage'; +import ServiceStatusDetailPage from './pages/monitoring/ServiceStatusDetailPage'; import MyKeysPage from './pages/apikeys/MyKeysPage'; import KeyRequestPage from './pages/apikeys/KeyRequestPage'; import KeyAdminPage from './pages/apikeys/KeyAdminPage'; @@ -32,6 +34,8 @@ const App = () => { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index cd8d38c..2930151 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -13,6 +13,7 @@ const navGroups: NavGroup[] = [ label: 'Monitoring', items: [ { label: 'Request Logs', path: '/monitoring/request-logs' }, + { label: 'Service Status', path: '/monitoring/service-status' }, ], }, { diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index dd0a71b..8698898 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -1,8 +1,432 @@ +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, + PieChart, Pie, Cell, AreaChart, Area, +} from 'recharts'; +import type { DashboardStats, HourlyTrend, ServiceRatio, ErrorTrend, TopApi, TenantRatio } from '../types/dashboard'; +import type { RequestLog } from '../types/monitoring'; +import type { HeartbeatStatus } from '../types/service'; +import { + getSummary, getHourlyTrend, getServiceRatio, + getErrorTrend, getTopApis, getRecentLogs, getHeartbeat, + getTenantRequestRatio, getTenantUserRatio, +} from '../services/dashboardService'; + +const PIE_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4']; + +const SERVICE_TAG_STYLES = [ + 'bg-blue-100 text-blue-700', + 'bg-emerald-100 text-emerald-700', + 'bg-amber-100 text-amber-700', + 'bg-red-100 text-red-700', + 'bg-violet-100 text-violet-700', + 'bg-cyan-100 text-cyan-700', +]; + +const STATUS_BADGE: Record = { + SUCCESS: 'bg-green-100 text-green-800', + FAIL: 'bg-red-100 text-red-800', + DENIED: 'bg-red-100 text-red-800', + EXPIRED: 'bg-orange-100 text-orange-800', + INVALID_KEY: 'bg-red-100 text-red-800', + ERROR: 'bg-orange-100 text-orange-800', + FAILED: 'bg-gray-100 text-gray-800', +}; + +const AUTO_REFRESH_MS = 30000; + +const extractSettled = (result: PromiseSettledResult<{ data?: T }>, fallback: T): T => { + if (result.status === 'fulfilled' && result.value.data !== undefined) { + return result.value.data; + } + return fallback; +}; + +const truncate = (str: string, max: number): string => { + return str.length > max ? str.slice(0, max) + '...' : str; +}; + const DashboardPage = () => { + const navigate = useNavigate(); + + const [stats, setStats] = useState(null); + const [heartbeat, setHeartbeat] = useState([]); + const [hourlyTrend, setHourlyTrend] = useState([]); + const [serviceRatio, setServiceRatio] = useState([]); + const [errorTrend, setErrorTrend] = useState([]); + const [topApis, setTopApis] = useState([]); + const [tenantRequestRatio, setTenantRequestRatio] = useState([]); + const [tenantUserRatio, setTenantUserRatio] = useState([]); + const [recentLogs, setRecentLogs] = useState([]); + const [lastUpdated, setLastUpdated] = useState(''); + const [isLoading, setIsLoading] = useState(true); + + const fetchAll = useCallback(async () => { + try { + const [summaryRes, heartbeatRes, hourlyRes, serviceRes, errorRes, topRes, tenantReqRes, tenantUserRes, logsRes] = + await Promise.allSettled([ + getSummary(), + getHeartbeat(), + getHourlyTrend(), + getServiceRatio(), + getErrorTrend(), + getTopApis(), + getTenantRequestRatio(), + getTenantUserRatio(), + getRecentLogs(), + ]); + + setStats(extractSettled(summaryRes, null)); + setHeartbeat(extractSettled(heartbeatRes, [])); + setHourlyTrend(extractSettled(hourlyRes, [])); + setServiceRatio(extractSettled(serviceRes, [])); + setErrorTrend(extractSettled(errorRes, [])); + setTopApis(extractSettled(topRes, [])); + setTenantRequestRatio(extractSettled(tenantReqRes, [])); + setTenantUserRatio(extractSettled(tenantUserRes, [])); + setRecentLogs(extractSettled(logsRes, [])); + setLastUpdated(new Date().toLocaleTimeString('ko-KR')); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + fetchAll(); + const interval = setInterval(fetchAll, AUTO_REFRESH_MS); + return () => clearInterval(interval); + }, [fetchAll]); + + const errorTrendPivoted = useMemo(() => { + const serviceNames = [...new Set(errorTrend.map((e) => e.serviceName))]; + const byHour: Record> = {}; + for (const item of errorTrend) { + if (!byHour[item.hour]) { + byHour[item.hour] = { hour: item.hour }; + } + byHour[item.hour][item.serviceName] = item.errorRate; + } + return { + data: Object.values(byHour), + serviceNames, + }; + }, [errorTrend]); + + const topApiServiceColorMap = useMemo(() => { + const serviceNames = [...new Set(topApis.map((a) => a.serviceName))]; + const map: Record = {}; + serviceNames.forEach((name, i) => { + map[name] = { + tag: SERVICE_TAG_STYLES[i % SERVICE_TAG_STYLES.length], + bar: PIE_COLORS[i % PIE_COLORS.length], + }; + }); + return map; + }, [topApis]); + + if (isLoading) { + return ( +
+
Loading...
+
+ ); + } + return (
-

Dashboard

-

Coming soon

+ {/* Header */} +
+

Dashboard

+ {lastUpdated && ( + 마지막 갱신: {lastUpdated} + )} +
+ + {/* Row 1: Summary Cards */} + {stats && ( +
+
+

오늘 총 요청

+

{stats.totalRequests.toLocaleString()}

+

0 ? 'text-green-600' : stats.changePercent < 0 ? 'text-red-600' : 'text-gray-500'}`}> + {stats.changePercent > 0 ? '▲' : stats.changePercent < 0 ? '▼' : ''} 전일 대비 {stats.changePercent}% +

+
+
+

성공률

+

{stats.successRate.toFixed(1)}%

+

실패 {stats.failureCount}건

+
+
+

평균 응답 시간

+

{stats.avgResponseTime.toFixed(0)}ms

+
+
+

활성 사용자

+

{stats.activeUserCount}

+

오늘

+
+
+ )} + + {/* Row 2: Heartbeat Status Bar */} +
+ {heartbeat.length > 0 ? ( +
+ {heartbeat.map((svc) => ( +
navigate('/monitoring/service-status')} + > +
+ {svc.serviceName} + {svc.healthResponseTime !== null && ( + {svc.healthResponseTime}ms + )} + {svc.healthCheckedAt && ( + {svc.healthCheckedAt} + )} +
+ ))} +
+ ) : ( +

등록된 서비스가 없습니다

+ )} +
+ + {/* Row 3: Charts 2x2 */} +
+ {/* Chart 1: Hourly Trend */} +
+

시간별 요청 추이

+ {hourlyTrend.length > 0 ? ( + + + + `${h}시`} /> + + `${h}시`} /> + + + + + + ) : ( +

데이터가 없습니다

+ )} +
+ + {/* Chart 2: Service Ratio */} +
+

서비스별 요청 비율

+ {serviceRatio.length > 0 ? ( + + + + {serviceRatio.map((_, idx) => ( + + ))} + + + + + + ) : ( +

데이터가 없습니다

+ )} +
+ + {/* Chart 3: Error Trend */} +
+

에러율 추이

+ {errorTrendPivoted.data.length > 0 ? ( + + + + `${h}시`} /> + + `${h}시`} /> + + {errorTrendPivoted.serviceNames.map((name, idx) => ( + + ))} + + + ) : ( +

데이터가 없습니다

+ )} +
+ + {/* Chart 4: Top APIs */} +
+

상위 호출 API

+ {topApis.length > 0 ? ( +
+ {topApis.map((api, idx) => { + const maxCount = topApis[0]?.count || 1; + const pct = (api.count / maxCount) * 100; + const colors = topApiServiceColorMap[api.serviceName] || { tag: SERVICE_TAG_STYLES[0], bar: PIE_COLORS[0] }; + return ( +
+ {idx + 1} + + {api.serviceName} + + + {api.apiName} + +
+
+
+ {api.count} +
+ ); + })} +
+ ) : ( +

데이터가 없습니다

+ )} +
+
+ + {/* Row 4: Tenant Stats */} +
+
+

테넌트별 요청 비율

+ {tenantRequestRatio.length > 0 ? ( + + + + {tenantRequestRatio.map((_, idx) => ( + + ))} + + + + + + ) : ( +

데이터가 없습니다

+ )} +
+ +
+

테넌트별 사용자 비율

+ {tenantUserRatio.length > 0 ? ( + + + + {tenantUserRatio.map((_, idx) => ( + + ))} + + + + + + ) : ( +

데이터가 없습니다

+ )} +
+
+ + {/* Row 5: Recent Logs */} +
+
+

최근 요청 로그

+
+ {recentLogs.length > 0 ? ( + <> +
+ + + + + + + + + + + + + {recentLogs.slice(0, 5).map((log) => ( + navigate(`/monitoring/request-logs/${log.logId}`)} + > + + + + + + + + ))} + +
시간서비스사용자URL상태응답시간
{log.requestedAt}{log.serviceName ?? '-'}{log.userName ?? '-'} + {truncate(log.requestUrl, 40)} + + + {log.requestStatus} + + + {log.responseTime !== null ? `${log.responseTime}ms` : '-'} +
+
+
+ +
+ + ) : ( +

요청 로그가 없습니다

+ )} +
); }; diff --git a/frontend/src/pages/monitoring/RequestLogsPage.tsx b/frontend/src/pages/monitoring/RequestLogsPage.tsx index 2a77939..7d8d53a 100644 --- a/frontend/src/pages/monitoring/RequestLogsPage.tsx +++ b/frontend/src/pages/monitoring/RequestLogsPage.tsx @@ -14,13 +14,15 @@ const METHOD_COLOR: Record = { const STATUS_BADGE: Record = { SUCCESS: 'bg-green-100 text-green-800', + FAIL: 'bg-red-100 text-red-800', DENIED: 'bg-red-100 text-red-800', EXPIRED: 'bg-orange-100 text-orange-800', INVALID_KEY: 'bg-red-100 text-red-800', + ERROR: 'bg-orange-100 text-orange-800', FAILED: 'bg-gray-100 text-gray-800', }; -const REQUEST_STATUSES = ['SUCCESS', 'DENIED', 'EXPIRED', 'INVALID_KEY', 'FAILED']; +const REQUEST_STATUSES = ['SUCCESS', 'FAIL', 'DENIED', 'EXPIRED', 'INVALID_KEY', 'ERROR', 'FAILED']; const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE']; const DEFAULT_PAGE_SIZE = 20; diff --git a/frontend/src/pages/monitoring/ServiceStatusDetailPage.tsx b/frontend/src/pages/monitoring/ServiceStatusDetailPage.tsx new file mode 100644 index 0000000..91502ec --- /dev/null +++ b/frontend/src/pages/monitoring/ServiceStatusDetailPage.tsx @@ -0,0 +1,179 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import type { ServiceStatusDetail } from '../../types/service'; +import { getServiceStatusDetail } from '../../services/heartbeatService'; + +const STATUS_COLOR: Record = { + UP: 'bg-green-500', + DOWN: 'bg-red-500', + UNKNOWN: 'bg-gray-400', +}; + +const getUptimeBarColor = (pct: number): string => { + if (pct >= 99.9) return 'bg-green-500'; + if (pct >= 99) return 'bg-green-400'; + if (pct >= 95) return 'bg-yellow-400'; + if (pct >= 90) return 'bg-orange-400'; + return 'bg-red-500'; +}; + +const formatDate = (dateStr: string): string => { + const d = new Date(dateStr); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +}; + +const formatTime = (dateStr: string): string => { + return new Date(dateStr).toLocaleTimeString('ko-KR'); +}; + +const ServiceStatusDetailPage = () => { + const { serviceId } = useParams<{ serviceId: string }>(); + const navigate = useNavigate(); + const [detail, setDetail] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + const fetchData = useCallback(async () => { + if (!serviceId) return; + try { + const res = await getServiceStatusDetail(Number(serviceId)); + if (res.success && res.data) { + setDetail(res.data); + } + } catch { + // silent + } finally { + setIsLoading(false); + } + }, [serviceId]); + + useEffect(() => { + fetchData(); + const interval = setInterval(fetchData, 60000); + return () => clearInterval(interval); + }, [fetchData]); + + if (isLoading) { + return
로딩 중...
; + } + + if (!detail) { + return
서비스를 찾을 수 없습니다
; + } + + return ( +
+ + + {/* Header */} +
+
+
+
+

{detail.serviceName}

+ {detail.serviceCode} +
+
+
+ {detail.currentStatus === 'UP' ? 'Operational' : detail.currentStatus === 'DOWN' ? 'Down' : 'Unknown'} +
+ {detail.lastResponseTime !== null && ( +
{detail.lastResponseTime}ms
+ )} +
+
+
+ + {/* Uptime Summary */} +
+

90일 Uptime

+
{detail.uptimePercent90d.toFixed(3)}%
+ + {/* 90-Day Bar */} +
+ {detail.dailyUptime.map((day, idx) => ( +
+
+
+
+
{formatDate(day.date)}
+
Uptime: {day.uptimePercent.toFixed(1)}%
+
Checks: {day.upChecks}/{day.totalChecks}
+
+
+
+ ))} + {detail.dailyUptime.length === 0 && ( +
+ )} +
+
+ {detail.dailyUptime.length > 0 ? formatDate(detail.dailyUptime[0].date) : ''} + Today +
+ + {/* Daily Uptime Legend */} +
+
99.9%+
+
99%+
+
95%+
+
90%+
+
<90%
+
+
+ + {/* Recent Checks */} +
+
+

최근 체크 이력

+
+
+ + + + + + + + + + + {detail.recentChecks.map((check, idx) => ( + + + + + + + ))} + {detail.recentChecks.length === 0 && ( + + + + )} + +
시간상태응답시간에러
{formatTime(check.checkedAt)} +
+
+ {check.status} +
+
+ {check.responseTime !== null ? `${check.responseTime}ms` : '-'} + + {check.errorMessage || '-'} +
+ 체크 이력이 없습니다 +
+
+
+
+ ); +}; + +export default ServiceStatusDetailPage; diff --git a/frontend/src/pages/monitoring/ServiceStatusPage.tsx b/frontend/src/pages/monitoring/ServiceStatusPage.tsx new file mode 100644 index 0000000..bf7b9c1 --- /dev/null +++ b/frontend/src/pages/monitoring/ServiceStatusPage.tsx @@ -0,0 +1,151 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import type { ServiceStatusDetail } from '../../types/service'; +import { getAllStatusDetail } from '../../services/heartbeatService'; + +const STATUS_COLOR: Record = { + UP: 'bg-green-500', + DOWN: 'bg-red-500', + UNKNOWN: 'bg-gray-400', +}; + +const STATUS_TEXT: Record = { + UP: 'Operational', + DOWN: 'Down', + UNKNOWN: 'Unknown', +}; + +const getUptimeBarColor = (pct: number): string => { + if (pct >= 99.9) return 'bg-green-500'; + if (pct >= 99) return 'bg-green-400'; + if (pct >= 95) return 'bg-yellow-400'; + if (pct >= 90) return 'bg-orange-400'; + return 'bg-red-500'; +}; + +const formatDate = (dateStr: string): string => { + const d = new Date(dateStr); + return `${d.getMonth() + 1}/${d.getDate()}`; +}; + +const ServiceStatusPage = () => { + const navigate = useNavigate(); + const [services, setServices] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [lastUpdated, setLastUpdated] = useState(''); + + const fetchData = useCallback(async () => { + try { + const res = await getAllStatusDetail(); + if (res.success && res.data) { + setServices(res.data); + } + setLastUpdated(new Date().toLocaleTimeString('ko-KR')); + } catch { + // silent + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + fetchData(); + const interval = setInterval(fetchData, 60000); + return () => clearInterval(interval); + }, [fetchData]); + + const allOperational = services.length > 0 && services.every((s) => s.currentStatus === 'UP'); + + if (isLoading) { + return
로딩 중...
; + } + + return ( +
+
+

Service Status

+ 마지막 갱신: {lastUpdated} +
+ + {/* Overall Status Banner */} +
+
+
+ + {allOperational ? 'All Systems Operational' : 'Some Systems Down'} + +
+
+ + {/* Service List */} +
+ {services.map((svc) => ( +
+ {/* Service Header */} +
+
+
+
+

navigate(`/monitoring/service-status/${svc.serviceId}`)} + > + {svc.serviceName} +

+ {svc.serviceCode} +
+
+ + {STATUS_TEXT[svc.currentStatus] || svc.currentStatus} + + {svc.lastResponseTime !== null && ( + {svc.lastResponseTime}ms + )} +
+
+
+ 90일 Uptime: {svc.uptimePercent90d.toFixed(2)}% +
+
+ + {/* 90-Day Uptime Bar */} +
+
+ {svc.dailyUptime.length > 0 ? ( + svc.dailyUptime.map((day, idx) => ( +
+
+
+
+ {formatDate(day.date)}: {day.uptimePercent.toFixed(1)}% +
+
+
+ )) + ) : ( +
+ )} +
+
+ {svc.dailyUptime.length > 0 ? formatDate(svc.dailyUptime[0].date) : ''} + Today +
+
+
+ ))} + + {services.length === 0 && ( +
등록된 서비스가 없습니다
+ )} +
+
+ ); +}; + +export default ServiceStatusPage; diff --git a/frontend/src/services/dashboardService.ts b/frontend/src/services/dashboardService.ts new file mode 100644 index 0000000..9751aa9 --- /dev/null +++ b/frontend/src/services/dashboardService.ts @@ -0,0 +1,14 @@ +import { get } from './apiClient'; +import type { DashboardStats, HourlyTrend, ServiceRatio, ErrorTrend, TopApi, TenantRatio } from '../types/dashboard'; +import type { RequestLog } from '../types/monitoring'; +import type { HeartbeatStatus } from '../types/service'; + +export const getSummary = () => get('/dashboard/summary'); +export const getHourlyTrend = () => get('/dashboard/hourly-trend'); +export const getServiceRatio = () => get('/dashboard/service-ratio'); +export const getErrorTrend = () => get('/dashboard/error-trend'); +export const getTopApis = (limit = 10) => get(`/dashboard/top-apis?limit=${limit}`); +export const getTenantRequestRatio = () => get('/dashboard/tenant-request-ratio'); +export const getTenantUserRatio = () => get('/dashboard/tenant-user-ratio'); +export const getRecentLogs = () => get('/dashboard/recent-logs'); +export const getHeartbeat = () => get('/dashboard/heartbeat'); diff --git a/frontend/src/services/heartbeatService.ts b/frontend/src/services/heartbeatService.ts index 93138b8..13736de 100644 --- a/frontend/src/services/heartbeatService.ts +++ b/frontend/src/services/heartbeatService.ts @@ -1,6 +1,8 @@ import { get, post } from './apiClient'; -import type { HeartbeatStatus, HealthHistory } from '../types/service'; +import type { HeartbeatStatus, HealthHistory, ServiceStatusDetail } from '../types/service'; export const getHeartbeatStatus = () => get('/heartbeat/status'); export const getHealthHistory = (serviceId: number) => get(`/heartbeat/${serviceId}/history`); export const triggerCheck = (serviceId: number) => post(`/heartbeat/${serviceId}/check`); +export const getAllStatusDetail = () => get('/heartbeat/status/detail'); +export const getServiceStatusDetail = (serviceId: number) => get(`/heartbeat/${serviceId}/status/detail`); diff --git a/frontend/src/types/dashboard.ts b/frontend/src/types/dashboard.ts new file mode 100644 index 0000000..9e3c472 --- /dev/null +++ b/frontend/src/types/dashboard.ts @@ -0,0 +1,39 @@ +export interface DashboardStats { + totalRequests: number; + yesterdayRequests: number; + changePercent: number; + successRate: number; + failureCount: number; + avgResponseTime: number; + activeUserCount: number; +} + +export interface HourlyTrend { + hour: number; + successCount: number; + failureCount: number; +} + +export interface ServiceRatio { + serviceName: string; + count: number; +} + +export interface ErrorTrend { + hour: number; + serviceName: string; + errorRate: number; +} + +export interface TenantRatio { + tenantName: string; + count: number; +} + +export interface TopApi { + serviceName: string; + apiName: string; + requestUrl: string; + requestMethod: string; + count: number; +} diff --git a/frontend/src/types/service.ts b/frontend/src/types/service.ts index fbf1053..477df58 100644 --- a/frontend/src/types/service.ts +++ b/frontend/src/types/service.ts @@ -58,6 +58,32 @@ export interface HeartbeatStatus { healthResponseTime: number | null; } +export interface DailyUptime { + date: string; + totalChecks: number; + upChecks: number; + uptimePercent: number; +} + +export interface RecentCheck { + status: string; + responseTime: number | null; + errorMessage: string | null; + checkedAt: string; +} + +export interface ServiceStatusDetail { + serviceId: number; + serviceCode: string; + serviceName: string; + currentStatus: string; + lastResponseTime: number | null; + lastCheckedAt: string | null; + uptimePercent90d: number; + dailyUptime: DailyUptime[]; + recentChecks: RecentCheck[]; +} + export interface HealthHistory { logId: number; serviceId: number; diff --git a/src/main/java/com/gcsc/connection/gateway/service/GatewayService.java b/src/main/java/com/gcsc/connection/gateway/service/GatewayService.java index 8de1b8c..ba38755 100644 --- a/src/main/java/com/gcsc/connection/gateway/service/GatewayService.java +++ b/src/main/java/com/gcsc/connection/gateway/service/GatewayService.java @@ -60,19 +60,7 @@ public class GatewayService { String targetUrl = null; try { - // 1. API Key 추출 - String rawKey = request.getHeader("X-API-KEY"); - if (rawKey == null || rawKey.isBlank()) { - throw new BusinessException(ErrorCode.GATEWAY_API_KEY_MISSING); - } - - // 2. API Key 검증 (prefix 매칭 후 복호화 비교) - apiKey = findApiKeyByRawKey(rawKey); - - // 3. Key 상태/만료 검증 - validateApiKey(apiKey); - - // 4. 서비스 조회 + // 1. 서비스 조회 (모든 로그에 service_id 기록을 위해 최우선 수행) service = snpServiceRepository.findByServiceCode(serviceCode) .orElseThrow(() -> new BusinessException(ErrorCode.SERVICE_NOT_FOUND)); @@ -80,9 +68,21 @@ public class GatewayService { throw new BusinessException(ErrorCode.GATEWAY_SERVICE_INACTIVE); } - // 5. 대상 URL 조합 (실패 로그에도 사용) + // 2. 대상 URL 조합 (실패 로그에도 사용) targetUrl = buildTargetUrl(service.getServiceUrl(), remainingPath, request); + // 3. API Key 추출 + String rawKey = request.getHeader("X-API-KEY"); + if (rawKey == null || rawKey.isBlank()) { + throw new BusinessException(ErrorCode.GATEWAY_API_KEY_MISSING); + } + + // 4. API Key 검증 (prefix 매칭 후 복호화 비교) + apiKey = findApiKeyByRawKey(rawKey); + + // 5. Key 상태/만료 검증 + validateApiKey(apiKey); + // 6. ServiceApi 조회 (경로 + 메서드 매칭) String apiPath = remainingPath.startsWith("/") ? remainingPath : "/" + remainingPath; SnpServiceApi serviceApi = snpServiceApiRepository diff --git a/src/main/java/com/gcsc/connection/monitoring/controller/DashboardController.java b/src/main/java/com/gcsc/connection/monitoring/controller/DashboardController.java new file mode 100644 index 0000000..0ce52e2 --- /dev/null +++ b/src/main/java/com/gcsc/connection/monitoring/controller/DashboardController.java @@ -0,0 +1,115 @@ +package com.gcsc.connection.monitoring.controller; + +import com.gcsc.connection.common.dto.ApiResponse; +import com.gcsc.connection.monitoring.dto.DashboardStatsResponse; +import com.gcsc.connection.monitoring.dto.ErrorTrendResponse; +import com.gcsc.connection.monitoring.dto.HeartbeatStatusResponse; +import com.gcsc.connection.monitoring.dto.HourlyTrendResponse; +import com.gcsc.connection.monitoring.dto.RequestLogResponse; +import com.gcsc.connection.monitoring.dto.ServiceRatioResponse; +import com.gcsc.connection.monitoring.dto.TenantRatioResponse; +import com.gcsc.connection.monitoring.dto.TopApiResponse; +import com.gcsc.connection.monitoring.service.DashboardService; +import com.gcsc.connection.monitoring.service.HeartbeatService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 대시보드 API - 모니터링 요약/통계 데이터 제공 + */ +@RestController +@RequestMapping("/api/dashboard") +@RequiredArgsConstructor +public class DashboardController { + + private final DashboardService dashboardService; + private final HeartbeatService heartbeatService; + + /** + * 대시보드 요약 통계 조회 + */ + @GetMapping("/summary") + public ResponseEntity> getSummary() { + DashboardStatsResponse summary = dashboardService.getSummary(); + return ResponseEntity.ok(ApiResponse.ok(summary)); + } + + /** + * 시간별 요청 추이 조회 + */ + @GetMapping("/hourly-trend") + public ResponseEntity>> getHourlyTrend() { + List trend = dashboardService.getHourlyTrend(); + return ResponseEntity.ok(ApiResponse.ok(trend)); + } + + /** + * 서비스별 요청 비율 조회 + */ + @GetMapping("/service-ratio") + public ResponseEntity>> getServiceRatio() { + List ratio = dashboardService.getServiceRatio(); + return ResponseEntity.ok(ApiResponse.ok(ratio)); + } + + /** + * 에러율 추이 조회 (시간별, 서비스별) + */ + @GetMapping("/error-trend") + public ResponseEntity>> getErrorTrend() { + List trend = dashboardService.getErrorTrend(); + return ResponseEntity.ok(ApiResponse.ok(trend)); + } + + /** + * 상위 API 랭킹 조회 + */ + @GetMapping("/top-apis") + public ResponseEntity>> getTopApis( + @RequestParam(defaultValue = "10") int limit) { + List topApis = dashboardService.getTopApis(limit); + return ResponseEntity.ok(ApiResponse.ok(topApis)); + } + + /** + * 테넌트별 요청 비율 조회 + */ + @GetMapping("/tenant-request-ratio") + public ResponseEntity>> getTenantRequestRatio() { + List ratio = dashboardService.getTenantRequestRatio(); + return ResponseEntity.ok(ApiResponse.ok(ratio)); + } + + /** + * 테넌트별 사용자 비율 조회 + */ + @GetMapping("/tenant-user-ratio") + public ResponseEntity>> getTenantUserRatio() { + List ratio = dashboardService.getTenantUserRatio(); + return ResponseEntity.ok(ApiResponse.ok(ratio)); + } + + /** + * 최근 요청 로그 조회 + */ + @GetMapping("/recent-logs") + public ResponseEntity>> getRecentLogs() { + List logs = dashboardService.getRecentLogs(); + return ResponseEntity.ok(ApiResponse.ok(logs)); + } + + /** + * 전체 서비스 헬스 상태 조회 (하트비트) + */ + @GetMapping("/heartbeat") + public ResponseEntity>> getHeartbeat() { + List statuses = heartbeatService.getAllServiceStatus(); + return ResponseEntity.ok(ApiResponse.ok(statuses)); + } +} diff --git a/src/main/java/com/gcsc/connection/monitoring/controller/HeartbeatController.java b/src/main/java/com/gcsc/connection/monitoring/controller/HeartbeatController.java index 77dd7e0..620fbad 100644 --- a/src/main/java/com/gcsc/connection/monitoring/controller/HeartbeatController.java +++ b/src/main/java/com/gcsc/connection/monitoring/controller/HeartbeatController.java @@ -3,6 +3,7 @@ package com.gcsc.connection.monitoring.controller; import com.gcsc.connection.common.dto.ApiResponse; import com.gcsc.connection.monitoring.dto.HeartbeatStatusResponse; import com.gcsc.connection.monitoring.dto.HealthHistoryResponse; +import com.gcsc.connection.monitoring.dto.ServiceStatusDetailResponse; import com.gcsc.connection.monitoring.service.HeartbeatService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -34,6 +35,25 @@ public class HeartbeatController { return ResponseEntity.ok(ApiResponse.ok(statuses)); } + /** + * 전체 서비스 상태 상세 (status page) + */ + @GetMapping("/status/detail") + public ResponseEntity>> getAllStatusDetail() { + List details = heartbeatService.getAllServiceStatusDetail(); + return ResponseEntity.ok(ApiResponse.ok(details)); + } + + /** + * 단일 서비스 상태 상세 (status page) + */ + @GetMapping("/{serviceId}/status/detail") + public ResponseEntity> getServiceStatusDetail( + @PathVariable Long serviceId) { + ServiceStatusDetailResponse detail = heartbeatService.getServiceStatusDetail(serviceId); + return ResponseEntity.ok(ApiResponse.ok(detail)); + } + /** * 서비스 헬스체크 이력 조회 */ diff --git a/src/main/java/com/gcsc/connection/monitoring/dto/DailyUptimeResponse.java b/src/main/java/com/gcsc/connection/monitoring/dto/DailyUptimeResponse.java new file mode 100644 index 0000000..cbd848d --- /dev/null +++ b/src/main/java/com/gcsc/connection/monitoring/dto/DailyUptimeResponse.java @@ -0,0 +1,10 @@ +package com.gcsc.connection.monitoring.dto; + +import java.time.LocalDate; + +public record DailyUptimeResponse( + LocalDate date, + long totalChecks, + long upChecks, + double uptimePercent +) {} diff --git a/src/main/java/com/gcsc/connection/monitoring/dto/DashboardStatsResponse.java b/src/main/java/com/gcsc/connection/monitoring/dto/DashboardStatsResponse.java new file mode 100644 index 0000000..a549a7b --- /dev/null +++ b/src/main/java/com/gcsc/connection/monitoring/dto/DashboardStatsResponse.java @@ -0,0 +1,11 @@ +package com.gcsc.connection.monitoring.dto; + +public record DashboardStatsResponse( + long totalRequests, + long yesterdayRequests, + double changePercent, + double successRate, + long failureCount, + double avgResponseTime, + long activeUserCount +) {} diff --git a/src/main/java/com/gcsc/connection/monitoring/dto/ErrorTrendResponse.java b/src/main/java/com/gcsc/connection/monitoring/dto/ErrorTrendResponse.java new file mode 100644 index 0000000..8efa632 --- /dev/null +++ b/src/main/java/com/gcsc/connection/monitoring/dto/ErrorTrendResponse.java @@ -0,0 +1,3 @@ +package com.gcsc.connection.monitoring.dto; + +public record ErrorTrendResponse(int hour, String serviceName, double errorRate) {} diff --git a/src/main/java/com/gcsc/connection/monitoring/dto/HourlyTrendResponse.java b/src/main/java/com/gcsc/connection/monitoring/dto/HourlyTrendResponse.java new file mode 100644 index 0000000..981f08f --- /dev/null +++ b/src/main/java/com/gcsc/connection/monitoring/dto/HourlyTrendResponse.java @@ -0,0 +1,3 @@ +package com.gcsc.connection.monitoring.dto; + +public record HourlyTrendResponse(int hour, long successCount, long failureCount) {} diff --git a/src/main/java/com/gcsc/connection/monitoring/dto/RecentCheckResponse.java b/src/main/java/com/gcsc/connection/monitoring/dto/RecentCheckResponse.java new file mode 100644 index 0000000..bc8ba20 --- /dev/null +++ b/src/main/java/com/gcsc/connection/monitoring/dto/RecentCheckResponse.java @@ -0,0 +1,21 @@ +package com.gcsc.connection.monitoring.dto; + +import com.gcsc.connection.monitoring.entity.SnpServiceHealthLog; + +import java.time.LocalDateTime; + +public record RecentCheckResponse( + String status, + Integer responseTime, + String errorMessage, + LocalDateTime checkedAt +) { + public static RecentCheckResponse from(SnpServiceHealthLog log) { + return new RecentCheckResponse( + log.getCurrentStatus(), + log.getResponseTime(), + log.getErrorMessage(), + log.getCheckedAt() + ); + } +} diff --git a/src/main/java/com/gcsc/connection/monitoring/dto/ServiceRatioResponse.java b/src/main/java/com/gcsc/connection/monitoring/dto/ServiceRatioResponse.java new file mode 100644 index 0000000..f46f20e --- /dev/null +++ b/src/main/java/com/gcsc/connection/monitoring/dto/ServiceRatioResponse.java @@ -0,0 +1,3 @@ +package com.gcsc.connection.monitoring.dto; + +public record ServiceRatioResponse(String serviceName, long count) {} diff --git a/src/main/java/com/gcsc/connection/monitoring/dto/ServiceStatusDetailResponse.java b/src/main/java/com/gcsc/connection/monitoring/dto/ServiceStatusDetailResponse.java new file mode 100644 index 0000000..8b44ff1 --- /dev/null +++ b/src/main/java/com/gcsc/connection/monitoring/dto/ServiceStatusDetailResponse.java @@ -0,0 +1,15 @@ +package com.gcsc.connection.monitoring.dto; + +import java.util.List; + +public record ServiceStatusDetailResponse( + Long serviceId, + String serviceCode, + String serviceName, + String currentStatus, + Integer lastResponseTime, + String lastCheckedAt, + double uptimePercent90d, + List dailyUptime, + List recentChecks +) {} diff --git a/src/main/java/com/gcsc/connection/monitoring/dto/TenantRatioResponse.java b/src/main/java/com/gcsc/connection/monitoring/dto/TenantRatioResponse.java new file mode 100644 index 0000000..a9c70c0 --- /dev/null +++ b/src/main/java/com/gcsc/connection/monitoring/dto/TenantRatioResponse.java @@ -0,0 +1,3 @@ +package com.gcsc.connection.monitoring.dto; + +public record TenantRatioResponse(String tenantName, long count) {} diff --git a/src/main/java/com/gcsc/connection/monitoring/dto/TopApiResponse.java b/src/main/java/com/gcsc/connection/monitoring/dto/TopApiResponse.java new file mode 100644 index 0000000..d2aebb8 --- /dev/null +++ b/src/main/java/com/gcsc/connection/monitoring/dto/TopApiResponse.java @@ -0,0 +1,3 @@ +package com.gcsc.connection.monitoring.dto; + +public record TopApiResponse(String serviceName, String apiName, String requestUrl, String requestMethod, long count) {} diff --git a/src/main/java/com/gcsc/connection/monitoring/repository/SnpApiRequestLogRepository.java b/src/main/java/com/gcsc/connection/monitoring/repository/SnpApiRequestLogRepository.java index 4159cf7..b060bf6 100644 --- a/src/main/java/com/gcsc/connection/monitoring/repository/SnpApiRequestLogRepository.java +++ b/src/main/java/com/gcsc/connection/monitoring/repository/SnpApiRequestLogRepository.java @@ -3,7 +3,91 @@ package com.gcsc.connection.monitoring.repository; import com.gcsc.connection.monitoring.entity.SnpApiRequestLog; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.List; public interface SnpApiRequestLogRepository extends JpaRepository, JpaSpecificationExecutor { + + /** 오늘 요약: 총 요청, 성공 건수, 평균 응답시간 */ + @Query(value = "SELECT COUNT(*) as total, " + + "COUNT(CASE WHEN request_status = 'SUCCESS' THEN 1 END) as successCount, " + + "COALESCE(AVG(response_time), 0) as avgResponseTime " + + "FROM common.snp_api_request_log WHERE requested_at >= :startOfDay", nativeQuery = true) + Object[] findTodayStats(@Param("startOfDay") LocalDateTime startOfDay); + + /** 전일 총 요청 건수 */ + @Query(value = "SELECT COUNT(*) FROM common.snp_api_request_log " + + "WHERE requested_at >= :yesterday AND requested_at < :today", nativeQuery = true) + long countYesterdayRequests(@Param("yesterday") LocalDateTime yesterday, + @Param("today") LocalDateTime today); + + /** 오늘 활성 사용자 수 */ + @Query(value = "SELECT COUNT(DISTINCT user_id) FROM common.snp_api_request_log " + + "WHERE requested_at >= :startOfDay AND user_id IS NOT NULL", nativeQuery = true) + long countTodayActiveUsers(@Param("startOfDay") LocalDateTime startOfDay); + + /** 시간별 요청 추이 (오늘) */ + @Query(value = "SELECT CAST(EXTRACT(HOUR FROM requested_at) AS int) as hour, " + + "request_status as status, COUNT(*) as cnt " + + "FROM common.snp_api_request_log WHERE requested_at >= :startOfDay " + + "GROUP BY 1, 2 ORDER BY 1", nativeQuery = true) + List findHourlyTrend(@Param("startOfDay") LocalDateTime startOfDay); + + /** 서비스별 요청 비율 */ + @Query(value = "SELECT s.service_name, COUNT(*) as cnt " + + "FROM common.snp_api_request_log l " + + "LEFT JOIN common.snp_service s ON l.service_id = s.service_id " + + "WHERE l.requested_at >= :startOfDay " + + "GROUP BY s.service_name ORDER BY cnt DESC", nativeQuery = true) + List findServiceRatio(@Param("startOfDay") LocalDateTime startOfDay); + + /** 에러율 추이 (시간별) */ + @Query(value = "SELECT CAST(EXTRACT(HOUR FROM l.requested_at) AS int) as hour, " + + "COALESCE(s.service_name, 'Unknown') as serviceName, " + + "CAST(COUNT(CASE WHEN l.request_status != 'SUCCESS' THEN 1 END) AS float) " + + "/ NULLIF(COUNT(*), 0) * 100 as errorRate " + + "FROM common.snp_api_request_log l " + + "LEFT JOIN common.snp_service s ON l.service_id = s.service_id " + + "WHERE l.requested_at >= :startOfDay " + + "GROUP BY 1, 2 ORDER BY 1", nativeQuery = true) + List findErrorTrend(@Param("startOfDay") LocalDateTime startOfDay); + + /** 상위 API 랭킹 (service_name, api_name 포함) */ + @Query(value = "SELECT COALESCE(s.service_name, 'Unknown') as serviceName, " + + "COALESCE(a.api_name, l.request_url) as apiName, " + + "l.request_url, l.request_method, COUNT(*) as cnt " + + "FROM common.snp_api_request_log l " + + "LEFT JOIN common.snp_service s ON l.service_id = s.service_id " + + "LEFT JOIN common.snp_service_api a ON s.service_id = a.service_id " + + "AND a.api_path = SUBSTRING(l.request_url FROM '/gateway/[^/]+(.*)') " + + "AND a.api_method = l.request_method " + + "WHERE l.requested_at >= :startOfDay " + + "GROUP BY s.service_name, a.api_name, l.request_url, l.request_method " + + "ORDER BY cnt DESC LIMIT :limit", nativeQuery = true) + List findTopApis(@Param("startOfDay") LocalDateTime startOfDay, + @Param("limit") int limit); + + /** 테넌트별 요청 비율 */ + @Query(value = "SELECT COALESCE(t.tenant_name, 'Unknown') as tenantName, COUNT(*) as cnt " + + "FROM common.snp_api_request_log l " + + "LEFT JOIN common.snp_tenant t ON l.tenant_id = t.tenant_id " + + "WHERE l.requested_at >= :startOfDay " + + "GROUP BY t.tenant_name ORDER BY cnt DESC", nativeQuery = true) + List findTenantRequestRatio(@Param("startOfDay") LocalDateTime startOfDay); + + /** 테넌트별 사용자 비율 */ + @Query(value = "SELECT COALESCE(t.tenant_name, 'Unknown') as tenantName, " + + "COUNT(DISTINCT l.user_id) as userCount " + + "FROM common.snp_api_request_log l " + + "LEFT JOIN common.snp_tenant t ON l.tenant_id = t.tenant_id " + + "WHERE l.requested_at >= :startOfDay AND l.user_id IS NOT NULL " + + "GROUP BY t.tenant_name ORDER BY userCount DESC", nativeQuery = true) + List findTenantUserRatio(@Param("startOfDay") LocalDateTime startOfDay); + + /** 최근 로그 20건 */ + List findTop20ByOrderByRequestedAtDesc(); } diff --git a/src/main/java/com/gcsc/connection/monitoring/repository/SnpServiceHealthLogRepository.java b/src/main/java/com/gcsc/connection/monitoring/repository/SnpServiceHealthLogRepository.java index be86273..6004126 100644 --- a/src/main/java/com/gcsc/connection/monitoring/repository/SnpServiceHealthLogRepository.java +++ b/src/main/java/com/gcsc/connection/monitoring/repository/SnpServiceHealthLogRepository.java @@ -1,11 +1,32 @@ package com.gcsc.connection.monitoring.repository; import com.gcsc.connection.monitoring.entity.SnpServiceHealthLog; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import java.time.LocalDateTime; import java.util.List; public interface SnpServiceHealthLogRepository extends JpaRepository { List findByServiceServiceIdOrderByCheckedAtDesc(Long serviceId); + + List findByServiceServiceIdOrderByCheckedAtDesc(Long serviceId, Pageable pageable); + + /** 일별 uptime 비율 (service_id별, 90일) */ + @Query(value = "SELECT DATE(checked_at) as check_date, " + + "COUNT(*) as total, " + + "COUNT(CASE WHEN current_status = 'UP' THEN 1 END) as up_count " + + "FROM common.snp_service_health_log " + + "WHERE service_id = :serviceId AND checked_at >= :since " + + "GROUP BY DATE(checked_at) ORDER BY check_date", nativeQuery = true) + List findDailyUptime(@Param("serviceId") Long serviceId, @Param("since") LocalDateTime since); + + /** 90일 이전 데이터 삭제 */ + @Modifying + @Query("DELETE FROM SnpServiceHealthLog h WHERE h.checkedAt < :before") + int deleteOlderThan(@Param("before") LocalDateTime before); } diff --git a/src/main/java/com/gcsc/connection/monitoring/service/DashboardService.java b/src/main/java/com/gcsc/connection/monitoring/service/DashboardService.java new file mode 100644 index 0000000..923c6fb --- /dev/null +++ b/src/main/java/com/gcsc/connection/monitoring/service/DashboardService.java @@ -0,0 +1,180 @@ +package com.gcsc.connection.monitoring.service; + +import com.gcsc.connection.monitoring.dto.DashboardStatsResponse; +import com.gcsc.connection.monitoring.dto.ErrorTrendResponse; +import com.gcsc.connection.monitoring.dto.HourlyTrendResponse; +import com.gcsc.connection.monitoring.dto.RequestLogResponse; +import com.gcsc.connection.monitoring.dto.ServiceRatioResponse; +import com.gcsc.connection.monitoring.dto.TenantRatioResponse; +import com.gcsc.connection.monitoring.dto.TopApiResponse; +import com.gcsc.connection.monitoring.repository.SnpApiRequestLogRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +@Slf4j +@RequiredArgsConstructor +public class DashboardService { + + private final SnpApiRequestLogRepository snpApiRequestLogRepository; + + /** + * 대시보드 요약 통계 조회 + */ + @Transactional(readOnly = true) + public DashboardStatsResponse getSummary() { + LocalDateTime startOfDay = LocalDate.now().atStartOfDay(); + LocalDateTime yesterday = startOfDay.minusDays(1); + + Object[] stats = snpApiRequestLogRepository.findTodayStats(startOfDay); + Object[] row = (Object[]) stats[0]; + + long total = ((Number) row[0]).longValue(); + long successCount = ((Number) row[1]).longValue(); + double avgResponseTime = ((Number) row[2]).doubleValue(); + + long yesterdayRequests = snpApiRequestLogRepository + .countYesterdayRequests(yesterday, startOfDay); + + double changePercent = yesterdayRequests > 0 + ? ((double) (total - yesterdayRequests) / yesterdayRequests) * 100 + : 0.0; + + double successRate = total > 0 + ? ((double) successCount / total) * 100 + : 0.0; + + long failureCount = total - successCount; + + long activeUserCount = snpApiRequestLogRepository + .countTodayActiveUsers(startOfDay); + + return new DashboardStatsResponse( + total, yesterdayRequests, changePercent, + successRate, failureCount, avgResponseTime, activeUserCount + ); + } + + /** + * 시간별 요청 추이 조회 (오늘, 0~23시) + */ + @Transactional(readOnly = true) + public List getHourlyTrend() { + LocalDateTime startOfDay = LocalDate.now().atStartOfDay(); + List rows = snpApiRequestLogRepository.findHourlyTrend(startOfDay); + + Map hourMap = new HashMap<>(); + for (Object[] row : rows) { + int hour = ((Number) row[0]).intValue(); + String status = (String) row[1]; + long cnt = ((Number) row[2]).longValue(); + + hourMap.computeIfAbsent(hour, k -> new long[2]); + if ("SUCCESS".equals(status)) { + hourMap.get(hour)[0] += cnt; + } else { + hourMap.get(hour)[1] += cnt; + } + } + + List result = new ArrayList<>(); + for (int h = 0; h < 24; h++) { + long[] counts = hourMap.getOrDefault(h, new long[2]); + result.add(new HourlyTrendResponse(h, counts[0], counts[1])); + } + return result; + } + + /** + * 서비스별 요청 비율 조회 + */ + @Transactional(readOnly = true) + public List getServiceRatio() { + LocalDateTime startOfDay = LocalDate.now().atStartOfDay(); + return snpApiRequestLogRepository.findServiceRatio(startOfDay).stream() + .map(row -> new ServiceRatioResponse( + (String) row[0], + ((Number) row[1]).longValue() + )) + .toList(); + } + + /** + * 에러율 추이 조회 (시간별, 서비스별) + */ + @Transactional(readOnly = true) + public List getErrorTrend() { + LocalDateTime startOfDay = LocalDate.now().atStartOfDay(); + return snpApiRequestLogRepository.findErrorTrend(startOfDay).stream() + .map(row -> new ErrorTrendResponse( + ((Number) row[0]).intValue(), + (String) row[1], + ((Number) row[2]).doubleValue() + )) + .toList(); + } + + /** + * 상위 API 랭킹 조회 + */ + @Transactional(readOnly = true) + public List getTopApis(int limit) { + LocalDateTime startOfDay = LocalDate.now().atStartOfDay(); + return snpApiRequestLogRepository.findTopApis(startOfDay, limit).stream() + .map(row -> new TopApiResponse( + (String) row[0], + (String) row[1], + (String) row[2], + (String) row[3], + ((Number) row[4]).longValue() + )) + .toList(); + } + + /** + * 테넌트별 요청 비율 조회 + */ + @Transactional(readOnly = true) + public List getTenantRequestRatio() { + LocalDateTime startOfDay = LocalDate.now().atStartOfDay(); + return snpApiRequestLogRepository.findTenantRequestRatio(startOfDay).stream() + .map(row -> new TenantRatioResponse( + (String) row[0], + ((Number) row[1]).longValue() + )) + .toList(); + } + + /** + * 테넌트별 사용자 비율 조회 + */ + @Transactional(readOnly = true) + public List getTenantUserRatio() { + LocalDateTime startOfDay = LocalDate.now().atStartOfDay(); + return snpApiRequestLogRepository.findTenantUserRatio(startOfDay).stream() + .map(row -> new TenantRatioResponse( + (String) row[0], + ((Number) row[1]).longValue() + )) + .toList(); + } + + /** + * 최근 로그 20건 조회 + */ + @Transactional(readOnly = true) + public List getRecentLogs() { + return snpApiRequestLogRepository.findTop20ByOrderByRequestedAtDesc().stream() + .map(RequestLogResponse::from) + .toList(); + } +} diff --git a/src/main/java/com/gcsc/connection/monitoring/service/HeartbeatService.java b/src/main/java/com/gcsc/connection/monitoring/service/HeartbeatService.java index bc8e480..9fdb2bc 100644 --- a/src/main/java/com/gcsc/connection/monitoring/service/HeartbeatService.java +++ b/src/main/java/com/gcsc/connection/monitoring/service/HeartbeatService.java @@ -2,8 +2,11 @@ package com.gcsc.connection.monitoring.service; import com.gcsc.connection.common.exception.BusinessException; import com.gcsc.connection.common.exception.ErrorCode; +import com.gcsc.connection.monitoring.dto.DailyUptimeResponse; import com.gcsc.connection.monitoring.dto.HeartbeatStatusResponse; import com.gcsc.connection.monitoring.dto.HealthHistoryResponse; +import com.gcsc.connection.monitoring.dto.RecentCheckResponse; +import com.gcsc.connection.monitoring.dto.ServiceStatusDetailResponse; import com.gcsc.connection.monitoring.entity.SnpServiceHealthLog; import com.gcsc.connection.monitoring.repository.SnpServiceHealthLogRepository; import com.gcsc.connection.service.entity.ServiceStatus; @@ -15,7 +18,11 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.data.domain.PageRequest; + +import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; @Service @@ -50,6 +57,64 @@ public class HeartbeatService { .toList(); } + /** + * 서비스 상태 상세 조회 (status.claude.com 스타일) + */ + @Transactional(readOnly = true) + public ServiceStatusDetailResponse getServiceStatusDetail(Long serviceId) { + SnpService service = snpServiceRepository.findById(serviceId) + .orElseThrow(() -> new BusinessException(ErrorCode.SERVICE_NOT_FOUND)); + + LocalDateTime since90d = LocalDateTime.now().minusDays(90); + + // 90일 일별 uptime + List dailyRows = snpServiceHealthLogRepository.findDailyUptime(serviceId, since90d); + List dailyUptime = new ArrayList<>(); + long totalAll = 0; + long upAll = 0; + + for (Object[] row : dailyRows) { + LocalDate date = ((java.sql.Date) row[0]).toLocalDate(); + long total = ((Number) row[1]).longValue(); + long upCount = ((Number) row[2]).longValue(); + double pct = total > 0 ? (double) upCount / total * 100 : 0; + dailyUptime.add(new DailyUptimeResponse(date, total, upCount, pct)); + totalAll += total; + upAll += upCount; + } + + double uptimePercent90d = totalAll > 0 ? (double) upAll / totalAll * 100 : 0; + + // 최근 60건 체크 이력 + List recentChecks = snpServiceHealthLogRepository + .findByServiceServiceIdOrderByCheckedAtDesc(serviceId, PageRequest.of(0, 60)) + .stream() + .map(RecentCheckResponse::from) + .toList(); + + return new ServiceStatusDetailResponse( + service.getServiceId(), + service.getServiceCode(), + service.getServiceName(), + service.getHealthStatus().name(), + service.getHealthResponseTime(), + service.getHealthCheckedAt() != null ? service.getHealthCheckedAt().toString() : null, + uptimePercent90d, + dailyUptime, + recentChecks + ); + } + + /** + * 전체 서비스 상태 요약 (status page 메인) + */ + @Transactional(readOnly = true) + public List getAllServiceStatusDetail() { + return snpServiceRepository.findByIsActiveTrue().stream() + .map(s -> getServiceStatusDetail(s.getServiceId())) + .toList(); + } + /** * 단일 서비스 헬스체크 실행 */ @@ -100,16 +165,18 @@ public class HeartbeatService { log.warn("헬스체크 실패 - 서비스: {}, 오류: {}", service.getServiceCode(), e.getMessage()); } + // 매 체크마다 기록 (status page 이력용) + SnpServiceHealthLog healthLog = SnpServiceHealthLog.builder() + .service(service) + .previousStatus(previousStatus.name()) + .currentStatus(newStatus.name()) + .responseTime(responseTime) + .errorMessage(errorMessage) + .checkedAt(LocalDateTime.now()) + .build(); + snpServiceHealthLogRepository.save(healthLog); + if (previousStatus != newStatus) { - SnpServiceHealthLog healthLog = SnpServiceHealthLog.builder() - .service(service) - .previousStatus(previousStatus.name()) - .currentStatus(newStatus.name()) - .responseTime(responseTime) - .errorMessage(errorMessage) - .checkedAt(LocalDateTime.now()) - .build(); - snpServiceHealthLogRepository.save(healthLog); log.info("서비스 상태 변경 - {}: {} -> {}", service.getServiceCode(), previousStatus, newStatus); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f0cb148..a557cb6 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -56,7 +56,7 @@ logging: app: environment: dev heartbeat: - default-interval-seconds: 30 + default-interval-seconds: 60 timeout-seconds: 5 jwt: secret: c25wLWNvbm5lY3Rpb24tbW9uaXRvcmluZy1qd3Qtc2VjcmV0LWtleS0yMDI2 -- 2.45.2 From cb615dae8729e3f4d1291e700b83bdaf69fc60bc Mon Sep 17 00:00:00 2001 From: HYOJIN Date: Wed, 8 Apr 2026 13:45:05 +0900 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/RELEASE-NOTES.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 3903267..c4f5f6d 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -25,6 +25,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/ko/1.0.0/). - 요청/응답 비동기 로깅 (@Async) (#9) - 요청 로그 검색 API (JPA Specification 동적 쿼리) (#9) - 요청 로그 검색/상세 프론트엔드 페이지 (#9) +- 대시보드 통계 API (요약, 시간별/서비스별/테넌트별 통계, 에러율, 상위 API) (#10) +- 대시보드 프론트엔드 (Recharts 차트, 요약 카드, 30초 자동 갱신) (#10) +- Service Status 페이지 (90일 일별 uptime 바, status.claude.com 스타일) (#10) +- 헬스체크 1분 간격 매 체크 기록, 통계 쿼리 인덱스 최적화 (#10) ## [2026-04-07] -- 2.45.2