diff --git a/CLAUDE.md b/CLAUDE.md index 4c0fedf..200cbb5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ API Gateway + 모니터링 통합 플랫폼. 모든 서비스 사용자가 모 ## 기술 스택 - Java 17, Spring Boot 3.2.1, Spring Data JPA -- PostgreSQL (스키마: std_snp_connection) +- PostgreSQL (DB: snp_connection, 스키마: common) - Spring Security (JWT 기반 인증 예정) - WebFlux WebClient (Heartbeat, Gateway Proxy) - Springdoc OpenAPI 2.3.0 (Swagger) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 83bc788..f2a4748 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -6,6 +6,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/ko/1.0.0/). ## [Unreleased] +## [2026-04-08] + +### 추가 + +- JPA Entity 9개 + Repository, JWT 인증, 프론트엔드 메인 레이아웃/로그인 (#6) +- 테넌트/사용자/서비스 CRUD API + 하트비트 스케줄러 (#7) +- API Key AES-256-GCM 암호화, 신청→승인 워크플로우, Permission 관리 (#8) +- API Gateway 프록시, API Key 인증 필터, 비동기 요청 로깅 (#9) +- 대시보드 통계 (Recharts 차트 6개, 요약 카드, 30초 자동 갱신) (#10) +- Service Status 페이지 (90일 일별 uptime, status.claude.com 스타일) (#10) +- 테넌트별 요청/사용자 비율 통계 (#10) +- 프론트엔드: 관리 페이지, API Key 관리, 요청 로그 검색/상세 (#7~#10) + ## [2026-04-07] ### 추가 diff --git a/docs/schema/create_tables.sql b/docs/schema/create_tables.sql new file mode 100644 index 0000000..df0af70 --- /dev/null +++ b/docs/schema/create_tables.sql @@ -0,0 +1,195 @@ +-- ============================================================= +-- SNP Connection Monitoring - 테이블 생성 스크립트 +-- 스키마: common +-- ============================================================= + +CREATE SCHEMA IF NOT EXISTS common; +SET search_path TO common; + +-- ----------------------------------------------------------- +-- 1. snp_tenant (테넌트) +-- ----------------------------------------------------------- +CREATE TABLE IF NOT EXISTS snp_tenant ( + tenant_id BIGSERIAL PRIMARY KEY, + tenant_code VARCHAR(50) NOT NULL UNIQUE, + tenant_name VARCHAR(200) NOT NULL, + description TEXT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_snp_tenant_code ON snp_tenant (tenant_code); +CREATE INDEX idx_snp_tenant_active ON snp_tenant (is_active); + +-- ----------------------------------------------------------- +-- 2. snp_user (사용자) +-- ----------------------------------------------------------- +CREATE TABLE IF NOT EXISTS snp_user ( + user_id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT REFERENCES snp_tenant(tenant_id), + login_id VARCHAR(100) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + user_name VARCHAR(100) NOT NULL, + email VARCHAR(200), + role VARCHAR(20) NOT NULL DEFAULT 'USER', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + last_login_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_snp_user_login_id ON snp_user (login_id); +CREATE INDEX idx_snp_user_tenant ON snp_user (tenant_id); +CREATE INDEX idx_snp_user_role ON snp_user (role); + +-- ----------------------------------------------------------- +-- 3. snp_service (서비스) +-- ----------------------------------------------------------- +CREATE TABLE IF NOT EXISTS snp_service ( + service_id BIGSERIAL PRIMARY KEY, + service_code VARCHAR(50) NOT NULL UNIQUE, + service_name VARCHAR(200) NOT NULL, + service_url VARCHAR(500), + description TEXT, + health_check_url VARCHAR(500), + health_check_interval INTEGER NOT NULL DEFAULT 30, + health_status VARCHAR(10) NOT NULL DEFAULT 'UNKNOWN', + health_checked_at TIMESTAMP, + health_response_time INTEGER, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_snp_service_code ON snp_service (service_code); +CREATE INDEX idx_snp_service_status ON snp_service (health_status); +CREATE INDEX idx_snp_service_active ON snp_service (is_active); + +-- ----------------------------------------------------------- +-- 4. snp_service_health_log (서비스 헬스 로그) +-- ----------------------------------------------------------- +CREATE TABLE IF NOT EXISTS snp_service_health_log ( + log_id BIGSERIAL PRIMARY KEY, + service_id BIGINT NOT NULL REFERENCES snp_service(service_id), + previous_status VARCHAR(10), + current_status VARCHAR(10) NOT NULL, + response_time INTEGER, + error_message TEXT, + checked_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_snp_health_log_service ON snp_service_health_log (service_id); +CREATE INDEX idx_snp_health_log_checked ON snp_service_health_log (checked_at); + +-- ----------------------------------------------------------- +-- 5. snp_service_api (서비스 API) +-- ----------------------------------------------------------- +CREATE TABLE IF NOT EXISTS snp_service_api ( + api_id BIGSERIAL PRIMARY KEY, + service_id BIGINT NOT NULL REFERENCES snp_service(service_id), + api_path VARCHAR(500) NOT NULL, + api_method VARCHAR(10) NOT NULL, + api_name VARCHAR(200) NOT NULL, + description TEXT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT uq_service_api UNIQUE (service_id, api_path, api_method) +); + +CREATE INDEX idx_snp_service_api_service ON snp_service_api (service_id); + +-- ----------------------------------------------------------- +-- 6. snp_api_key (API 키) +-- ----------------------------------------------------------- +CREATE TABLE IF NOT EXISTS snp_api_key ( + api_key_id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES snp_user(user_id), + api_key VARCHAR(128) NOT NULL UNIQUE, + api_key_prefix VARCHAR(10), + key_name VARCHAR(200) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + approved_by BIGINT REFERENCES snp_user(user_id), + approved_at TIMESTAMP, + expires_at TIMESTAMP, + last_used_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_snp_api_key_user ON snp_api_key (user_id); +CREATE INDEX idx_snp_api_key_key ON snp_api_key (api_key); +CREATE INDEX idx_snp_api_key_status ON snp_api_key (status); + +-- ----------------------------------------------------------- +-- 7. snp_api_permission (API 권한) +-- ----------------------------------------------------------- +CREATE TABLE IF NOT EXISTS snp_api_permission ( + permission_id BIGSERIAL PRIMARY KEY, + api_key_id BIGINT NOT NULL REFERENCES snp_api_key(api_key_id), + api_id BIGINT NOT NULL REFERENCES snp_service_api(api_id), + is_active BOOLEAN NOT NULL DEFAULT TRUE, + granted_by BIGINT, + granted_at TIMESTAMP NOT NULL DEFAULT NOW(), + revoked_at TIMESTAMP, + CONSTRAINT uq_api_permission UNIQUE (api_key_id, api_id) +); + +CREATE INDEX idx_snp_api_permission_key ON snp_api_permission (api_key_id); +CREATE INDEX idx_snp_api_permission_api ON snp_api_permission (api_id); + +-- ----------------------------------------------------------- +-- 8. snp_api_request_log (API 요청 로그) +-- ----------------------------------------------------------- +CREATE TABLE IF NOT EXISTS snp_api_request_log ( + log_id BIGSERIAL PRIMARY KEY, + request_url VARCHAR(2000), + request_params TEXT, + request_method VARCHAR(10), + request_status VARCHAR(20), + request_headers TEXT, + request_ip VARCHAR(45), + service_id BIGINT REFERENCES snp_service(service_id), + user_id BIGINT REFERENCES snp_user(user_id), + api_key_id BIGINT REFERENCES snp_api_key(api_key_id), + response_size BIGINT, + response_time INTEGER, + response_status INTEGER, + error_message TEXT, + requested_at TIMESTAMP NOT NULL DEFAULT NOW(), + tenant_id BIGINT REFERENCES snp_tenant(tenant_id) +); + +CREATE INDEX idx_snp_request_log_service ON snp_api_request_log (service_id); +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 키 발급 요청) +-- ----------------------------------------------------------- +CREATE TABLE IF NOT EXISTS snp_api_key_request ( + request_id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES snp_user(user_id), + key_name VARCHAR(200) NOT NULL, + purpose TEXT, + requested_apis TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + reviewed_by BIGINT REFERENCES snp_user(user_id), + reviewed_at TIMESTAMP, + review_comment TEXT, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_snp_key_request_user ON snp_api_key_request (user_id); +CREATE INDEX idx_snp_key_request_status ON snp_api_key_request (status); diff --git a/docs/schema/initial_data.sql b/docs/schema/initial_data.sql new file mode 100644 index 0000000..8c1d137 --- /dev/null +++ b/docs/schema/initial_data.sql @@ -0,0 +1,24 @@ +-- ============================================================= +-- SNP Connection Monitoring - 초기 데이터 +-- 스키마: common +-- ============================================================= + +SET search_path TO common; + +-- 기본 테넌트 +INSERT INTO snp_tenant (tenant_code, tenant_name, description, is_active) +VALUES ('DEFAULT', '기본 테넌트', 'SNP Connection Monitoring 기본 테넌트', TRUE) +ON CONFLICT (tenant_code) DO NOTHING; + +-- 관리자 계정 (password: admin123, BCrypt 해시) +INSERT INTO snp_user (tenant_id, login_id, password_hash, user_name, email, role, is_active) +VALUES ( + (SELECT tenant_id FROM snp_tenant WHERE tenant_code = 'DEFAULT'), + 'admin', + '$2b$10$res6.RkRwakcEui0XbCpOOxYzwQiT07/J0Jl4cKlMtaDZFRyDt1EC', + '시스템 관리자', + 'admin@gcsc.com', + 'ADMIN', + TRUE +) +ON CONFLICT (login_id) DO NOTHING; 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 ce1211a..8ce7397 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,26 +1,54 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import AuthProvider from './store/AuthContext'; +import AuthLayout from './layouts/AuthLayout'; +import MainLayout from './layouts/MainLayout'; +import ProtectedRoute from './components/ProtectedRoute'; +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'; +import ServicesPage from './pages/admin/ServicesPage'; +import UsersPage from './pages/admin/UsersPage'; +import TenantsPage from './pages/admin/TenantsPage'; +import NotFoundPage from './pages/NotFoundPage'; const BASE_PATH = '/snp-connection'; -function App() { +const App = () => { return ( - - } /> - -
-

SNP Connection Monitoring

-

Dashboard - Coming Soon

-
- - } - /> -
+ + + }> + } /> + + + }> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + +
); -} +}; export default App; diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..6a3d913 --- /dev/null +++ b/frontend/src/components/ProtectedRoute.tsx @@ -0,0 +1,22 @@ +import { Navigate, Outlet } from 'react-router-dom'; +import { useAuth } from '../hooks/useAuth'; + +const ProtectedRoute = () => { + const { isAuthenticated, isLoading } = useAuth(); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (!isAuthenticated) { + return ; + } + + return ; +}; + +export default ProtectedRoute; diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts new file mode 100644 index 0000000..68173a3 --- /dev/null +++ b/frontend/src/hooks/useAuth.ts @@ -0,0 +1,8 @@ +import { useContext } from 'react'; +import { AuthContext } from '../store/AuthContext'; + +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) throw new Error('useAuth must be used within AuthProvider'); + return context; +}; diff --git a/frontend/src/layouts/AuthLayout.tsx b/frontend/src/layouts/AuthLayout.tsx new file mode 100644 index 0000000..6819e6a --- /dev/null +++ b/frontend/src/layouts/AuthLayout.tsx @@ -0,0 +1,11 @@ +import { Outlet } from 'react-router-dom'; + +const AuthLayout = () => { + return ( +
+ +
+ ); +}; + +export default AuthLayout; diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx new file mode 100644 index 0000000..2930151 --- /dev/null +++ b/frontend/src/layouts/MainLayout.tsx @@ -0,0 +1,167 @@ +import { useState } from 'react'; +import { Outlet, NavLink } from 'react-router-dom'; +import { useAuth } from '../hooks/useAuth'; + +interface NavGroup { + label: string; + items: { label: string; path: string }[]; + adminOnly?: boolean; +} + +const navGroups: NavGroup[] = [ + { + label: 'Monitoring', + items: [ + { label: 'Request Logs', path: '/monitoring/request-logs' }, + { label: 'Service Status', path: '/monitoring/service-status' }, + ], + }, + { + label: 'API Keys', + items: [ + { label: 'My Keys', path: '/apikeys/my-keys' }, + { label: 'Request', path: '/apikeys/request' }, + { label: 'Admin', path: '/apikeys/admin' }, + ], + }, + { + label: 'Admin', + adminOnly: true, + items: [ + { label: 'Services', path: '/admin/services' }, + { label: 'Users', path: '/admin/users' }, + { label: 'Tenants', path: '/admin/tenants' }, + ], + }, +]; + +const MainLayout = () => { + const { user, logout } = useAuth(); + const [openGroups, setOpenGroups] = useState>({ + Monitoring: true, + 'API Keys': true, + Admin: true, + }); + + const toggleGroup = (label: string) => { + setOpenGroups((prev) => ({ ...prev, [label]: !prev[label] })); + }; + + const handleLogout = async () => { + await logout(); + }; + + const isAdminOrManager = user?.role === 'ADMIN' || user?.role === 'MANAGER'; + + return ( +
+ {/* Sidebar */} + + + {/* Main Content */} +
+ {/* Header */} +
+
+
+ {user?.userName} + + {user?.role} + + +
+
+ + {/* Content */} +
+ +
+
+
+ ); +}; + +export default MainLayout; diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx new file mode 100644 index 0000000..8698898 --- /dev/null +++ b/frontend/src/pages/DashboardPage.tsx @@ -0,0 +1,434 @@ +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 ( +
+ {/* 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` : '-'} +
+
+
+ +
+ + ) : ( +

요청 로그가 없습니다

+ )} +
+
+ ); +}; + +export default DashboardPage; diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..0a0a44c --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -0,0 +1,93 @@ +import { useState } from 'react'; +import type { FormEvent } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../hooks/useAuth'; + +const LoginPage = () => { + const navigate = useNavigate(); + const { login } = useAuth(); + const [loginId, setLoginId] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(''); + + if (!loginId.trim() || !password.trim()) { + setError('아이디와 비밀번호를 입력해주세요.'); + return; + } + + setIsSubmitting(true); + try { + await login(loginId, password); + navigate('/dashboard', { replace: true }); + } catch (err) { + if (err instanceof Error) { + setError(err.message); + } else { + setError('로그인에 실패했습니다.'); + } + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+

+ SNP Connection Monitoring +

+ +
+
+ + setLoginId(e.target.value)} + className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + placeholder="아이디를 입력하세요" + autoComplete="username" + /> +
+ +
+ + setPassword(e.target.value)} + className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + placeholder="비밀번호를 입력하세요" + autoComplete="current-password" + /> +
+ + {error && ( +

{error}

+ )} + + +
+
+
+ ); +}; + +export default LoginPage; diff --git a/frontend/src/pages/NotFoundPage.tsx b/frontend/src/pages/NotFoundPage.tsx new file mode 100644 index 0000000..5dc4188 --- /dev/null +++ b/frontend/src/pages/NotFoundPage.tsx @@ -0,0 +1,18 @@ +import { Link } from 'react-router-dom'; + +const NotFoundPage = () => { + return ( +
+

404 - Page Not Found

+

요청하신 페이지를 찾을 수 없습니다.

+ + Dashboard로 이동 + +
+ ); +}; + +export default NotFoundPage; diff --git a/frontend/src/pages/admin/ServicesPage.tsx b/frontend/src/pages/admin/ServicesPage.tsx new file mode 100644 index 0000000..256fd2f --- /dev/null +++ b/frontend/src/pages/admin/ServicesPage.tsx @@ -0,0 +1,575 @@ +import { useState, useEffect } from 'react'; +import type { + ServiceInfo, + ServiceApi, + CreateServiceRequest, + UpdateServiceRequest, + CreateServiceApiRequest, +} from '../../types/service'; +import { + getServices, + createService, + updateService, + getServiceApis, + createServiceApi, +} from '../../services/serviceService'; + +const HEALTH_BADGE: Record = { + UP: { dot: 'bg-green-500', bg: 'bg-green-100', text: 'text-green-800' }, + DOWN: { dot: 'bg-red-500', bg: 'bg-red-100', text: 'text-red-800' }, + UNKNOWN: { dot: 'bg-gray-400', bg: 'bg-gray-100', text: 'text-gray-800' }, +}; + +const METHOD_COLOR: Record = { + GET: 'bg-green-100 text-green-800', + POST: 'bg-blue-100 text-blue-800', + PUT: 'bg-orange-100 text-orange-800', + DELETE: 'bg-red-100 text-red-800', +}; + +const formatRelativeTime = (dateStr: string | null): string => { + if (!dateStr) return '-'; + const diff = Date.now() - new Date(dateStr).getTime(); + const seconds = Math.floor(diff / 1000); + if (seconds < 60) return `${seconds}초 전`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}분 전`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}시간 전`; + const days = Math.floor(hours / 24); + return `${days}일 전`; +}; + +const ServicesPage = () => { + const [services, setServices] = useState([]); + const [selectedService, setSelectedService] = useState(null); + const [serviceApis, setServiceApis] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [isServiceModalOpen, setIsServiceModalOpen] = useState(false); + const [editingService, setEditingService] = useState(null); + const [serviceCode, setServiceCode] = useState(''); + const [serviceName, setServiceName] = useState(''); + const [serviceUrl, setServiceUrl] = useState(''); + const [serviceDescription, setServiceDescription] = useState(''); + const [healthCheckUrl, setHealthCheckUrl] = useState(''); + const [healthCheckInterval, setHealthCheckInterval] = useState(60); + const [serviceIsActive, setServiceIsActive] = useState(true); + + const [isApiModalOpen, setIsApiModalOpen] = useState(false); + const [apiMethod, setApiMethod] = useState('GET'); + const [apiPath, setApiPath] = useState(''); + const [apiName, setApiName] = useState(''); + const [apiDescription, setApiDescription] = useState(''); + + const fetchServices = async () => { + try { + setLoading(true); + const res = await getServices(); + if (res.success && res.data) { + setServices(res.data); + } else { + setError(res.message || '서비스 목록을 불러오는데 실패했습니다.'); + } + } catch { + setError('서비스 목록을 불러오는데 실패했습니다.'); + } finally { + setLoading(false); + } + }; + + const fetchApis = async (serviceId: number) => { + try { + const res = await getServiceApis(serviceId); + if (res.success && res.data) { + setServiceApis(res.data); + } + } catch { + setServiceApis([]); + } + }; + + useEffect(() => { + fetchServices(); + }, []); + + const handleSelectService = (service: ServiceInfo) => { + setSelectedService(service); + fetchApis(service.serviceId); + }; + + const handleOpenCreateService = () => { + setEditingService(null); + setServiceCode(''); + setServiceName(''); + setServiceUrl(''); + setServiceDescription(''); + setHealthCheckUrl(''); + setHealthCheckInterval(60); + setServiceIsActive(true); + setIsServiceModalOpen(true); + }; + + const handleOpenEditService = (service: ServiceInfo) => { + setEditingService(service); + setServiceCode(service.serviceCode); + setServiceName(service.serviceName); + setServiceUrl(service.serviceUrl || ''); + setServiceDescription(service.description || ''); + setHealthCheckUrl(service.healthCheckUrl || ''); + setHealthCheckInterval(service.healthCheckInterval); + setServiceIsActive(service.isActive); + setIsServiceModalOpen(true); + }; + + const handleCloseServiceModal = () => { + setIsServiceModalOpen(false); + setEditingService(null); + setError(null); + }; + + const handleServiceSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + try { + if (editingService) { + const req: UpdateServiceRequest = { + serviceName, + serviceUrl: serviceUrl || undefined, + description: serviceDescription || undefined, + healthCheckUrl: healthCheckUrl || undefined, + healthCheckInterval, + isActive: serviceIsActive, + }; + const res = await updateService(editingService.serviceId, req); + if (!res.success) { + setError(res.message || '서비스 수정에 실패했습니다.'); + return; + } + } else { + const req: CreateServiceRequest = { + serviceCode, + serviceName, + serviceUrl: serviceUrl || undefined, + description: serviceDescription || undefined, + healthCheckUrl: healthCheckUrl || undefined, + healthCheckInterval, + }; + const res = await createService(req); + if (!res.success) { + setError(res.message || '서비스 생성에 실패했습니다.'); + return; + } + } + handleCloseServiceModal(); + await fetchServices(); + } catch { + setError(editingService ? '서비스 수정에 실패했습니다.' : '서비스 생성에 실패했습니다.'); + } + }; + + const handleOpenCreateApi = () => { + setApiMethod('GET'); + setApiPath(''); + setApiName(''); + setApiDescription(''); + setIsApiModalOpen(true); + }; + + const handleCloseApiModal = () => { + setIsApiModalOpen(false); + setError(null); + }; + + const handleApiSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!selectedService) return; + setError(null); + + try { + const req: CreateServiceApiRequest = { + apiMethod, + apiPath, + apiName, + description: apiDescription || undefined, + }; + const res = await createServiceApi(selectedService.serviceId, req); + if (!res.success) { + setError(res.message || 'API 생성에 실패했습니다.'); + return; + } + handleCloseApiModal(); + await fetchApis(selectedService.serviceId); + } catch { + setError('API 생성에 실패했습니다.'); + } + }; + + if (loading) { + return
로딩 중...
; + } + + return ( +
+
+

Services

+ +
+ + {error && !isServiceModalOpen && !isApiModalOpen && ( +
{error}
+ )} + +
+ + + + + + + + + + + + + + + {services.map((service) => { + const badge = HEALTH_BADGE[service.healthStatus] || HEALTH_BADGE.UNKNOWN; + const isSelected = selectedService?.serviceId === service.serviceId; + return ( + handleSelectService(service)} + className={`cursor-pointer ${ + isSelected ? 'bg-blue-50' : 'hover:bg-gray-50' + }`} + > + + + + + + + + + + ); + })} + {services.length === 0 && ( + + + + )} + +
CodeNameURLHealth StatusResponse TimeLast CheckedActiveActions
{service.serviceCode}{service.serviceName} + {service.serviceUrl || '-'} + + + + {service.healthStatus} + + + {service.healthResponseTime != null + ? `${service.healthResponseTime}ms` + : '-'} + + {formatRelativeTime(service.healthCheckedAt)} + + + {service.isActive ? 'Active' : 'Inactive'} + + + +
+ 등록된 서비스가 없습니다. +
+
+ + {selectedService && ( +
+
+

+ APIs for {selectedService.serviceName} +

+ +
+
+ + + + + + + + + + + + {serviceApis.map((api) => ( + + + + + + + + ))} + {serviceApis.length === 0 && ( + + + + )} + +
MethodPathNameDescriptionActive
+ + {api.apiMethod} + + {api.apiPath}{api.apiName}{api.description || '-'} + + {api.isActive ? 'Active' : 'Inactive'} + +
+ 등록된 API가 없습니다. +
+
+
+ )} + + {isServiceModalOpen && ( +
+
+
+

+ {editingService ? '서비스 수정' : '서비스 생성'} +

+
+
+
+ {error && ( +
{error}
+ )} +
+ + setServiceCode(e.target.value)} + disabled={!!editingService} + required + className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:bg-gray-100 disabled:text-gray-500" + /> +
+
+ + setServiceName(e.target.value)} + required + className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none" + /> +
+
+ + setServiceUrl(e.target.value)} + className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none" + /> +
+
+ +