generated from gc/template-java-maven
Merge pull request 'feat(phase5): 대시보드 + 통계 + Service Status 페이지' (#17) from feature/ISSUE-10-phase5-dashboard into develop
This commit is contained in:
커밋
36c1f4a9ea
@ -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]
|
||||
|
||||
|
||||
@ -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 키 발급 요청)
|
||||
-- -----------------------------------------------------------
|
||||
|
||||
413
frontend/package-lock.json
generated
413
frontend/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 = () => {
|
||||
<Route path="/dashboard" element={<DashboardPage />} />
|
||||
<Route path="/monitoring/request-logs" element={<RequestLogsPage />} />
|
||||
<Route path="/monitoring/request-logs/:id" element={<RequestLogDetailPage />} />
|
||||
<Route path="/monitoring/service-status" element={<ServiceStatusPage />} />
|
||||
<Route path="/monitoring/service-status/:serviceId" element={<ServiceStatusDetailPage />} />
|
||||
<Route path="/apikeys/my-keys" element={<MyKeysPage />} />
|
||||
<Route path="/apikeys/request" element={<KeyRequestPage />} />
|
||||
<Route path="/apikeys/admin" element={<KeyAdminPage />} />
|
||||
|
||||
@ -13,6 +13,7 @@ const navGroups: NavGroup[] = [
|
||||
label: 'Monitoring',
|
||||
items: [
|
||||
{ label: 'Request Logs', path: '/monitoring/request-logs' },
|
||||
{ label: 'Service Status', path: '/monitoring/service-status' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -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<string, string> = {
|
||||
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 = <T,>(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<DashboardStats | null>(null);
|
||||
const [heartbeat, setHeartbeat] = useState<HeartbeatStatus[]>([]);
|
||||
const [hourlyTrend, setHourlyTrend] = useState<HourlyTrend[]>([]);
|
||||
const [serviceRatio, setServiceRatio] = useState<ServiceRatio[]>([]);
|
||||
const [errorTrend, setErrorTrend] = useState<ErrorTrend[]>([]);
|
||||
const [topApis, setTopApis] = useState<TopApi[]>([]);
|
||||
const [tenantRequestRatio, setTenantRequestRatio] = useState<TenantRatio[]>([]);
|
||||
const [tenantUserRatio, setTenantUserRatio] = useState<TenantRatio[]>([]);
|
||||
const [recentLogs, setRecentLogs] = useState<RequestLog[]>([]);
|
||||
const [lastUpdated, setLastUpdated] = useState<string>('');
|
||||
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<DashboardStats | null>(summaryRes, null));
|
||||
setHeartbeat(extractSettled<HeartbeatStatus[]>(heartbeatRes, []));
|
||||
setHourlyTrend(extractSettled<HourlyTrend[]>(hourlyRes, []));
|
||||
setServiceRatio(extractSettled<ServiceRatio[]>(serviceRes, []));
|
||||
setErrorTrend(extractSettled<ErrorTrend[]>(errorRes, []));
|
||||
setTopApis(extractSettled<TopApi[]>(topRes, []));
|
||||
setTenantRequestRatio(extractSettled<TenantRatio[]>(tenantReqRes, []));
|
||||
setTenantUserRatio(extractSettled<TenantRatio[]>(tenantUserRes, []));
|
||||
setRecentLogs(extractSettled<RequestLog[]>(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<number, Record<string, number>> = {};
|
||||
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<string, { tag: string; bar: string }> = {};
|
||||
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 (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||
<p className="mt-2 text-gray-600">Coming soon</p>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||
{lastUpdated && (
|
||||
<span className="text-sm text-gray-500">마지막 갱신: {lastUpdated}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 1: Summary Cards */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<p className="text-sm text-gray-500">오늘 총 요청</p>
|
||||
<p className="text-3xl font-bold">{stats.totalRequests.toLocaleString()}</p>
|
||||
<p className={`text-sm ${stats.changePercent > 0 ? 'text-green-600' : stats.changePercent < 0 ? 'text-red-600' : 'text-gray-500'}`}>
|
||||
{stats.changePercent > 0 ? '▲' : stats.changePercent < 0 ? '▼' : ''} 전일 대비 {stats.changePercent}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<p className="text-sm text-gray-500">성공률</p>
|
||||
<p className="text-3xl font-bold">{stats.successRate.toFixed(1)}%</p>
|
||||
<p className="text-sm text-red-500">실패 {stats.failureCount}건</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<p className="text-sm text-gray-500">평균 응답 시간</p>
|
||||
<p className="text-3xl font-bold">{stats.avgResponseTime.toFixed(0)}ms</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<p className="text-sm text-gray-500">활성 사용자</p>
|
||||
<p className="text-3xl font-bold">{stats.activeUserCount}</p>
|
||||
<p className="text-sm text-gray-500">오늘</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Row 2: Heartbeat Status Bar */}
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
||||
{heartbeat.length > 0 ? (
|
||||
<div className="flex flex-row gap-6">
|
||||
{heartbeat.map((svc) => (
|
||||
<div
|
||||
key={svc.serviceId}
|
||||
className="flex items-center gap-2 cursor-pointer hover:bg-gray-50 rounded-lg px-2 py-1 transition-colors"
|
||||
onClick={() => navigate('/monitoring/service-status')}
|
||||
>
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full ${
|
||||
svc.healthStatus === 'UP'
|
||||
? 'bg-green-500'
|
||||
: svc.healthStatus === 'DOWN'
|
||||
? 'bg-red-500'
|
||||
: 'bg-gray-400'
|
||||
}`}
|
||||
/>
|
||||
<span className="font-medium">{svc.serviceName}</span>
|
||||
{svc.healthResponseTime !== null && (
|
||||
<span className="text-gray-500 text-sm">{svc.healthResponseTime}ms</span>
|
||||
)}
|
||||
{svc.healthCheckedAt && (
|
||||
<span className="text-gray-400 text-xs">{svc.healthCheckedAt}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm">등록된 서비스가 없습니다</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 3: Charts 2x2 */}
|
||||
<div className="grid grid-cols-2 gap-6 mb-6">
|
||||
{/* Chart 1: Hourly Trend */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">시간별 요청 추이</h3>
|
||||
{hourlyTrend.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={hourlyTrend}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="hour" tickFormatter={(h: number) => `${h}시`} />
|
||||
<YAxis />
|
||||
<Tooltip labelFormatter={(h) => `${h}시`} />
|
||||
<Legend />
|
||||
<Line type="monotone" dataKey="successCount" stroke="#3b82f6" name="성공" />
|
||||
<Line type="monotone" dataKey="failureCount" stroke="#ef4444" name="실패" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<p className="text-gray-400 text-center py-20">데이터가 없습니다</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chart 2: Service Ratio */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">서비스별 요청 비율</h3>
|
||||
{serviceRatio.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={serviceRatio}
|
||||
dataKey="count"
|
||||
nameKey="serviceName"
|
||||
innerRadius={60}
|
||||
outerRadius={100}
|
||||
>
|
||||
{serviceRatio.map((_, idx) => (
|
||||
<Cell key={idx} fill={PIE_COLORS[idx % PIE_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend layout="vertical" align="right" verticalAlign="middle" />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<p className="text-gray-400 text-center py-20">데이터가 없습니다</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chart 3: Error Trend */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">에러율 추이</h3>
|
||||
{errorTrendPivoted.data.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={errorTrendPivoted.data}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="hour" tickFormatter={(h: number) => `${h}시`} />
|
||||
<YAxis unit="%" />
|
||||
<Tooltip labelFormatter={(h) => `${h}시`} />
|
||||
<Legend />
|
||||
{errorTrendPivoted.serviceNames.map((name, idx) => (
|
||||
<Area
|
||||
key={name}
|
||||
type="monotone"
|
||||
dataKey={name}
|
||||
stackId="1"
|
||||
stroke={PIE_COLORS[idx % PIE_COLORS.length]}
|
||||
fill={PIE_COLORS[idx % PIE_COLORS.length]}
|
||||
fillOpacity={0.3}
|
||||
/>
|
||||
))}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<p className="text-gray-400 text-center py-20">데이터가 없습니다</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chart 4: Top APIs */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">상위 호출 API</h3>
|
||||
{topApis.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{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 (
|
||||
<div key={idx} className="flex items-center gap-3">
|
||||
<span className="text-xs text-gray-400 w-5 text-right">{idx + 1}</span>
|
||||
<span className={`shrink-0 px-1.5 py-0.5 rounded text-xs font-medium ${colors.tag}`}>
|
||||
{api.serviceName}
|
||||
</span>
|
||||
<span className="shrink-0 text-sm text-gray-900 w-48 truncate" title={api.apiName}>
|
||||
{api.apiName}
|
||||
</span>
|
||||
<div className="flex-1 bg-gray-100 rounded-full h-5 relative">
|
||||
<div
|
||||
className="h-5 rounded-full"
|
||||
style={{ width: `${pct}%`, backgroundColor: colors.bar }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-700 w-12 text-right">{api.count}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-400 text-center py-20">데이터가 없습니다</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 4: Tenant Stats */}
|
||||
<div className="grid grid-cols-2 gap-6 mb-6">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">테넌트별 요청 비율</h3>
|
||||
{tenantRequestRatio.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={tenantRequestRatio}
|
||||
dataKey="count"
|
||||
nameKey="tenantName"
|
||||
innerRadius={60}
|
||||
outerRadius={100}
|
||||
>
|
||||
{tenantRequestRatio.map((_, idx) => (
|
||||
<Cell key={idx} fill={PIE_COLORS[idx % PIE_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend layout="vertical" align="right" verticalAlign="middle" />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<p className="text-gray-400 text-center py-20">데이터가 없습니다</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">테넌트별 사용자 비율</h3>
|
||||
{tenantUserRatio.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={tenantUserRatio}
|
||||
dataKey="count"
|
||||
nameKey="tenantName"
|
||||
innerRadius={60}
|
||||
outerRadius={100}
|
||||
>
|
||||
{tenantUserRatio.map((_, idx) => (
|
||||
<Cell key={idx} fill={PIE_COLORS[idx % PIE_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend layout="vertical" align="right" verticalAlign="middle" />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<p className="text-gray-400 text-center py-20">데이터가 없습니다</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 5: Recent Logs */}
|
||||
<div className="bg-white rounded-lg shadow mb-6">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-900">최근 요청 로그</h3>
|
||||
</div>
|
||||
{recentLogs.length > 0 ? (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">시간</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">서비스</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">사용자</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">URL</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">상태</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">응답시간</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{recentLogs.slice(0, 5).map((log) => (
|
||||
<tr
|
||||
key={log.logId}
|
||||
className="hover:bg-gray-50 cursor-pointer"
|
||||
onClick={() => navigate(`/monitoring/request-logs/${log.logId}`)}
|
||||
>
|
||||
<td className="px-4 py-3 text-gray-600 whitespace-nowrap">{log.requestedAt}</td>
|
||||
<td className="px-4 py-3 text-gray-900">{log.serviceName ?? '-'}</td>
|
||||
<td className="px-4 py-3 text-gray-900">{log.userName ?? '-'}</td>
|
||||
<td className="px-4 py-3 text-gray-900" title={log.requestUrl}>
|
||||
{truncate(log.requestUrl, 40)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${STATUS_BADGE[log.requestStatus] ?? 'bg-gray-100 text-gray-800'}`}>
|
||||
{log.requestStatus}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600">
|
||||
{log.responseTime !== null ? `${log.responseTime}ms` : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="px-6 py-3 border-t text-center">
|
||||
<button
|
||||
onClick={() => navigate('/monitoring/request-logs')}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 font-medium"
|
||||
>
|
||||
더보기 →
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-gray-400 text-center py-8">요청 로그가 없습니다</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -14,13 +14,15 @@ const METHOD_COLOR: Record<string, string> = {
|
||||
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
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;
|
||||
|
||||
|
||||
179
frontend/src/pages/monitoring/ServiceStatusDetailPage.tsx
Normal file
179
frontend/src/pages/monitoring/ServiceStatusDetailPage.tsx
Normal file
@ -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<string, string> = {
|
||||
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<ServiceStatusDetail | null>(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 <div className="text-center py-20 text-gray-500">로딩 중...</div>;
|
||||
}
|
||||
|
||||
if (!detail) {
|
||||
return <div className="text-center py-20 text-gray-500">서비스를 찾을 수 없습니다</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<button
|
||||
onClick={() => navigate('/monitoring/service-status')}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 mb-4 inline-block"
|
||||
>
|
||||
← Status 목록으로
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-4 h-4 rounded-full ${STATUS_COLOR[detail.currentStatus] || 'bg-gray-400'}`} />
|
||||
<h1 className="text-2xl font-bold text-gray-900">{detail.serviceName}</h1>
|
||||
<span className="text-gray-500">{detail.serviceCode}</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className={`text-lg font-semibold ${detail.currentStatus === 'UP' ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{detail.currentStatus === 'UP' ? 'Operational' : detail.currentStatus === 'DOWN' ? 'Down' : 'Unknown'}
|
||||
</div>
|
||||
{detail.lastResponseTime !== null && (
|
||||
<div className="text-sm text-gray-500">{detail.lastResponseTime}ms</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Uptime Summary */}
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">90일 Uptime</h2>
|
||||
<div className="text-4xl font-bold text-gray-900 mb-4">{detail.uptimePercent90d.toFixed(3)}%</div>
|
||||
|
||||
{/* 90-Day Bar */}
|
||||
<div className="flex items-center gap-0.5 mb-2">
|
||||
{detail.dailyUptime.map((day, idx) => (
|
||||
<div key={idx} className="group relative flex-1">
|
||||
<div
|
||||
className={`h-10 rounded-sm ${getUptimeBarColor(day.uptimePercent)} hover:opacity-80 transition-opacity`}
|
||||
/>
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:block z-10">
|
||||
<div className="bg-gray-900 text-white text-xs rounded px-3 py-2 whitespace-nowrap shadow-lg">
|
||||
<div className="font-medium">{formatDate(day.date)}</div>
|
||||
<div>Uptime: {day.uptimePercent.toFixed(1)}%</div>
|
||||
<div>Checks: {day.upChecks}/{day.totalChecks}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{detail.dailyUptime.length === 0 && (
|
||||
<div className="flex-1 h-10 bg-gray-100 rounded-sm" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-gray-400">
|
||||
<span>{detail.dailyUptime.length > 0 ? formatDate(detail.dailyUptime[0].date) : ''}</span>
|
||||
<span>Today</span>
|
||||
</div>
|
||||
|
||||
{/* Daily Uptime Legend */}
|
||||
<div className="flex items-center gap-4 mt-4 text-xs text-gray-500">
|
||||
<div className="flex items-center gap-1"><div className="w-3 h-3 rounded-sm bg-green-500" /> 99.9%+</div>
|
||||
<div className="flex items-center gap-1"><div className="w-3 h-3 rounded-sm bg-green-400" /> 99%+</div>
|
||||
<div className="flex items-center gap-1"><div className="w-3 h-3 rounded-sm bg-yellow-400" /> 95%+</div>
|
||||
<div className="flex items-center gap-1"><div className="w-3 h-3 rounded-sm bg-orange-400" /> 90%+</div>
|
||||
<div className="flex items-center gap-1"><div className="w-3 h-3 rounded-sm bg-red-500" /> <90%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Checks */}
|
||||
<div className="bg-white rounded-lg shadow mb-6">
|
||||
<div className="p-6 border-b">
|
||||
<h2 className="text-lg font-semibold text-gray-900">최근 체크 이력</h2>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">시간</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">상태</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">응답시간</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">에러</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{detail.recentChecks.map((check, idx) => (
|
||||
<tr key={idx} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-gray-600 whitespace-nowrap">{formatTime(check.checkedAt)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2.5 h-2.5 rounded-full ${STATUS_COLOR[check.status] || 'bg-gray-400'}`} />
|
||||
<span className={check.status === 'UP' ? 'text-green-700' : 'text-red-700'}>{check.status}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600">
|
||||
{check.responseTime !== null ? `${check.responseTime}ms` : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-red-600 text-xs max-w-xs truncate">
|
||||
{check.errorMessage || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{detail.recentChecks.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-8 text-center text-gray-400">
|
||||
체크 이력이 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServiceStatusDetailPage;
|
||||
151
frontend/src/pages/monitoring/ServiceStatusPage.tsx
Normal file
151
frontend/src/pages/monitoring/ServiceStatusPage.tsx
Normal file
@ -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<string, string> = {
|
||||
UP: 'bg-green-500',
|
||||
DOWN: 'bg-red-500',
|
||||
UNKNOWN: 'bg-gray-400',
|
||||
};
|
||||
|
||||
const STATUS_TEXT: Record<string, string> = {
|
||||
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<ServiceStatusDetail[]>([]);
|
||||
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 <div className="text-center py-20 text-gray-500">로딩 중...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Service Status</h1>
|
||||
<span className="text-sm text-gray-500">마지막 갱신: {lastUpdated}</span>
|
||||
</div>
|
||||
|
||||
{/* Overall Status Banner */}
|
||||
<div className={`rounded-lg p-6 mb-8 ${allOperational ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-4 h-4 rounded-full ${allOperational ? 'bg-green-500' : 'bg-red-500'}`} />
|
||||
<span className={`text-lg font-semibold ${allOperational ? 'text-green-800' : 'text-red-800'}`}>
|
||||
{allOperational ? 'All Systems Operational' : 'Some Systems Down'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Service List */}
|
||||
<div className="space-y-6">
|
||||
{services.map((svc) => (
|
||||
<div key={svc.serviceId} className="bg-white rounded-lg shadow">
|
||||
{/* Service Header */}
|
||||
<div className="p-6 border-b">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-3 h-3 rounded-full ${STATUS_COLOR[svc.currentStatus] || 'bg-gray-400'}`} />
|
||||
<h2
|
||||
className="text-lg font-semibold text-gray-900 cursor-pointer hover:text-blue-600"
|
||||
onClick={() => navigate(`/monitoring/service-status/${svc.serviceId}`)}
|
||||
>
|
||||
{svc.serviceName}
|
||||
</h2>
|
||||
<span className="text-sm text-gray-500">{svc.serviceCode}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className={`text-sm font-medium ${svc.currentStatus === 'UP' ? 'text-green-600' : svc.currentStatus === 'DOWN' ? 'text-red-600' : 'text-gray-500'}`}>
|
||||
{STATUS_TEXT[svc.currentStatus] || svc.currentStatus}
|
||||
</span>
|
||||
{svc.lastResponseTime !== null && (
|
||||
<span className="text-sm text-gray-400">{svc.lastResponseTime}ms</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-500">
|
||||
90일 Uptime: <span className="font-medium text-gray-900">{svc.uptimePercent90d.toFixed(2)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 90-Day Uptime Bar */}
|
||||
<div className="px-6 py-4">
|
||||
<div className="flex items-center gap-0.5">
|
||||
{svc.dailyUptime.length > 0 ? (
|
||||
svc.dailyUptime.map((day, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="group relative flex-1"
|
||||
>
|
||||
<div
|
||||
className={`h-8 rounded-sm ${getUptimeBarColor(day.uptimePercent)} hover:opacity-80 transition-opacity`}
|
||||
title={`${formatDate(day.date)}: ${day.uptimePercent.toFixed(1)}% (${day.upChecks}/${day.totalChecks})`}
|
||||
/>
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:block z-10">
|
||||
<div className="bg-gray-900 text-white text-xs rounded px-2 py-1 whitespace-nowrap">
|
||||
{formatDate(day.date)}: {day.uptimePercent.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex-1 h-8 bg-gray-100 rounded-sm" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between mt-1 text-xs text-gray-400">
|
||||
<span>{svc.dailyUptime.length > 0 ? formatDate(svc.dailyUptime[0].date) : ''}</span>
|
||||
<span>Today</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{services.length === 0 && (
|
||||
<div className="text-center py-20 text-gray-400">등록된 서비스가 없습니다</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServiceStatusPage;
|
||||
14
frontend/src/services/dashboardService.ts
Normal file
14
frontend/src/services/dashboardService.ts
Normal file
@ -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<DashboardStats>('/dashboard/summary');
|
||||
export const getHourlyTrend = () => get<HourlyTrend[]>('/dashboard/hourly-trend');
|
||||
export const getServiceRatio = () => get<ServiceRatio[]>('/dashboard/service-ratio');
|
||||
export const getErrorTrend = () => get<ErrorTrend[]>('/dashboard/error-trend');
|
||||
export const getTopApis = (limit = 10) => get<TopApi[]>(`/dashboard/top-apis?limit=${limit}`);
|
||||
export const getTenantRequestRatio = () => get<TenantRatio[]>('/dashboard/tenant-request-ratio');
|
||||
export const getTenantUserRatio = () => get<TenantRatio[]>('/dashboard/tenant-user-ratio');
|
||||
export const getRecentLogs = () => get<RequestLog[]>('/dashboard/recent-logs');
|
||||
export const getHeartbeat = () => get<HeartbeatStatus[]>('/dashboard/heartbeat');
|
||||
@ -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<HeartbeatStatus[]>('/heartbeat/status');
|
||||
export const getHealthHistory = (serviceId: number) => get<HealthHistory[]>(`/heartbeat/${serviceId}/history`);
|
||||
export const triggerCheck = (serviceId: number) => post<HeartbeatStatus>(`/heartbeat/${serviceId}/check`);
|
||||
export const getAllStatusDetail = () => get<ServiceStatusDetail[]>('/heartbeat/status/detail');
|
||||
export const getServiceStatusDetail = (serviceId: number) => get<ServiceStatusDetail>(`/heartbeat/${serviceId}/status/detail`);
|
||||
|
||||
39
frontend/src/types/dashboard.ts
Normal file
39
frontend/src/types/dashboard.ts
Normal file
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<ApiResponse<DashboardStatsResponse>> getSummary() {
|
||||
DashboardStatsResponse summary = dashboardService.getSummary();
|
||||
return ResponseEntity.ok(ApiResponse.ok(summary));
|
||||
}
|
||||
|
||||
/**
|
||||
* 시간별 요청 추이 조회
|
||||
*/
|
||||
@GetMapping("/hourly-trend")
|
||||
public ResponseEntity<ApiResponse<List<HourlyTrendResponse>>> getHourlyTrend() {
|
||||
List<HourlyTrendResponse> trend = dashboardService.getHourlyTrend();
|
||||
return ResponseEntity.ok(ApiResponse.ok(trend));
|
||||
}
|
||||
|
||||
/**
|
||||
* 서비스별 요청 비율 조회
|
||||
*/
|
||||
@GetMapping("/service-ratio")
|
||||
public ResponseEntity<ApiResponse<List<ServiceRatioResponse>>> getServiceRatio() {
|
||||
List<ServiceRatioResponse> ratio = dashboardService.getServiceRatio();
|
||||
return ResponseEntity.ok(ApiResponse.ok(ratio));
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러율 추이 조회 (시간별, 서비스별)
|
||||
*/
|
||||
@GetMapping("/error-trend")
|
||||
public ResponseEntity<ApiResponse<List<ErrorTrendResponse>>> getErrorTrend() {
|
||||
List<ErrorTrendResponse> trend = dashboardService.getErrorTrend();
|
||||
return ResponseEntity.ok(ApiResponse.ok(trend));
|
||||
}
|
||||
|
||||
/**
|
||||
* 상위 API 랭킹 조회
|
||||
*/
|
||||
@GetMapping("/top-apis")
|
||||
public ResponseEntity<ApiResponse<List<TopApiResponse>>> getTopApis(
|
||||
@RequestParam(defaultValue = "10") int limit) {
|
||||
List<TopApiResponse> topApis = dashboardService.getTopApis(limit);
|
||||
return ResponseEntity.ok(ApiResponse.ok(topApis));
|
||||
}
|
||||
|
||||
/**
|
||||
* 테넌트별 요청 비율 조회
|
||||
*/
|
||||
@GetMapping("/tenant-request-ratio")
|
||||
public ResponseEntity<ApiResponse<List<TenantRatioResponse>>> getTenantRequestRatio() {
|
||||
List<TenantRatioResponse> ratio = dashboardService.getTenantRequestRatio();
|
||||
return ResponseEntity.ok(ApiResponse.ok(ratio));
|
||||
}
|
||||
|
||||
/**
|
||||
* 테넌트별 사용자 비율 조회
|
||||
*/
|
||||
@GetMapping("/tenant-user-ratio")
|
||||
public ResponseEntity<ApiResponse<List<TenantRatioResponse>>> getTenantUserRatio() {
|
||||
List<TenantRatioResponse> ratio = dashboardService.getTenantUserRatio();
|
||||
return ResponseEntity.ok(ApiResponse.ok(ratio));
|
||||
}
|
||||
|
||||
/**
|
||||
* 최근 요청 로그 조회
|
||||
*/
|
||||
@GetMapping("/recent-logs")
|
||||
public ResponseEntity<ApiResponse<List<RequestLogResponse>>> getRecentLogs() {
|
||||
List<RequestLogResponse> logs = dashboardService.getRecentLogs();
|
||||
return ResponseEntity.ok(ApiResponse.ok(logs));
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 서비스 헬스 상태 조회 (하트비트)
|
||||
*/
|
||||
@GetMapping("/heartbeat")
|
||||
public ResponseEntity<ApiResponse<List<HeartbeatStatusResponse>>> getHeartbeat() {
|
||||
List<HeartbeatStatusResponse> statuses = heartbeatService.getAllServiceStatus();
|
||||
return ResponseEntity.ok(ApiResponse.ok(statuses));
|
||||
}
|
||||
}
|
||||
@ -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<ApiResponse<List<ServiceStatusDetailResponse>>> getAllStatusDetail() {
|
||||
List<ServiceStatusDetailResponse> details = heartbeatService.getAllServiceStatusDetail();
|
||||
return ResponseEntity.ok(ApiResponse.ok(details));
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 서비스 상태 상세 (status page)
|
||||
*/
|
||||
@GetMapping("/{serviceId}/status/detail")
|
||||
public ResponseEntity<ApiResponse<ServiceStatusDetailResponse>> getServiceStatusDetail(
|
||||
@PathVariable Long serviceId) {
|
||||
ServiceStatusDetailResponse detail = heartbeatService.getServiceStatusDetail(serviceId);
|
||||
return ResponseEntity.ok(ApiResponse.ok(detail));
|
||||
}
|
||||
|
||||
/**
|
||||
* 서비스 헬스체크 이력 조회
|
||||
*/
|
||||
|
||||
@ -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
|
||||
) {}
|
||||
@ -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
|
||||
) {}
|
||||
@ -0,0 +1,3 @@
|
||||
package com.gcsc.connection.monitoring.dto;
|
||||
|
||||
public record ErrorTrendResponse(int hour, String serviceName, double errorRate) {}
|
||||
@ -0,0 +1,3 @@
|
||||
package com.gcsc.connection.monitoring.dto;
|
||||
|
||||
public record HourlyTrendResponse(int hour, long successCount, long failureCount) {}
|
||||
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
package com.gcsc.connection.monitoring.dto;
|
||||
|
||||
public record ServiceRatioResponse(String serviceName, long count) {}
|
||||
@ -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<DailyUptimeResponse> dailyUptime,
|
||||
List<RecentCheckResponse> recentChecks
|
||||
) {}
|
||||
@ -0,0 +1,3 @@
|
||||
package com.gcsc.connection.monitoring.dto;
|
||||
|
||||
public record TenantRatioResponse(String tenantName, long count) {}
|
||||
@ -0,0 +1,3 @@
|
||||
package com.gcsc.connection.monitoring.dto;
|
||||
|
||||
public record TopApiResponse(String serviceName, String apiName, String requestUrl, String requestMethod, long count) {}
|
||||
@ -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<SnpApiRequestLog, Long>,
|
||||
JpaSpecificationExecutor<SnpApiRequestLog> {
|
||||
|
||||
/** 오늘 요약: 총 요청, 성공 건수, 평균 응답시간 */
|
||||
@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<Object[]> 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<Object[]> 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<Object[]> 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<Object[]> 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<Object[]> 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<Object[]> findTenantUserRatio(@Param("startOfDay") LocalDateTime startOfDay);
|
||||
|
||||
/** 최근 로그 20건 */
|
||||
List<SnpApiRequestLog> findTop20ByOrderByRequestedAtDesc();
|
||||
}
|
||||
|
||||
@ -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<SnpServiceHealthLog, Long> {
|
||||
|
||||
List<SnpServiceHealthLog> findByServiceServiceIdOrderByCheckedAtDesc(Long serviceId);
|
||||
|
||||
List<SnpServiceHealthLog> 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<Object[]> 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);
|
||||
}
|
||||
|
||||
@ -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<HourlyTrendResponse> getHourlyTrend() {
|
||||
LocalDateTime startOfDay = LocalDate.now().atStartOfDay();
|
||||
List<Object[]> rows = snpApiRequestLogRepository.findHourlyTrend(startOfDay);
|
||||
|
||||
Map<Integer, long[]> 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<HourlyTrendResponse> 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<ServiceRatioResponse> 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<ErrorTrendResponse> 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<TopApiResponse> 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<TenantRatioResponse> 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<TenantRatioResponse> 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<RequestLogResponse> getRecentLogs() {
|
||||
return snpApiRequestLogRepository.findTop20ByOrderByRequestedAtDesc().stream()
|
||||
.map(RequestLogResponse::from)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@ -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<Object[]> dailyRows = snpServiceHealthLogRepository.findDailyUptime(serviceId, since90d);
|
||||
List<DailyUptimeResponse> 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<RecentCheckResponse> 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<ServiceStatusDetailResponse> 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);
|
||||
}
|
||||
|
||||
|
||||
@ -56,7 +56,7 @@ logging:
|
||||
app:
|
||||
environment: dev
|
||||
heartbeat:
|
||||
default-interval-seconds: 30
|
||||
default-interval-seconds: 60
|
||||
timeout-seconds: 5
|
||||
jwt:
|
||||
secret: c25wLWNvbm5lY3Rpb24tbW9uaXRvcmluZy1qd3Qtc2VjcmV0LWtleS0yMDI2
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user