generated from gc/template-java-maven
Merge pull request 'release: 2026-04-08 (16건 커밋)' (#19) from develop into main
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 52s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 52s
This commit is contained in:
커밋
4d07ac1663
@ -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)
|
||||
|
||||
@ -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]
|
||||
|
||||
### 추가
|
||||
|
||||
195
docs/schema/create_tables.sql
Normal file
195
docs/schema/create_tables.sql
Normal file
@ -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);
|
||||
24
docs/schema/initial_data.sql
Normal file
24
docs/schema/initial_data.sql
Normal file
@ -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;
|
||||
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",
|
||||
|
||||
@ -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 (
|
||||
<BrowserRouter basename={BASE_PATH}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold text-gray-900">SNP Connection Monitoring</h1>
|
||||
<p className="mt-2 text-gray-600">Dashboard - Coming Soon</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route element={<AuthLayout />}>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
</Route>
|
||||
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<MainLayout />}>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<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 />} />
|
||||
<Route path="/admin/services" element={<ServicesPage />} />
|
||||
<Route path="/admin/users" element={<UsersPage />} />
|
||||
<Route path="/admin/tenants" element={<TenantsPage />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default App;
|
||||
|
||||
22
frontend/src/components/ProtectedRoute.tsx
Normal file
22
frontend/src/components/ProtectedRoute.tsx
Normal file
@ -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 (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-500 border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
};
|
||||
|
||||
export default ProtectedRoute;
|
||||
8
frontend/src/hooks/useAuth.ts
Normal file
8
frontend/src/hooks/useAuth.ts
Normal file
@ -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;
|
||||
};
|
||||
11
frontend/src/layouts/AuthLayout.tsx
Normal file
11
frontend/src/layouts/AuthLayout.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
const AuthLayout = () => {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50">
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthLayout;
|
||||
167
frontend/src/layouts/MainLayout.tsx
Normal file
167
frontend/src/layouts/MainLayout.tsx
Normal file
@ -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<Record<string, boolean>>({
|
||||
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 (
|
||||
<div className="flex min-h-screen">
|
||||
{/* Sidebar */}
|
||||
<aside className="fixed left-0 top-0 h-screen w-64 bg-gray-900 text-white flex flex-col">
|
||||
<div className="flex items-center gap-2 px-6 py-5 border-b border-gray-700">
|
||||
<svg className="h-6 w-6 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<span className="text-lg font-semibold">SNP Connection</span>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-4">
|
||||
{/* Dashboard */}
|
||||
<NavLink
|
||||
to="/dashboard"
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
|
||||
isActive ? 'bg-gray-700 text-white' : 'text-gray-300 hover:bg-gray-800 hover:text-white'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
Dashboard
|
||||
</NavLink>
|
||||
|
||||
{/* Nav Groups */}
|
||||
{navGroups.map((group) => {
|
||||
if (group.adminOnly && user?.role !== 'ADMIN') return null;
|
||||
|
||||
const isOpen = openGroups[group.label] ?? false;
|
||||
|
||||
return (
|
||||
<div key={group.label} className="mt-4">
|
||||
<button
|
||||
onClick={() => toggleGroup(group.label)}
|
||||
className="flex w-full items-center justify-between rounded-lg px-3 py-2 text-xs font-semibold uppercase tracking-wider text-gray-400 hover:text-white"
|
||||
>
|
||||
{group.label}
|
||||
<svg
|
||||
className={`h-4 w-4 transition-transform ${isOpen ? 'rotate-90' : ''}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="ml-2 space-y-1">
|
||||
{group.items.map((item) => {
|
||||
if (
|
||||
group.label === 'API Keys' &&
|
||||
item.label === 'Admin' &&
|
||||
!isAdminOrManager
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) =>
|
||||
`block rounded-lg px-3 py-2 text-sm transition-colors ${
|
||||
isActive ? 'bg-gray-700 text-white' : 'text-gray-300 hover:bg-gray-800 hover:text-white'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 ml-64">
|
||||
{/* Header */}
|
||||
<header className="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-6">
|
||||
<div />
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-700">{user?.userName}</span>
|
||||
<span className="rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800">
|
||||
{user?.role}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="rounded-lg px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<main className="p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainLayout;
|
||||
434
frontend/src/pages/DashboardPage.tsx
Normal file
434
frontend/src/pages/DashboardPage.tsx
Normal file
@ -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<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>
|
||||
{/* 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>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
93
frontend/src/pages/LoginPage.tsx
Normal file
93
frontend/src/pages/LoginPage.tsx
Normal file
@ -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 (
|
||||
<div className="w-full max-w-md">
|
||||
<div className="rounded-xl bg-white px-8 py-10 shadow-lg">
|
||||
<h1 className="mb-8 text-center text-2xl font-bold text-gray-900">
|
||||
SNP Connection Monitoring
|
||||
</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label htmlFor="loginId" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
아이디
|
||||
</label>
|
||||
<input
|
||||
id="loginId"
|
||||
type="text"
|
||||
value={loginId}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
비밀번호
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isSubmitting ? '로그인 중...' : '로그인'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
18
frontend/src/pages/NotFoundPage.tsx
Normal file
18
frontend/src/pages/NotFoundPage.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const NotFoundPage = () => {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] flex-col items-center justify-center text-center">
|
||||
<h1 className="text-4xl font-bold text-gray-900">404 - Page Not Found</h1>
|
||||
<p className="mt-3 text-gray-600">요청하신 페이지를 찾을 수 없습니다.</p>
|
||||
<Link
|
||||
to="/dashboard"
|
||||
className="mt-6 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Dashboard로 이동
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotFoundPage;
|
||||
575
frontend/src/pages/admin/ServicesPage.tsx
Normal file
575
frontend/src/pages/admin/ServicesPage.tsx
Normal file
@ -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<string, { dot: string; bg: string; text: string }> = {
|
||||
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<string, string> = {
|
||||
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<ServiceInfo[]>([]);
|
||||
const [selectedService, setSelectedService] = useState<ServiceInfo | null>(null);
|
||||
const [serviceApis, setServiceApis] = useState<ServiceApi[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [isServiceModalOpen, setIsServiceModalOpen] = useState(false);
|
||||
const [editingService, setEditingService] = useState<ServiceInfo | null>(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 <div className="text-center py-10 text-gray-500">로딩 중...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Services</h1>
|
||||
<button
|
||||
onClick={handleOpenCreateService}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
Create Service
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && !isServiceModalOpen && !isApiModalOpen && (
|
||||
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto bg-white rounded-lg shadow mb-6">
|
||||
<table className="w-full divide-y divide-gray-200 text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Code</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Name</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">URL</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Health Status</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Response Time</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Last Checked</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Active</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{services.map((service) => {
|
||||
const badge = HEALTH_BADGE[service.healthStatus] || HEALTH_BADGE.UNKNOWN;
|
||||
const isSelected = selectedService?.serviceId === service.serviceId;
|
||||
return (
|
||||
<tr
|
||||
key={service.serviceId}
|
||||
onClick={() => handleSelectService(service)}
|
||||
className={`cursor-pointer ${
|
||||
isSelected ? 'bg-blue-50' : 'hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<td className="px-4 py-3 font-mono">{service.serviceCode}</td>
|
||||
<td className="px-4 py-3">{service.serviceName}</td>
|
||||
<td className="px-4 py-3 text-gray-500 truncate max-w-[200px]">
|
||||
{service.serviceUrl || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${badge.bg} ${badge.text}`}
|
||||
>
|
||||
<span className={`w-2 h-2 rounded-full ${badge.dot}`} />
|
||||
{service.healthStatus}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500">
|
||||
{service.healthResponseTime != null
|
||||
? `${service.healthResponseTime}ms`
|
||||
: '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500">
|
||||
{formatRelativeTime(service.healthCheckedAt)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
service.isActive
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{service.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleOpenEditService(service);
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-800 font-medium"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{services.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
|
||||
등록된 서비스가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{selectedService && (
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
APIs for {selectedService.serviceName}
|
||||
</h2>
|
||||
<button
|
||||
onClick={handleOpenCreateApi}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 rounded-lg text-sm font-medium"
|
||||
>
|
||||
Add API
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full divide-y divide-gray-200 text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Method</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Path</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Name</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Description</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Active</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{serviceApis.map((api) => (
|
||||
<tr key={api.apiId} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded text-xs font-bold ${
|
||||
METHOD_COLOR[api.apiMethod] || 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{api.apiMethod}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono">{api.apiPath}</td>
|
||||
<td className="px-4 py-3">{api.apiName}</td>
|
||||
<td className="px-4 py-3 text-gray-500">{api.description || '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
api.isActive
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{api.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{serviceApis.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-gray-400">
|
||||
등록된 API가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isServiceModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
|
||||
<div className="px-6 py-4 border-b">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
{editingService ? '서비스 수정' : '서비스 생성'}
|
||||
</h2>
|
||||
</div>
|
||||
<form onSubmit={handleServiceSubmit}>
|
||||
<div className="px-6 py-4 space-y-4 max-h-[70vh] overflow-y-auto">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Service Code
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={serviceCode}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Service Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={serviceName}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Service URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={serviceUrl}
|
||||
onChange={(e) => setServiceUrl(e.target.value)}
|
||||
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={serviceDescription}
|
||||
onChange={(e) => setServiceDescription(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Health Check URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={healthCheckUrl}
|
||||
onChange={(e) => setHealthCheckUrl(e.target.value)}
|
||||
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Health Check Interval (seconds)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={healthCheckInterval}
|
||||
onChange={(e) => setHealthCheckInterval(Number(e.target.value))}
|
||||
min={10}
|
||||
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
{editingService && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="serviceIsActive"
|
||||
checked={serviceIsActive}
|
||||
onChange={(e) => setServiceIsActive(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<label htmlFor="serviceIsActive" className="text-sm text-gray-700">
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCloseServiceModal}
|
||||
className="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isApiModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
|
||||
<div className="px-6 py-4 border-b">
|
||||
<h2 className="text-lg font-semibold text-gray-900">API 생성</h2>
|
||||
</div>
|
||||
<form onSubmit={handleApiSubmit}>
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Method</label>
|
||||
<select
|
||||
value={apiMethod}
|
||||
onChange={(e) => setApiMethod(e.target.value)}
|
||||
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="GET">GET</option>
|
||||
<option value="POST">POST</option>
|
||||
<option value="PUT">PUT</option>
|
||||
<option value="DELETE">DELETE</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">API Path</label>
|
||||
<input
|
||||
type="text"
|
||||
value={apiPath}
|
||||
onChange={(e) => setApiPath(e.target.value)}
|
||||
required
|
||||
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">API Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={apiName}
|
||||
onChange={(e) => setApiName(e.target.value)}
|
||||
required
|
||||
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={apiDescription}
|
||||
onChange={(e) => setApiDescription(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCloseApiModal}
|
||||
className="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServicesPage;
|
||||
256
frontend/src/pages/admin/TenantsPage.tsx
Normal file
256
frontend/src/pages/admin/TenantsPage.tsx
Normal file
@ -0,0 +1,256 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { Tenant, CreateTenantRequest, UpdateTenantRequest } from '../../types/tenant';
|
||||
import { getTenants, createTenant, updateTenant } from '../../services/tenantService';
|
||||
|
||||
const TenantsPage = () => {
|
||||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingTenant, setEditingTenant] = useState<Tenant | null>(null);
|
||||
|
||||
const [tenantCode, setTenantCode] = useState('');
|
||||
const [tenantName, setTenantName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [isActive, setIsActive] = useState(true);
|
||||
|
||||
const fetchTenants = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await getTenants();
|
||||
if (res.success && res.data) {
|
||||
setTenants(res.data);
|
||||
} else {
|
||||
setError(res.message || '테넌트 목록을 불러오는데 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
setError('테넌트 목록을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTenants();
|
||||
}, []);
|
||||
|
||||
const handleOpenCreate = () => {
|
||||
setEditingTenant(null);
|
||||
setTenantCode('');
|
||||
setTenantName('');
|
||||
setDescription('');
|
||||
setIsActive(true);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenEdit = (tenant: Tenant) => {
|
||||
setEditingTenant(tenant);
|
||||
setTenantCode(tenant.tenantCode);
|
||||
setTenantName(tenant.tenantName);
|
||||
setDescription(tenant.description || '');
|
||||
setIsActive(tenant.isActive);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setEditingTenant(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (editingTenant) {
|
||||
const req: UpdateTenantRequest = {
|
||||
tenantName,
|
||||
description: description || undefined,
|
||||
isActive,
|
||||
};
|
||||
const res = await updateTenant(editingTenant.tenantId, req);
|
||||
if (!res.success) {
|
||||
setError(res.message || '테넌트 수정에 실패했습니다.');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const req: CreateTenantRequest = {
|
||||
tenantCode,
|
||||
tenantName,
|
||||
description: description || undefined,
|
||||
};
|
||||
const res = await createTenant(req);
|
||||
if (!res.success) {
|
||||
setError(res.message || '테넌트 생성에 실패했습니다.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
handleCloseModal();
|
||||
await fetchTenants();
|
||||
} catch {
|
||||
setError(editingTenant ? '테넌트 수정에 실패했습니다.' : '테넌트 생성에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center py-10 text-gray-500">로딩 중...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Tenants</h1>
|
||||
<button
|
||||
onClick={handleOpenCreate}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
Create Tenant
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && !isModalOpen && (
|
||||
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto bg-white rounded-lg shadow">
|
||||
<table className="w-full divide-y divide-gray-200 text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Code</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Name</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Description</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Active</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Created</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{tenants.map((tenant) => (
|
||||
<tr key={tenant.tenantId} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 font-mono">{tenant.tenantCode}</td>
|
||||
<td className="px-4 py-3">{tenant.tenantName}</td>
|
||||
<td className="px-4 py-3 text-gray-500">{tenant.description || '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
tenant.isActive
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{tenant.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500">
|
||||
{new Date(tenant.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => handleOpenEdit(tenant)}
|
||||
className="text-blue-600 hover:text-blue-800 font-medium"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{tenants.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-gray-400">
|
||||
등록된 테넌트가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
|
||||
<div className="px-6 py-4 border-b">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
{editingTenant ? '테넌트 수정' : '테넌트 생성'}
|
||||
</h2>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Tenant Code
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tenantCode}
|
||||
onChange={(e) => setTenantCode(e.target.value)}
|
||||
disabled={!!editingTenant}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Tenant Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tenantName}
|
||||
onChange={(e) => setTenantName(e.target.value)}
|
||||
required
|
||||
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
{editingTenant && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isActive"
|
||||
checked={isActive}
|
||||
onChange={(e) => setIsActive(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<label htmlFor="isActive" className="text-sm text-gray-700">
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCloseModal}
|
||||
className="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TenantsPage;
|
||||
352
frontend/src/pages/admin/UsersPage.tsx
Normal file
352
frontend/src/pages/admin/UsersPage.tsx
Normal file
@ -0,0 +1,352 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { UserDetail, CreateUserRequest, UpdateUserRequest } from '../../types/user';
|
||||
import type { Tenant } from '../../types/tenant';
|
||||
import { getUsers, createUser, updateUser, deactivateUser } from '../../services/userService';
|
||||
import { getTenants } from '../../services/tenantService';
|
||||
|
||||
const ROLE_BADGE: Record<string, string> = {
|
||||
ADMIN: 'bg-red-100 text-red-800',
|
||||
MANAGER: 'bg-orange-100 text-orange-800',
|
||||
USER: 'bg-blue-100 text-blue-800',
|
||||
VIEWER: 'bg-gray-100 text-gray-800',
|
||||
};
|
||||
|
||||
const UsersPage = () => {
|
||||
const [users, setUsers] = useState<UserDetail[]>([]);
|
||||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<UserDetail | null>(null);
|
||||
|
||||
const [loginId, setLoginId] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [userName, setUserName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [tenantId, setTenantId] = useState<string>('');
|
||||
const [role, setRole] = useState('USER');
|
||||
const [isActive, setIsActive] = useState(true);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [usersRes, tenantsRes] = await Promise.all([getUsers(), getTenants()]);
|
||||
if (usersRes.success && usersRes.data) {
|
||||
setUsers(usersRes.data);
|
||||
}
|
||||
if (tenantsRes.success && tenantsRes.data) {
|
||||
setTenants(tenantsRes.data);
|
||||
}
|
||||
} catch {
|
||||
setError('데이터를 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleOpenCreate = () => {
|
||||
setEditingUser(null);
|
||||
setLoginId('');
|
||||
setPassword('');
|
||||
setUserName('');
|
||||
setEmail('');
|
||||
setTenantId('');
|
||||
setRole('USER');
|
||||
setIsActive(true);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenEdit = (user: UserDetail) => {
|
||||
setEditingUser(user);
|
||||
setLoginId(user.loginId);
|
||||
setPassword('');
|
||||
setUserName(user.userName);
|
||||
setEmail(user.email || '');
|
||||
setTenantId(user.tenantId ? String(user.tenantId) : '');
|
||||
setRole(user.role);
|
||||
setIsActive(user.isActive);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setEditingUser(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (editingUser) {
|
||||
const req: UpdateUserRequest = {
|
||||
tenantId: tenantId ? Number(tenantId) : undefined,
|
||||
userName,
|
||||
email: email || undefined,
|
||||
role,
|
||||
password: password || undefined,
|
||||
isActive,
|
||||
};
|
||||
const res = await updateUser(editingUser.userId, req);
|
||||
if (!res.success) {
|
||||
setError(res.message || '사용자 수정에 실패했습니다.');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const req: CreateUserRequest = {
|
||||
tenantId: tenantId ? Number(tenantId) : undefined,
|
||||
loginId,
|
||||
password,
|
||||
userName,
|
||||
email: email || undefined,
|
||||
role,
|
||||
};
|
||||
const res = await createUser(req);
|
||||
if (!res.success) {
|
||||
setError(res.message || '사용자 생성에 실패했습니다.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
handleCloseModal();
|
||||
await fetchData();
|
||||
} catch {
|
||||
setError(editingUser ? '사용자 수정에 실패했습니다.' : '사용자 생성에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeactivate = async (user: UserDetail) => {
|
||||
if (!confirm(`'${user.userName}' 사용자를 비활성화하시겠습니까?`)) return;
|
||||
|
||||
try {
|
||||
const res = await deactivateUser(user.userId);
|
||||
if (!res.success) {
|
||||
setError(res.message || '사용자 비활성화에 실패했습니다.');
|
||||
return;
|
||||
}
|
||||
await fetchData();
|
||||
} catch {
|
||||
setError('사용자 비활성화에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center py-10 text-gray-500">로딩 중...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Users</h1>
|
||||
<button
|
||||
onClick={handleOpenCreate}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
Create User
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && !isModalOpen && (
|
||||
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto bg-white rounded-lg shadow">
|
||||
<table className="w-full divide-y divide-gray-200 text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Login ID</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Name</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Email</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Tenant</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Role</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Active</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Last Login</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{users.map((user) => (
|
||||
<tr key={user.userId} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 font-mono">{user.loginId}</td>
|
||||
<td className="px-4 py-3">{user.userName}</td>
|
||||
<td className="px-4 py-3 text-gray-500">{user.email || '-'}</td>
|
||||
<td className="px-4 py-3 text-gray-500">{user.tenantName || '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
ROLE_BADGE[user.role] || 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
user.isActive
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{user.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500">
|
||||
{user.lastLoginAt
|
||||
? new Date(user.lastLoginAt).toLocaleString()
|
||||
: '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 space-x-2">
|
||||
<button
|
||||
onClick={() => handleOpenEdit(user)}
|
||||
className="text-blue-600 hover:text-blue-800 font-medium"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
{user.isActive && (
|
||||
<button
|
||||
onClick={() => handleDeactivate(user)}
|
||||
className="text-red-600 hover:text-red-800 font-medium"
|
||||
>
|
||||
비활성화
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{users.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
|
||||
등록된 사용자가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
|
||||
<div className="px-6 py-4 border-b">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
{editingUser ? '사용자 수정' : '사용자 생성'}
|
||||
</h2>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="px-6 py-4 space-y-4 max-h-[70vh] overflow-y-auto">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Login ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={loginId}
|
||||
onChange={(e) => setLoginId(e.target.value)}
|
||||
disabled={!!editingUser}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required={!editingUser}
|
||||
placeholder={editingUser ? '변경 시 입력' : ''}
|
||||
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">User Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={userName}
|
||||
onChange={(e) => setUserName(e.target.value)}
|
||||
required
|
||||
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Tenant</label>
|
||||
<select
|
||||
value={tenantId}
|
||||
onChange={(e) => setTenantId(e.target.value)}
|
||||
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">-- 선택 --</option>
|
||||
{tenants.map((t) => (
|
||||
<option key={t.tenantId} value={t.tenantId}>
|
||||
{t.tenantName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Role</label>
|
||||
<select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value)}
|
||||
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="ADMIN">ADMIN</option>
|
||||
<option value="MANAGER">MANAGER</option>
|
||||
<option value="USER">USER</option>
|
||||
<option value="VIEWER">VIEWER</option>
|
||||
</select>
|
||||
</div>
|
||||
{editingUser && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isActive"
|
||||
checked={isActive}
|
||||
onChange={(e) => setIsActive(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<label htmlFor="isActive" className="text-sm text-gray-700">
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCloseModal}
|
||||
className="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsersPage;
|
||||
778
frontend/src/pages/apikeys/KeyAdminPage.tsx
Normal file
778
frontend/src/pages/apikeys/KeyAdminPage.tsx
Normal file
@ -0,0 +1,778 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type { ApiKey, ApiKeyDetail, ApiKeyRequest } from '../../types/apikey';
|
||||
import type { ServiceInfo, ServiceApi } from '../../types/service';
|
||||
import {
|
||||
getAllKeys,
|
||||
getKeyDetail,
|
||||
revokeKey,
|
||||
getAllRequests,
|
||||
reviewRequest,
|
||||
getPermissions,
|
||||
updatePermissions,
|
||||
} from '../../services/apiKeyService';
|
||||
import { getServices, getServiceApis } from '../../services/serviceService';
|
||||
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
ACTIVE: 'bg-green-100 text-green-800',
|
||||
PENDING: 'bg-yellow-100 text-yellow-800',
|
||||
REVOKED: 'bg-red-100 text-red-800',
|
||||
EXPIRED: 'bg-gray-100 text-gray-800',
|
||||
INACTIVE: 'bg-gray-100 text-gray-800',
|
||||
APPROVED: 'bg-green-100 text-green-800',
|
||||
REJECTED: 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
const METHOD_COLOR: Record<string, string> = {
|
||||
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 formatDateTime = (dateStr: string | null): string => {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleString('ko-KR');
|
||||
};
|
||||
|
||||
interface AllApisMap {
|
||||
services: ServiceInfo[];
|
||||
apisByService: Record<number, ServiceApi[]>;
|
||||
apiById: Record<number, ServiceApi & { serviceName: string }>;
|
||||
}
|
||||
|
||||
const KeyAdminPage = () => {
|
||||
const [activeTab, setActiveTab] = useState<'requests' | 'keys'>('requests');
|
||||
|
||||
// Shared state
|
||||
const [allApisMap, setAllApisMap] = useState<AllApisMap>({ services: [], apisByService: {}, apiById: {} });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Requests tab state
|
||||
const [requests, setRequests] = useState<ApiKeyRequest[]>([]);
|
||||
const [requestsLoading, setRequestsLoading] = useState(true);
|
||||
const [isReviewModalOpen, setIsReviewModalOpen] = useState(false);
|
||||
const [selectedRequest, setSelectedRequest] = useState<ApiKeyRequest | null>(null);
|
||||
const [adjustedApiIds, setAdjustedApiIds] = useState<Set<number>>(new Set());
|
||||
const [reviewComment, setReviewComment] = useState('');
|
||||
const [adjustedFromDate, setAdjustedFromDate] = useState('');
|
||||
const [adjustedToDate, setAdjustedToDate] = useState('');
|
||||
|
||||
// Keys tab state
|
||||
const [allKeys, setAllKeys] = useState<ApiKey[]>([]);
|
||||
const [keysLoading, setKeysLoading] = useState(true);
|
||||
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
|
||||
const [selectedKeyDetail, setSelectedKeyDetail] = useState<ApiKeyDetail | null>(null);
|
||||
const [isPermissionModalOpen, setIsPermissionModalOpen] = useState(false);
|
||||
const [permissionKeyId, setPermissionKeyId] = useState<number | null>(null);
|
||||
const [permissionKeyName, setPermissionKeyName] = useState('');
|
||||
const [permissionApiIds, setPermissionApiIds] = useState<Set<number>>(new Set());
|
||||
const [detailCopied, setDetailCopied] = useState(false);
|
||||
|
||||
// Raw key modal (after approve)
|
||||
const [rawKeyModal, setRawKeyModal] = useState<{ keyName: string; rawKey: string } | null>(null);
|
||||
const [rawKeyCopied, setRawKeyCopied] = useState(false);
|
||||
|
||||
const fetchAllApis = useCallback(async () => {
|
||||
try {
|
||||
const servicesRes = await getServices();
|
||||
if (servicesRes.success && servicesRes.data) {
|
||||
const services = servicesRes.data;
|
||||
const apisByService: Record<number, ServiceApi[]> = {};
|
||||
const apiById: Record<number, ServiceApi & { serviceName: string }> = {};
|
||||
|
||||
await Promise.all(
|
||||
services.map(async (service) => {
|
||||
const apisRes = await getServiceApis(service.serviceId);
|
||||
if (apisRes.success && apisRes.data) {
|
||||
apisByService[service.serviceId] = apisRes.data;
|
||||
apisRes.data.forEach((api) => {
|
||||
apiById[api.apiId] = { ...api, serviceName: service.serviceName };
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
setAllApisMap({ services, apisByService, apiById });
|
||||
}
|
||||
} catch {
|
||||
// Silently fail for API map loading
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchRequests = useCallback(async () => {
|
||||
try {
|
||||
setRequestsLoading(true);
|
||||
const res = await getAllRequests();
|
||||
if (res.success && res.data) {
|
||||
setRequests(res.data);
|
||||
}
|
||||
} catch {
|
||||
setError('신청 목록을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setRequestsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchKeys = useCallback(async () => {
|
||||
try {
|
||||
setKeysLoading(true);
|
||||
const res = await getAllKeys();
|
||||
if (res.success && res.data) {
|
||||
setAllKeys(res.data);
|
||||
}
|
||||
} catch {
|
||||
setError('키 목록을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setKeysLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAllApis();
|
||||
fetchRequests();
|
||||
fetchKeys();
|
||||
}, [fetchAllApis, fetchRequests, fetchKeys]);
|
||||
|
||||
// --- Requests tab handlers ---
|
||||
|
||||
const handleOpenReview = (req: ApiKeyRequest) => {
|
||||
setSelectedRequest(req);
|
||||
setAdjustedApiIds(new Set(req.requestedApiIds));
|
||||
setReviewComment('');
|
||||
setAdjustedFromDate(req.usageFromDate ? req.usageFromDate.split('T')[0] : '');
|
||||
setAdjustedToDate(req.usageToDate ? req.usageToDate.split('T')[0] : '');
|
||||
setError(null);
|
||||
setIsReviewModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseReview = () => {
|
||||
setIsReviewModalOpen(false);
|
||||
setSelectedRequest(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleToggleReviewApi = (apiId: number) => {
|
||||
setAdjustedApiIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(apiId)) {
|
||||
next.delete(apiId);
|
||||
} else {
|
||||
next.add(apiId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleReviewSubmit = async (status: 'APPROVED' | 'REJECTED') => {
|
||||
if (!selectedRequest) return;
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await reviewRequest(selectedRequest.requestId, {
|
||||
status,
|
||||
reviewComment: reviewComment || undefined,
|
||||
adjustedApiIds: status === 'APPROVED' ? Array.from(adjustedApiIds) : undefined,
|
||||
adjustedFromDate: status === 'APPROVED' && adjustedFromDate ? adjustedFromDate : undefined,
|
||||
adjustedToDate: status === 'APPROVED' && adjustedToDate ? adjustedToDate : undefined,
|
||||
});
|
||||
|
||||
if (res.success) {
|
||||
handleCloseReview();
|
||||
await fetchRequests();
|
||||
await fetchKeys();
|
||||
|
||||
if (status === 'APPROVED' && res.data) {
|
||||
setRawKeyModal({ keyName: res.data.keyName, rawKey: res.data.rawKey });
|
||||
}
|
||||
} else {
|
||||
setError(res.message || '검토 처리에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
setError('검토 처리에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// --- Keys tab handlers ---
|
||||
|
||||
const handleViewDetail = async (key: ApiKey) => {
|
||||
setError(null);
|
||||
try {
|
||||
const res = await getKeyDetail(key.apiKeyId);
|
||||
if (res.success && res.data) {
|
||||
setSelectedKeyDetail(res.data);
|
||||
setDetailCopied(false);
|
||||
setIsDetailModalOpen(true);
|
||||
} else {
|
||||
setError(res.message || '키 상세 정보를 불러오는데 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
setError('키 상세 정보를 불러오는데 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyDecryptedKey = async () => {
|
||||
if (!selectedKeyDetail) return;
|
||||
await navigator.clipboard.writeText(selectedKeyDetail.decryptedKey);
|
||||
setDetailCopied(true);
|
||||
setTimeout(() => setDetailCopied(false), 2000);
|
||||
};
|
||||
|
||||
const handleOpenPermissions = async (key: ApiKey) => {
|
||||
setError(null);
|
||||
setPermissionKeyId(key.apiKeyId);
|
||||
setPermissionKeyName(key.keyName);
|
||||
|
||||
try {
|
||||
const res = await getPermissions(key.apiKeyId);
|
||||
if (res.success && res.data) {
|
||||
setPermissionApiIds(new Set(res.data.map((p) => p.apiId)));
|
||||
} else {
|
||||
setPermissionApiIds(new Set());
|
||||
}
|
||||
setIsPermissionModalOpen(true);
|
||||
} catch {
|
||||
setError('권한 정보를 불러오는데 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTogglePermissionApi = (apiId: number) => {
|
||||
setPermissionApiIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(apiId)) {
|
||||
next.delete(apiId);
|
||||
} else {
|
||||
next.add(apiId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSavePermissions = async () => {
|
||||
if (permissionKeyId === null) return;
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await updatePermissions(permissionKeyId, {
|
||||
apiIds: Array.from(permissionApiIds),
|
||||
});
|
||||
if (res.success) {
|
||||
setIsPermissionModalOpen(false);
|
||||
setPermissionKeyId(null);
|
||||
} else {
|
||||
setError(res.message || '권한 저장에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
setError('권한 저장에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevokeKey = async (key: ApiKey) => {
|
||||
if (!window.confirm(`'${key.keyName}' 키를 폐기하시겠습니까? 이 작업은 되돌릴 수 없습니다.`)) return;
|
||||
|
||||
try {
|
||||
const res = await revokeKey(key.apiKeyId);
|
||||
if (res.success) {
|
||||
await fetchKeys();
|
||||
} else {
|
||||
setError(res.message || '키 폐기에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
setError('키 폐기에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyRawKey = async () => {
|
||||
if (!rawKeyModal) return;
|
||||
await navigator.clipboard.writeText(rawKeyModal.rawKey);
|
||||
setRawKeyCopied(true);
|
||||
setTimeout(() => setRawKeyCopied(false), 2000);
|
||||
};
|
||||
|
||||
// --- Render helpers ---
|
||||
|
||||
const renderApiCheckboxes = (
|
||||
selectedIds: Set<number>,
|
||||
onToggle: (apiId: number) => void,
|
||||
) => {
|
||||
return allApisMap.services.map((service) => {
|
||||
const apis = allApisMap.apisByService[service.serviceId] || [];
|
||||
if (apis.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={service.serviceId} className="mb-3">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-1">{service.serviceName}</h4>
|
||||
<div className="space-y-1 pl-4">
|
||||
{apis.map((api) => (
|
||||
<label
|
||||
key={api.apiId}
|
||||
className="flex items-center gap-2 py-0.5 cursor-pointer hover:bg-gray-50 rounded px-1"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(api.apiId)}
|
||||
onChange={() => onToggle(api.apiId)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span
|
||||
className={`inline-block px-1.5 py-0.5 rounded text-xs font-bold ${
|
||||
METHOD_COLOR[api.apiMethod] || 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{api.apiMethod}
|
||||
</span>
|
||||
<span className="font-mono text-sm text-gray-700">{api.apiPath}</span>
|
||||
<span className="text-sm text-gray-500">- {api.apiName}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">API Key 관리</h1>
|
||||
|
||||
{error && !isReviewModalOpen && !isDetailModalOpen && !isPermissionModalOpen && (
|
||||
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b mb-6">
|
||||
<button
|
||||
onClick={() => setActiveTab('requests')}
|
||||
className={`px-4 py-2 text-sm font-medium -mb-px ${
|
||||
activeTab === 'requests'
|
||||
? 'border-b-2 border-blue-600 text-blue-600'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
신청 관리
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('keys')}
|
||||
className={`px-4 py-2 text-sm font-medium -mb-px ${
|
||||
activeTab === 'keys'
|
||||
? 'border-b-2 border-blue-600 text-blue-600'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
키 관리
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab: 신청 관리 */}
|
||||
{activeTab === 'requests' && (
|
||||
<div>
|
||||
{requestsLoading ? (
|
||||
<div className="text-center py-10 text-gray-500">로딩 중...</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto bg-white rounded-lg shadow">
|
||||
<table className="w-full divide-y divide-gray-200 text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Requester</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Key Name</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Purpose</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Status</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">APIs</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Created At</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{requests.map((req) => (
|
||||
<tr key={req.requestId} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3">{req.userName}</td>
|
||||
<td className="px-4 py-3">{req.keyName}</td>
|
||||
<td className="px-4 py-3 text-gray-500 max-w-[200px] truncate">
|
||||
{req.purpose || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
STATUS_BADGE[req.status] || 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{req.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500">{req.requestedApiIds.length}개</td>
|
||||
<td className="px-4 py-3 text-gray-500">{formatDateTime(req.createdAt)}</td>
|
||||
<td className="px-4 py-3">
|
||||
{req.status === 'PENDING' && (
|
||||
<button
|
||||
onClick={() => handleOpenReview(req)}
|
||||
className="text-blue-600 hover:text-blue-800 font-medium text-sm"
|
||||
>
|
||||
검토
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{requests.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-gray-400">
|
||||
신청 내역이 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab: 키 관리 */}
|
||||
{activeTab === 'keys' && (
|
||||
<div>
|
||||
{keysLoading ? (
|
||||
<div className="text-center py-10 text-gray-500">로딩 중...</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto bg-white rounded-lg shadow">
|
||||
<table className="w-full divide-y divide-gray-200 text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Key Name</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Prefix</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">User</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Status</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Last Used</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Created</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{allKeys.map((key) => (
|
||||
<tr key={key.apiKeyId} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3">{key.keyName}</td>
|
||||
<td className="px-4 py-3 font-mono text-gray-600">{key.apiKeyPrefix}</td>
|
||||
<td className="px-4 py-3 text-gray-500">{key.maskedKey}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
STATUS_BADGE[key.status] || 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{key.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500">{formatDateTime(key.lastUsedAt)}</td>
|
||||
<td className="px-4 py-3 text-gray-500">{formatDateTime(key.createdAt)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleViewDetail(key)}
|
||||
className="text-blue-600 hover:text-blue-800 font-medium text-sm"
|
||||
>
|
||||
상세
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleOpenPermissions(key)}
|
||||
className="text-purple-600 hover:text-purple-800 font-medium text-sm"
|
||||
>
|
||||
권한
|
||||
</button>
|
||||
{key.status === 'ACTIVE' && (
|
||||
<button
|
||||
onClick={() => handleRevokeKey(key)}
|
||||
className="text-red-600 hover:text-red-800 font-medium text-sm"
|
||||
>
|
||||
폐기
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{allKeys.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-gray-400">
|
||||
등록된 API Key가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Review Modal */}
|
||||
{isReviewModalOpen && selectedRequest && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl mx-4 max-h-[90vh] flex flex-col">
|
||||
<div className="px-6 py-4 border-b">
|
||||
<h2 className="text-lg font-semibold text-gray-900">신청 검토</h2>
|
||||
</div>
|
||||
<div className="px-6 py-4 space-y-4 overflow-y-auto flex-1">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500">신청자</label>
|
||||
<p className="text-gray-900">{selectedRequest.userName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500">Key Name</label>
|
||||
<p className="text-gray-900">{selectedRequest.keyName}</p>
|
||||
</div>
|
||||
</div>
|
||||
{selectedRequest.purpose && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500">목적</label>
|
||||
<p className="text-gray-900">{selectedRequest.purpose}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{selectedRequest.serviceIp && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500">서비스 IP</label>
|
||||
<p className="font-mono text-gray-900">{selectedRequest.serviceIp}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedRequest.servicePurpose && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500">서비스 용도</label>
|
||||
<p className="text-gray-900">{selectedRequest.servicePurpose}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedRequest.dailyRequestEstimate != null && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500">하루 예상 요청량</label>
|
||||
<p className="text-gray-900">{Number(selectedRequest.dailyRequestEstimate).toLocaleString()}건</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">사용 기간 (조정 가능)</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="date" value={adjustedFromDate}
|
||||
onChange={(e) => setAdjustedFromDate(e.target.value)}
|
||||
className="border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none" />
|
||||
<span className="text-gray-500">~</span>
|
||||
<input type="date" value={adjustedToDate}
|
||||
onChange={(e) => setAdjustedToDate(e.target.value)}
|
||||
className="border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
API 권한 ({adjustedApiIds.size}개 선택)
|
||||
</label>
|
||||
<div className="border rounded-lg p-3 max-h-60 overflow-y-auto">
|
||||
{renderApiCheckboxes(adjustedApiIds, handleToggleReviewApi)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">검토 의견</label>
|
||||
<textarea
|
||||
value={reviewComment}
|
||||
onChange={(e) => setReviewComment(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="검토 의견을 입력하세요"
|
||||
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCloseReview}
|
||||
className="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleReviewSubmit('REJECTED')}
|
||||
className="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
반려
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleReviewSubmit('APPROVED')}
|
||||
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
승인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detail Modal */}
|
||||
{isDetailModalOpen && selectedKeyDetail && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-lg mx-4">
|
||||
<div className="px-6 py-4 border-b">
|
||||
<h2 className="text-lg font-semibold text-gray-900">키 상세 정보</h2>
|
||||
</div>
|
||||
<div className="px-6 py-4 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500">Key Name</label>
|
||||
<p className="text-gray-900">{selectedKeyDetail.keyName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500">User</label>
|
||||
<p className="text-gray-900">{selectedKeyDetail.userName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500">Prefix</label>
|
||||
<p className="font-mono text-gray-900">{selectedKeyDetail.apiKeyPrefix}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500">Status</label>
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
STATUS_BADGE[selectedKeyDetail.status] || 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{selectedKeyDetail.status}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500">Expires At</label>
|
||||
<p className="text-gray-900">{formatDateTime(selectedKeyDetail.expiresAt)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500">Last Used</label>
|
||||
<p className="text-gray-900">{formatDateTime(selectedKeyDetail.lastUsedAt)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500">Created At</label>
|
||||
<p className="text-gray-900">{formatDateTime(selectedKeyDetail.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 mb-1">Decrypted Key</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-gray-100 px-3 py-2 rounded-lg text-sm font-mono break-all">
|
||||
{selectedKeyDetail.decryptedKey}
|
||||
</code>
|
||||
<button
|
||||
onClick={handleCopyDecryptedKey}
|
||||
className="shrink-0 bg-blue-600 hover:bg-blue-700 text-white px-3 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
{detailCopied ? '복사됨!' : '복사'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t flex justify-end">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsDetailModalOpen(false);
|
||||
setSelectedKeyDetail(null);
|
||||
}}
|
||||
className="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Permission Modal */}
|
||||
{isPermissionModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl mx-4 max-h-[90vh] flex flex-col">
|
||||
<div className="px-6 py-4 border-b">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
권한 관리 - {permissionKeyName}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="px-6 py-4 overflow-y-auto flex-1">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm mb-4">{error}</div>
|
||||
)}
|
||||
<p className="text-sm text-gray-500 mb-3">
|
||||
{permissionApiIds.size}개 API 선택됨
|
||||
</p>
|
||||
<div className="border rounded-lg p-3">
|
||||
{renderApiCheckboxes(permissionApiIds, handleTogglePermissionApi)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsPermissionModalOpen(false);
|
||||
setPermissionKeyId(null);
|
||||
setError(null);
|
||||
}}
|
||||
className="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSavePermissions}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Raw Key Modal (after approve) */}
|
||||
{rawKeyModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-lg mx-4">
|
||||
<div className="px-6 py-4 border-b">
|
||||
<h2 className="text-lg font-semibold text-gray-900">API Key 생성 완료</h2>
|
||||
</div>
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
<div className="p-3 bg-yellow-50 border border-yellow-200 text-yellow-800 rounded-lg text-sm">
|
||||
이 키는 다시 표시되지 않습니다. 안전한 곳에 보관하세요.
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Key Name</label>
|
||||
<p className="text-gray-900">{rawKeyModal.keyName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">API Key</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-gray-100 px-3 py-2 rounded-lg text-sm font-mono break-all">
|
||||
{rawKeyModal.rawKey}
|
||||
</code>
|
||||
<button
|
||||
onClick={handleCopyRawKey}
|
||||
className="shrink-0 bg-blue-600 hover:bg-blue-700 text-white px-3 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
{rawKeyCopied ? '복사됨!' : '복사'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t flex justify-end">
|
||||
<button
|
||||
onClick={() => {
|
||||
setRawKeyModal(null);
|
||||
setRawKeyCopied(false);
|
||||
}}
|
||||
className="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KeyAdminPage;
|
||||
399
frontend/src/pages/apikeys/KeyRequestPage.tsx
Normal file
399
frontend/src/pages/apikeys/KeyRequestPage.tsx
Normal file
@ -0,0 +1,399 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { ServiceInfo, ServiceApi } from '../../types/service';
|
||||
import { getServices, getServiceApis } from '../../services/serviceService';
|
||||
import { createKeyRequest } from '../../services/apiKeyService';
|
||||
|
||||
const METHOD_COLOR: Record<string, string> = {
|
||||
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 KeyRequestPage = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [services, setServices] = useState<ServiceInfo[]>([]);
|
||||
const [serviceApisMap, setServiceApisMap] = useState<Record<number, ServiceApi[]>>({});
|
||||
const [expandedServices, setExpandedServices] = useState<Set<number>>(new Set());
|
||||
const [selectedApiIds, setSelectedApiIds] = useState<Set<number>>(new Set());
|
||||
const [keyName, setKeyName] = useState('');
|
||||
const [purpose, setPurpose] = useState('');
|
||||
const [serviceIp, setServiceIp] = useState('');
|
||||
const [servicePurpose, setServicePurpose] = useState('');
|
||||
const [dailyRequestEstimate, setDailyRequestEstimate] = useState('');
|
||||
const [usagePeriodMode, setUsagePeriodMode] = useState<'preset' | 'custom'>('preset');
|
||||
const [usageFromDate, setUsageFromDate] = useState('');
|
||||
const [usageToDate, setUsageToDate] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const servicesRes = await getServices();
|
||||
if (servicesRes.success && servicesRes.data) {
|
||||
const activeServices = servicesRes.data.filter((s) => s.isActive);
|
||||
setServices(activeServices);
|
||||
|
||||
const apisMap: Record<number, ServiceApi[]> = {};
|
||||
await Promise.all(
|
||||
activeServices.map(async (service) => {
|
||||
const apisRes = await getServiceApis(service.serviceId);
|
||||
if (apisRes.success && apisRes.data) {
|
||||
apisMap[service.serviceId] = apisRes.data.filter((a) => a.isActive);
|
||||
}
|
||||
}),
|
||||
);
|
||||
setServiceApisMap(apisMap);
|
||||
} else {
|
||||
setError(servicesRes.message || '서비스 목록을 불러오는데 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
setError('서비스 목록을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleToggleService = (serviceId: number) => {
|
||||
setExpandedServices((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(serviceId)) {
|
||||
next.delete(serviceId);
|
||||
} else {
|
||||
next.add(serviceId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleApi = (apiId: number) => {
|
||||
setSelectedApiIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(apiId)) {
|
||||
next.delete(apiId);
|
||||
} else {
|
||||
next.add(apiId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleAllServiceApis = (serviceId: number) => {
|
||||
const apis = serviceApisMap[serviceId] || [];
|
||||
const allSelected = apis.every((a) => selectedApiIds.has(a.apiId));
|
||||
|
||||
setSelectedApiIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
apis.forEach((a) => {
|
||||
if (allSelected) {
|
||||
next.delete(a.apiId);
|
||||
} else {
|
||||
next.add(a.apiId);
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handlePresetPeriod = (months: number) => {
|
||||
const from = new Date();
|
||||
const to = new Date();
|
||||
to.setMonth(to.getMonth() + months);
|
||||
setUsageFromDate(from.toISOString().split('T')[0]);
|
||||
setUsageToDate(to.toISOString().split('T')[0]);
|
||||
setUsagePeriodMode('preset');
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (selectedApiIds.size === 0) {
|
||||
setError('최소 하나의 API를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
if (!usageFromDate || !usageToDate) {
|
||||
setError('사용 기간을 설정해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const res = await createKeyRequest({
|
||||
keyName,
|
||||
purpose: purpose || undefined,
|
||||
requestedApiIds: Array.from(selectedApiIds),
|
||||
serviceIp: serviceIp || undefined,
|
||||
servicePurpose: servicePurpose || undefined,
|
||||
dailyRequestEstimate: dailyRequestEstimate ? Number(dailyRequestEstimate) : undefined,
|
||||
usageFromDate,
|
||||
usageToDate,
|
||||
});
|
||||
if (res.success) {
|
||||
setSuccess(true);
|
||||
} else {
|
||||
setError(res.message || 'API Key 신청에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
setError('API Key 신청에 실패했습니다.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-center py-10 text-gray-500">로딩 중...</div>;
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="max-w-lg mx-auto mt-10 text-center">
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-6">
|
||||
<h2 className="text-lg font-semibold text-green-800 mb-2">신청이 완료되었습니다</h2>
|
||||
<p className="text-green-700 text-sm mb-4">
|
||||
관리자 승인 후 API Key가 생성됩니다.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate('/apikeys/my-keys')}
|
||||
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
내 키 목록으로 이동
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">API Key 신청</h1>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Key Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={keyName}
|
||||
onChange={(e) => setKeyName(e.target.value)}
|
||||
required
|
||||
placeholder="API Key 이름을 입력하세요"
|
||||
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">사용 목적</label>
|
||||
<textarea
|
||||
value={purpose}
|
||||
onChange={(e) => setPurpose(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="사용 목적을 입력하세요"
|
||||
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
사용 기간 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<button type="button" onClick={() => handlePresetPeriod(3)}
|
||||
disabled={usagePeriodMode === 'custom'}
|
||||
className={`px-3 py-1.5 text-sm rounded-lg border ${usagePeriodMode === 'custom' ? 'opacity-40 cursor-not-allowed' : 'hover:bg-blue-50'}`}>
|
||||
3개월
|
||||
</button>
|
||||
<button type="button" onClick={() => handlePresetPeriod(6)}
|
||||
disabled={usagePeriodMode === 'custom'}
|
||||
className={`px-3 py-1.5 text-sm rounded-lg border ${usagePeriodMode === 'custom' ? 'opacity-40 cursor-not-allowed' : 'hover:bg-blue-50'}`}>
|
||||
6개월
|
||||
</button>
|
||||
<button type="button" onClick={() => handlePresetPeriod(9)}
|
||||
disabled={usagePeriodMode === 'custom'}
|
||||
className={`px-3 py-1.5 text-sm rounded-lg border ${usagePeriodMode === 'custom' ? 'opacity-40 cursor-not-allowed' : 'hover:bg-blue-50'}`}>
|
||||
9개월
|
||||
</button>
|
||||
<span className="text-gray-400 mx-1">|</span>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer select-none">
|
||||
직접 선택
|
||||
<button type="button"
|
||||
onClick={() => setUsagePeriodMode(usagePeriodMode === 'custom' ? 'preset' : 'custom')}
|
||||
className={`relative w-10 h-5 rounded-full transition-colors ${usagePeriodMode === 'custom' ? 'bg-blue-600' : 'bg-gray-300'}`}>
|
||||
<span className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform ${usagePeriodMode === 'custom' ? 'translate-x-5' : ''}`} />
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="date" value={usageFromDate}
|
||||
onChange={(e) => setUsageFromDate(e.target.value)}
|
||||
readOnly={usagePeriodMode !== 'custom'}
|
||||
className={`border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none ${usagePeriodMode !== 'custom' ? 'bg-gray-50 text-gray-500' : ''}`} />
|
||||
<span className="text-gray-500">~</span>
|
||||
<input type="date" value={usageToDate}
|
||||
onChange={(e) => setUsageToDate(e.target.value)}
|
||||
readOnly={usagePeriodMode !== 'custom'}
|
||||
className={`border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none ${usagePeriodMode !== 'custom' ? 'bg-gray-50 text-gray-500' : ''}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
서비스 IP <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" value={serviceIp}
|
||||
onChange={(e) => setServiceIp(e.target.value)}
|
||||
required placeholder="예: 192.168.1.100"
|
||||
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none" />
|
||||
<p className="text-xs text-gray-400 mt-1">발급받은 API Key로 프록시 서버에 요청하는 서비스 IP</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
서비스 용도 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select value={servicePurpose}
|
||||
onChange={(e) => setServicePurpose(e.target.value)}
|
||||
required
|
||||
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none">
|
||||
<option value="">선택하세요</option>
|
||||
<option value="로컬 환경">로컬 환경</option>
|
||||
<option value="개발 서버">개발 서버</option>
|
||||
<option value="검증 서버">검증 서버</option>
|
||||
<option value="운영 서버">운영 서버</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
하루 예상 요청량 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select value={dailyRequestEstimate}
|
||||
onChange={(e) => setDailyRequestEstimate(e.target.value)}
|
||||
required
|
||||
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none">
|
||||
<option value="">선택하세요</option>
|
||||
<option value="100">100 이하</option>
|
||||
<option value="500">100~500</option>
|
||||
<option value="1000">500~1,000</option>
|
||||
<option value="5000">1,000~5,000</option>
|
||||
<option value="10000">5,000~10,000</option>
|
||||
<option value="50000">10,000 이상</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow mb-6">
|
||||
<div className="px-6 py-4 border-b">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
API 선택 <span className="text-sm font-normal text-gray-500">({selectedApiIds.size}개 선택됨)</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-200">
|
||||
{services.map((service) => {
|
||||
const apis = serviceApisMap[service.serviceId] || [];
|
||||
const isExpanded = expandedServices.has(service.serviceId);
|
||||
const selectedCount = apis.filter((a) => selectedApiIds.has(a.apiId)).length;
|
||||
const allSelected = apis.length > 0 && apis.every((a) => selectedApiIds.has(a.apiId));
|
||||
|
||||
return (
|
||||
<div key={service.serviceId}>
|
||||
<div
|
||||
className="px-6 py-3 flex items-center justify-between cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => handleToggleService(service.serviceId)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-gray-400 text-sm">{isExpanded ? '\u25BC' : '\u25B6'}</span>
|
||||
<span className="font-medium text-gray-900">{service.serviceName}</span>
|
||||
{selectedCount > 0 && (
|
||||
<span className="text-xs bg-blue-100 text-blue-800 px-2 py-0.5 rounded-full">
|
||||
{selectedCount}/{apis.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">{apis.length}개 API</span>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="px-6 pb-3">
|
||||
{apis.length > 0 && (
|
||||
<div className="mb-2 pl-6">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-600 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
onChange={() => handleToggleAllServiceApis(service.serviceId)}
|
||||
className="rounded"
|
||||
/>
|
||||
전체 선택
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1 pl-6">
|
||||
{apis.map((api) => (
|
||||
<label
|
||||
key={api.apiId}
|
||||
className="flex items-center gap-2 py-1 cursor-pointer hover:bg-gray-50 rounded px-2"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedApiIds.has(api.apiId)}
|
||||
onChange={() => handleToggleApi(api.apiId)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded text-xs font-bold ${
|
||||
METHOD_COLOR[api.apiMethod] || 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{api.apiMethod}
|
||||
</span>
|
||||
<span className="font-mono text-sm text-gray-700">{api.apiPath}</span>
|
||||
<span className="text-sm text-gray-500">- {api.apiName}</span>
|
||||
</label>
|
||||
))}
|
||||
{apis.length === 0 && (
|
||||
<p className="text-sm text-gray-400 py-1">등록된 API가 없습니다.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{services.length === 0 && (
|
||||
<div className="px-6 py-8 text-center text-gray-400">
|
||||
등록된 서비스가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="bg-blue-600 hover:bg-blue-700 disabled:bg-blue-300 text-white px-6 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
{isSubmitting ? '신청 중...' : '신청하기'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KeyRequestPage;
|
||||
273
frontend/src/pages/apikeys/MyKeysPage.tsx
Normal file
273
frontend/src/pages/apikeys/MyKeysPage.tsx
Normal file
@ -0,0 +1,273 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { ApiKey } from '../../types/apikey';
|
||||
import { getMyKeys, createKey, revokeKey } from '../../services/apiKeyService';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
ACTIVE: 'bg-green-100 text-green-800',
|
||||
PENDING: 'bg-yellow-100 text-yellow-800',
|
||||
REVOKED: 'bg-red-100 text-red-800',
|
||||
EXPIRED: 'bg-gray-100 text-gray-800',
|
||||
INACTIVE: 'bg-gray-100 text-gray-800',
|
||||
};
|
||||
|
||||
const formatDateTime = (dateStr: string | null): string => {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleString('ko-KR');
|
||||
};
|
||||
|
||||
const MyKeysPage = () => {
|
||||
const { user } = useAuth();
|
||||
const isAdmin = user?.role === 'ADMIN';
|
||||
|
||||
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [keyName, setKeyName] = useState('');
|
||||
|
||||
const [rawKeyModal, setRawKeyModal] = useState<{ keyName: string; rawKey: string } | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const fetchKeys = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await getMyKeys();
|
||||
if (res.success && res.data) {
|
||||
setKeys(res.data);
|
||||
} else {
|
||||
setError(res.message || 'API Key 목록을 불러오는데 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
setError('API Key 목록을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchKeys();
|
||||
}, []);
|
||||
|
||||
const handleRevoke = async (key: ApiKey) => {
|
||||
if (!window.confirm(`'${key.keyName}' 키를 폐기하시겠습니까? 이 작업은 되돌릴 수 없습니다.`)) return;
|
||||
|
||||
try {
|
||||
const res = await revokeKey(key.apiKeyId);
|
||||
if (res.success) {
|
||||
await fetchKeys();
|
||||
} else {
|
||||
setError(res.message || '키 폐기에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
setError('키 폐기에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await createKey({ keyName });
|
||||
if (res.success && res.data) {
|
||||
setIsCreateModalOpen(false);
|
||||
setKeyName('');
|
||||
setRawKeyModal({ keyName: res.data.keyName, rawKey: res.data.rawKey });
|
||||
await fetchKeys();
|
||||
} else {
|
||||
setError(res.message || 'API Key 생성에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
setError('API Key 생성에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyRawKey = async () => {
|
||||
if (!rawKeyModal) return;
|
||||
await navigator.clipboard.writeText(rawKeyModal.rawKey);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center py-10 text-gray-500">로딩 중...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">My API Keys</h1>
|
||||
<div className="flex gap-2">
|
||||
{!isAdmin && (
|
||||
<Link
|
||||
to="/apikeys/request"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
API Key 신청
|
||||
</Link>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setKeyName('');
|
||||
setError(null);
|
||||
setIsCreateModalOpen(true);
|
||||
}}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
API Key 생성
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && !isCreateModalOpen && (
|
||||
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto bg-white rounded-lg shadow">
|
||||
<table className="w-full divide-y divide-gray-200 text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Key Name</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Prefix</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Status</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Expires At</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Last Used At</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Created At</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{keys.map((key) => (
|
||||
<tr key={key.apiKeyId} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3">{key.keyName}</td>
|
||||
<td className="px-4 py-3 font-mono text-gray-600">{key.apiKeyPrefix}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
STATUS_BADGE[key.status] || 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{key.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500">{formatDateTime(key.expiresAt)}</td>
|
||||
<td className="px-4 py-3 text-gray-500">{formatDateTime(key.lastUsedAt)}</td>
|
||||
<td className="px-4 py-3 text-gray-500">{formatDateTime(key.createdAt)}</td>
|
||||
<td className="px-4 py-3">
|
||||
{key.status === 'ACTIVE' && (
|
||||
<button
|
||||
onClick={() => handleRevoke(key)}
|
||||
className="text-red-600 hover:text-red-800 font-medium text-sm"
|
||||
>
|
||||
폐기
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{keys.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-gray-400">
|
||||
등록된 API Key가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{isCreateModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
|
||||
<div className="px-6 py-4 border-b">
|
||||
<h2 className="text-lg font-semibold text-gray-900">API Key 생성</h2>
|
||||
</div>
|
||||
<form onSubmit={handleCreateSubmit}>
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Key Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={keyName}
|
||||
onChange={(e) => setKeyName(e.target.value)}
|
||||
required
|
||||
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCreateModalOpen(false)}
|
||||
className="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
생성
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rawKeyModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-lg mx-4">
|
||||
<div className="px-6 py-4 border-b">
|
||||
<h2 className="text-lg font-semibold text-gray-900">API Key 생성 완료</h2>
|
||||
</div>
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
<div className="p-3 bg-yellow-50 border border-yellow-200 text-yellow-800 rounded-lg text-sm">
|
||||
이 키는 다시 표시되지 않습니다. 안전한 곳에 보관하세요.
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Key Name</label>
|
||||
<p className="text-gray-900">{rawKeyModal.keyName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">API Key</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-gray-100 px-3 py-2 rounded-lg text-sm font-mono break-all">
|
||||
{rawKeyModal.rawKey}
|
||||
</code>
|
||||
<button
|
||||
onClick={handleCopyRawKey}
|
||||
className="shrink-0 bg-blue-600 hover:bg-blue-700 text-white px-3 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
{copied ? '복사됨!' : '복사'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t flex justify-end">
|
||||
<button
|
||||
onClick={() => {
|
||||
setRawKeyModal(null);
|
||||
setCopied(false);
|
||||
}}
|
||||
className="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyKeysPage;
|
||||
210
frontend/src/pages/monitoring/RequestLogDetailPage.tsx
Normal file
210
frontend/src/pages/monitoring/RequestLogDetailPage.tsx
Normal file
@ -0,0 +1,210 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import type { RequestLog } from '../../types/monitoring';
|
||||
import { getLogDetail } from '../../services/monitoringService';
|
||||
|
||||
const METHOD_COLOR: Record<string, string> = {
|
||||
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 STATUS_BADGE: Record<string, string> = {
|
||||
SUCCESS: 'bg-green-100 text-green-800',
|
||||
DENIED: 'bg-red-100 text-red-800',
|
||||
EXPIRED: 'bg-orange-100 text-orange-800',
|
||||
INVALID_KEY: 'bg-red-100 text-red-800',
|
||||
FAILED: 'bg-gray-100 text-gray-800',
|
||||
};
|
||||
|
||||
const formatDateTime = (dateStr: string): string => {
|
||||
const d = new Date(dateStr);
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
const hours = String(d.getHours()).padStart(2, '0');
|
||||
const minutes = String(d.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(d.getSeconds()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
};
|
||||
|
||||
const formatJson = (str: string | null): string | null => {
|
||||
if (!str) return null;
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(str), null, 2);
|
||||
} catch {
|
||||
return str;
|
||||
}
|
||||
};
|
||||
|
||||
const RequestLogDetailPage = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [log, setLog] = useState<RequestLog | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
|
||||
const fetchDetail = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await getLogDetail(Number(id));
|
||||
if (res.success && res.data) {
|
||||
setLog(res.data);
|
||||
} else {
|
||||
setError(res.message || '로그 상세 조회에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
setError('로그 상세 조회에 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDetail();
|
||||
}, [id]);
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center py-10 text-gray-500">로딩 중...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="text-blue-600 hover:text-blue-800 font-medium mb-4"
|
||||
>
|
||||
← 목록으로
|
||||
</button>
|
||||
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!log) return null;
|
||||
|
||||
const formattedHeaders = formatJson(log.requestHeaders);
|
||||
const formattedParams = formatJson(log.requestParams);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="text-blue-600 hover:text-blue-800 font-medium mb-6"
|
||||
>
|
||||
← 목록으로
|
||||
</button>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">기본 정보</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-gray-500">요청 시간</span>
|
||||
<span className="text-sm text-gray-900">{formatDateTime(log.requestedAt)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-gray-500">요청</span>
|
||||
<span className="text-sm text-gray-900">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded text-xs font-bold mr-2 ${
|
||||
METHOD_COLOR[log.requestMethod] || 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{log.requestMethod}
|
||||
</span>
|
||||
{log.requestUrl}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-gray-500">응답 코드</span>
|
||||
<span className="text-sm text-gray-900">
|
||||
{log.responseStatus != null ? log.responseStatus : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-gray-500">응답시간(ms)</span>
|
||||
<span className="text-sm text-gray-900">
|
||||
{log.responseTime != null ? log.responseTime : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-gray-500">응답크기(bytes)</span>
|
||||
<span className="text-sm text-gray-900">
|
||||
{log.responseSize != null ? log.responseSize : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-gray-500">상태</span>
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
STATUS_BADGE[log.requestStatus] || 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{log.requestStatus}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-gray-500">서비스</span>
|
||||
<span className="text-sm text-gray-900">{log.serviceName || '-'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-gray-500">API Key</span>
|
||||
<span className="text-sm text-gray-900 font-mono">
|
||||
{log.apiKeyPrefix || '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-gray-500">사용자</span>
|
||||
<span className="text-sm text-gray-900">{log.userName || '-'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-gray-500">IP</span>
|
||||
<span className="text-sm text-gray-900 font-mono">{log.requestIp}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 요청 정보 */}
|
||||
{(formattedHeaders || formattedParams) && (
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">요청 정보</h2>
|
||||
{formattedHeaders && (
|
||||
<div className="mb-4">
|
||||
<span className="block text-sm font-medium text-gray-500 mb-1">Request Headers</span>
|
||||
<pre className="bg-gray-50 rounded-lg p-4 text-sm text-gray-800 overflow-x-auto">
|
||||
{formattedHeaders}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{formattedParams && (
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-gray-500 mb-1">Request Params</span>
|
||||
<pre className="bg-gray-50 rounded-lg p-4 text-sm text-gray-800 overflow-x-auto">
|
||||
{formattedParams}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 에러 정보 */}
|
||||
{log.errorMessage && (
|
||||
<div className="bg-red-50 rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold text-red-900 mb-2">에러 정보</h2>
|
||||
<p className="text-sm text-red-800">{log.errorMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RequestLogDetailPage;
|
||||
364
frontend/src/pages/monitoring/RequestLogsPage.tsx
Normal file
364
frontend/src/pages/monitoring/RequestLogsPage.tsx
Normal file
@ -0,0 +1,364 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { RequestLog, PageResponse } from '../../types/monitoring';
|
||||
import type { ServiceInfo } from '../../types/service';
|
||||
import { searchLogs } from '../../services/monitoringService';
|
||||
import { getServices } from '../../services/serviceService';
|
||||
|
||||
const METHOD_COLOR: Record<string, string> = {
|
||||
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 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', 'FAIL', 'DENIED', 'EXPIRED', 'INVALID_KEY', 'ERROR', 'FAILED'];
|
||||
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE'];
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
|
||||
const getTodayString = (): string => {
|
||||
const d = new Date();
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const formatDateTime = (dateStr: string): string => {
|
||||
const d = new Date(dateStr);
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
const hours = String(d.getHours()).padStart(2, '0');
|
||||
const minutes = String(d.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(d.getSeconds()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
};
|
||||
|
||||
const RequestLogsPage = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [startDate, setStartDate] = useState(getTodayString());
|
||||
const [endDate, setEndDate] = useState(getTodayString());
|
||||
const [serviceId, setServiceId] = useState('');
|
||||
const [requestStatus, setRequestStatus] = useState('');
|
||||
const [requestMethod, setRequestMethod] = useState('');
|
||||
const [requestIp, setRequestIp] = useState('');
|
||||
|
||||
const [services, setServices] = useState<ServiceInfo[]>([]);
|
||||
const [result, setResult] = useState<PageResponse<RequestLog> | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
|
||||
const fetchServices = async () => {
|
||||
try {
|
||||
const res = await getServices();
|
||||
if (res.success && res.data) {
|
||||
setServices(res.data);
|
||||
}
|
||||
} catch {
|
||||
// 서비스 목록 로딩 실패는 무시
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = async (page: number) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setCurrentPage(page);
|
||||
|
||||
try {
|
||||
const params: Record<string, string | number | undefined> = {
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
serviceId: serviceId ? Number(serviceId) : undefined,
|
||||
requestStatus: requestStatus || undefined,
|
||||
requestMethod: requestMethod || undefined,
|
||||
requestIp: requestIp || undefined,
|
||||
page,
|
||||
size: DEFAULT_PAGE_SIZE,
|
||||
};
|
||||
const res = await searchLogs(params);
|
||||
if (res.success && res.data) {
|
||||
setResult(res.data);
|
||||
} else {
|
||||
setError(res.message || '로그 조회에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
setError('로그 조회에 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setStartDate(getTodayString());
|
||||
setEndDate(getTodayString());
|
||||
setServiceId('');
|
||||
setRequestStatus('');
|
||||
setRequestMethod('');
|
||||
setRequestIp('');
|
||||
setCurrentPage(0);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchServices();
|
||||
handleSearch(0);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleResetAndSearch = () => {
|
||||
handleReset();
|
||||
// 초기화 후 기본값으로 검색 (setState는 비동기이므로 직접 호출)
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setCurrentPage(0);
|
||||
const today = getTodayString();
|
||||
const params: Record<string, string | number | undefined> = {
|
||||
startDate: today,
|
||||
endDate: today,
|
||||
page: 0,
|
||||
size: DEFAULT_PAGE_SIZE,
|
||||
};
|
||||
searchLogs(params)
|
||||
.then((res) => {
|
||||
if (res.success && res.data) {
|
||||
setResult(res.data);
|
||||
} else {
|
||||
setError(res.message || '로그 조회에 실패했습니다.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError('로그 조회에 실패했습니다.');
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleRowClick = (logId: number) => {
|
||||
navigate(`/monitoring/request-logs/${logId}`);
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
if (currentPage > 0) {
|
||||
handleSearch(currentPage - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (result && currentPage < result.totalPages - 1) {
|
||||
handleSearch(currentPage + 1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Request Logs</h1>
|
||||
|
||||
{/* Search Form */}
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">기간</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="flex-1 border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
<span className="text-gray-500">~</span>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="flex-1 border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">서비스</label>
|
||||
<select
|
||||
value={serviceId}
|
||||
onChange={(e) => setServiceId(e.target.value)}
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">전체</option>
|
||||
{services.map((s) => (
|
||||
<option key={s.serviceId} value={s.serviceId}>
|
||||
{s.serviceName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">상태</label>
|
||||
<select
|
||||
value={requestStatus}
|
||||
onChange={(e) => setRequestStatus(e.target.value)}
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">전체</option>
|
||||
{REQUEST_STATUSES.map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">HTTP Method</label>
|
||||
<select
|
||||
value={requestMethod}
|
||||
onChange={(e) => setRequestMethod(e.target.value)}
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">전체</option>
|
||||
{HTTP_METHODS.map((m) => (
|
||||
<option key={m} value={m}>{m}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">IP</label>
|
||||
<input
|
||||
type="text"
|
||||
value={requestIp}
|
||||
onChange={(e) => setRequestIp(e.target.value)}
|
||||
placeholder="IP 주소"
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<button
|
||||
onClick={() => handleSearch(0)}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
검색
|
||||
</button>
|
||||
<button
|
||||
onClick={handleResetAndSearch}
|
||||
className="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
초기화
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Results Table */}
|
||||
<div className="overflow-x-auto bg-white rounded-lg shadow mb-6">
|
||||
{loading ? (
|
||||
<div className="text-center py-10 text-gray-500">로딩 중...</div>
|
||||
) : (
|
||||
<table className="w-full divide-y divide-gray-200 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">Method</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">URL</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">Status Code</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500">응답시간(ms)</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">IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{result && result.content.length > 0 ? (
|
||||
result.content.map((log) => (
|
||||
<tr
|
||||
key={log.logId}
|
||||
onClick={() => handleRowClick(log.logId)}
|
||||
className="cursor-pointer hover:bg-gray-50"
|
||||
>
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
{formatDateTime(log.requestedAt)}
|
||||
</td>
|
||||
<td className="px-4 py-3">{log.serviceName || '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded text-xs font-bold ${
|
||||
METHOD_COLOR[log.requestMethod] || 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{log.requestMethod}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500 truncate max-w-[250px]" title={log.requestUrl}>
|
||||
{log.requestUrl}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{log.responseStatus != null ? log.responseStatus : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{log.responseTime != null ? log.responseTime : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 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 font-mono text-xs">{log.requestIp}</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
|
||||
검색 결과가 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{result && result.totalElements > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">
|
||||
총 {result.totalElements}건 / {result.page + 1} / {result.totalPages} 페이지
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handlePrev}
|
||||
disabled={currentPage === 0}
|
||||
className="bg-white border border-gray-300 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={!result || currentPage >= result.totalPages - 1}
|
||||
className="bg-white border border-gray-300 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
||||
>
|
||||
다음
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RequestLogsPage;
|
||||
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;
|
||||
116
frontend/src/services/apiClient.ts
Normal file
116
frontend/src/services/apiClient.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import type { ApiResponse } from '../types/api';
|
||||
|
||||
const BASE_URL = '/snp-connection/api';
|
||||
|
||||
let isRefreshing = false;
|
||||
let refreshPromise: Promise<boolean> | null = null;
|
||||
|
||||
const getAccessToken = (): string | null => {
|
||||
return localStorage.getItem('snp_access_token');
|
||||
};
|
||||
|
||||
const getRefreshToken = (): string | null => {
|
||||
return localStorage.getItem('snp_refresh_token');
|
||||
};
|
||||
|
||||
const clearStorage = () => {
|
||||
localStorage.removeItem('snp_access_token');
|
||||
localStorage.removeItem('snp_refresh_token');
|
||||
localStorage.removeItem('snp_user');
|
||||
};
|
||||
|
||||
const attemptRefresh = async (): Promise<boolean> => {
|
||||
const refreshToken = getRefreshToken();
|
||||
if (!refreshToken) return false;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
});
|
||||
|
||||
if (!response.ok) return false;
|
||||
|
||||
const data = await response.json() as ApiResponse<{ accessToken: string }>;
|
||||
if (data.success && data.data?.accessToken) {
|
||||
localStorage.setItem('snp_access_token', data.data.accessToken);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleTokenRefresh = async (): Promise<boolean> => {
|
||||
if (isRefreshing && refreshPromise) {
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
isRefreshing = true;
|
||||
refreshPromise = attemptRefresh().finally(() => {
|
||||
isRefreshing = false;
|
||||
refreshPromise = null;
|
||||
});
|
||||
|
||||
return refreshPromise;
|
||||
};
|
||||
|
||||
const request = async <T>(url: string, options: RequestInit = {}): Promise<ApiResponse<T>> => {
|
||||
const token = getAccessToken();
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers as Record<string, string>,
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
let response = await fetch(`${BASE_URL}${url}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
const refreshed = await handleTokenRefresh();
|
||||
if (refreshed) {
|
||||
const newToken = getAccessToken();
|
||||
headers['Authorization'] = `Bearer ${newToken}`;
|
||||
response = await fetch(`${BASE_URL}${url}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
} else {
|
||||
clearStorage();
|
||||
window.location.href = '/snp-connection/login';
|
||||
return { success: false, message: 'Authentication failed' };
|
||||
}
|
||||
}
|
||||
|
||||
const data = await response.json() as ApiResponse<T>;
|
||||
return data;
|
||||
};
|
||||
|
||||
export const get = <T>(url: string): Promise<ApiResponse<T>> => {
|
||||
return request<T>(url, { method: 'GET' });
|
||||
};
|
||||
|
||||
export const post = <T>(url: string, body?: unknown): Promise<ApiResponse<T>> => {
|
||||
return request<T>(url, {
|
||||
method: 'POST',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
export const put = <T>(url: string, body?: unknown): Promise<ApiResponse<T>> => {
|
||||
return request<T>(url, {
|
||||
method: 'PUT',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
export const del = <T>(url: string): Promise<ApiResponse<T>> => {
|
||||
return request<T>(url, { method: 'DELETE' });
|
||||
};
|
||||
32
frontend/src/services/apiKeyService.ts
Normal file
32
frontend/src/services/apiKeyService.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { get, post, put } from './apiClient';
|
||||
import type {
|
||||
ApiKey,
|
||||
ApiKeyDetail,
|
||||
ApiKeyCreateResponse,
|
||||
CreateApiKeyRequest,
|
||||
ApiKeyRequest,
|
||||
ApiKeyRequestCreateDto,
|
||||
ApiKeyRequestReviewDto,
|
||||
Permission,
|
||||
UpdatePermissionsRequest,
|
||||
} from '../types/apikey';
|
||||
|
||||
// My Keys
|
||||
export const getMyKeys = () => get<ApiKey[]>('/keys');
|
||||
export const getAllKeys = () => get<ApiKey[]>('/keys/all');
|
||||
export const getKeyDetail = (id: number) => get<ApiKeyDetail>(`/keys/${id}`);
|
||||
export const createKey = (req: CreateApiKeyRequest) => post<ApiKeyCreateResponse>('/keys', req);
|
||||
export const revokeKey = (id: number) => put<void>(`/keys/${id}/revoke`);
|
||||
|
||||
// Requests
|
||||
export const createKeyRequest = (req: ApiKeyRequestCreateDto) =>
|
||||
post<ApiKeyRequest>('/keys/requests', req);
|
||||
export const getMyRequests = () => get<ApiKeyRequest[]>('/keys/requests/my');
|
||||
export const getAllRequests = () => get<ApiKeyRequest[]>('/keys/requests');
|
||||
export const reviewRequest = (id: number, req: ApiKeyRequestReviewDto) =>
|
||||
put<ApiKeyCreateResponse | null>(`/keys/requests/${id}/review`, req);
|
||||
|
||||
// Permissions
|
||||
export const getPermissions = (keyId: number) => get<Permission[]>(`/keys/${keyId}/permissions`);
|
||||
export const updatePermissions = (keyId: number, req: UpdatePermissionsRequest) =>
|
||||
put<Permission[]>(`/keys/${keyId}/permissions`, req);
|
||||
67
frontend/src/services/authService.ts
Normal file
67
frontend/src/services/authService.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { post } from './apiClient';
|
||||
import type { LoginRequest, LoginResponse, User } from '../types/auth';
|
||||
|
||||
const TOKEN_KEY = 'snp_access_token';
|
||||
const REFRESH_TOKEN_KEY = 'snp_refresh_token';
|
||||
const USER_KEY = 'snp_user';
|
||||
|
||||
export const login = async (req: LoginRequest): Promise<LoginResponse> => {
|
||||
const response = await post<LoginResponse>('/auth/login', req);
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
throw new Error(response.message || '로그인에 실패했습니다.');
|
||||
}
|
||||
|
||||
const data = response.data;
|
||||
localStorage.setItem(TOKEN_KEY, data.accessToken);
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, data.refreshToken);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify({
|
||||
loginId: data.loginId,
|
||||
userName: data.userName,
|
||||
role: data.role,
|
||||
}));
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
export const logout = async (): Promise<void> => {
|
||||
try {
|
||||
await post('/auth/logout');
|
||||
} finally {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
}
|
||||
};
|
||||
|
||||
export const refreshToken = async (): Promise<void> => {
|
||||
const storedRefreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
if (!storedRefreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
const response = await post<{ accessToken: string }>('/auth/refresh', {
|
||||
refreshToken: storedRefreshToken,
|
||||
});
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
throw new Error('Token refresh failed');
|
||||
}
|
||||
|
||||
localStorage.setItem(TOKEN_KEY, response.data.accessToken);
|
||||
};
|
||||
|
||||
export const getStoredUser = (): User | null => {
|
||||
const userStr = localStorage.getItem(USER_KEY);
|
||||
if (!userStr) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(userStr) as User;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const getAccessToken = (): string | null => {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
};
|
||||
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');
|
||||
8
frontend/src/services/heartbeatService.ts
Normal file
8
frontend/src/services/heartbeatService.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { get, post } from './apiClient';
|
||||
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`);
|
||||
12
frontend/src/services/monitoringService.ts
Normal file
12
frontend/src/services/monitoringService.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { get } from './apiClient';
|
||||
import type { RequestLog, PageResponse } from '../types/monitoring';
|
||||
|
||||
export const searchLogs = (params: Record<string, string | number | undefined>) => {
|
||||
const query = Object.entries(params)
|
||||
.filter(([, v]) => v !== undefined && v !== '')
|
||||
.map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`)
|
||||
.join('&');
|
||||
return get<PageResponse<RequestLog>>(`/monitoring/logs${query ? '?' + query : ''}`);
|
||||
};
|
||||
|
||||
export const getLogDetail = (id: number) => get<RequestLog>(`/monitoring/logs/${id}`);
|
||||
15
frontend/src/services/serviceService.ts
Normal file
15
frontend/src/services/serviceService.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { get, post, put } from './apiClient';
|
||||
import type {
|
||||
ServiceInfo,
|
||||
ServiceApi,
|
||||
CreateServiceRequest,
|
||||
UpdateServiceRequest,
|
||||
CreateServiceApiRequest,
|
||||
} from '../types/service';
|
||||
|
||||
export const getServices = () => get<ServiceInfo[]>('/services');
|
||||
export const createService = (req: CreateServiceRequest) => post<ServiceInfo>('/services', req);
|
||||
export const updateService = (id: number, req: UpdateServiceRequest) => put<ServiceInfo>(`/services/${id}`, req);
|
||||
export const getServiceApis = (serviceId: number) => get<ServiceApi[]>(`/services/${serviceId}/apis`);
|
||||
export const createServiceApi = (serviceId: number, req: CreateServiceApiRequest) =>
|
||||
post<ServiceApi>(`/services/${serviceId}/apis`, req);
|
||||
6
frontend/src/services/tenantService.ts
Normal file
6
frontend/src/services/tenantService.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { get, post, put } from './apiClient';
|
||||
import type { Tenant, CreateTenantRequest, UpdateTenantRequest } from '../types/tenant';
|
||||
|
||||
export const getTenants = () => get<Tenant[]>('/tenants');
|
||||
export const createTenant = (req: CreateTenantRequest) => post<Tenant>('/tenants', req);
|
||||
export const updateTenant = (id: number, req: UpdateTenantRequest) => put<Tenant>(`/tenants/${id}`, req);
|
||||
8
frontend/src/services/userService.ts
Normal file
8
frontend/src/services/userService.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { get, post, put, del } from './apiClient';
|
||||
import type { UserDetail, CreateUserRequest, UpdateUserRequest } from '../types/user';
|
||||
|
||||
export const getUsers = () => get<UserDetail[]>('/users');
|
||||
export const getUser = (id: number) => get<UserDetail>(`/users/${id}`);
|
||||
export const createUser = (req: CreateUserRequest) => post<UserDetail>('/users', req);
|
||||
export const updateUser = (id: number, req: UpdateUserRequest) => put<UserDetail>(`/users/${id}`, req);
|
||||
export const deactivateUser = (id: number) => del<void>(`/users/${id}`);
|
||||
78
frontend/src/store/AuthContext.tsx
Normal file
78
frontend/src/store/AuthContext.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import { createContext, useState, useEffect, useCallback } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { User } from '../types/auth';
|
||||
import * as authService from '../services/authService';
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
interface AuthContextValue extends AuthState {
|
||||
login: (loginId: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const AuthProvider = ({ children }: AuthProviderProps) => {
|
||||
const [state, setState] = useState<AuthState>({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const storedUser = authService.getStoredUser();
|
||||
const token = authService.getAccessToken();
|
||||
|
||||
if (storedUser && token) {
|
||||
setState({
|
||||
user: storedUser,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
});
|
||||
} else {
|
||||
setState({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (loginId: string, password: string) => {
|
||||
const response = await authService.login({ loginId, password });
|
||||
setState({
|
||||
user: {
|
||||
loginId: response.loginId,
|
||||
userName: response.userName,
|
||||
role: response.role,
|
||||
},
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await authService.logout();
|
||||
setState({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ ...state, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthProvider;
|
||||
5
frontend/src/types/api.ts
Normal file
5
frontend/src/types/api.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
data?: T;
|
||||
}
|
||||
82
frontend/src/types/apikey.ts
Normal file
82
frontend/src/types/apikey.ts
Normal file
@ -0,0 +1,82 @@
|
||||
export interface ApiKey {
|
||||
apiKeyId: number;
|
||||
keyName: string;
|
||||
apiKeyPrefix: string;
|
||||
maskedKey: string;
|
||||
status: 'PENDING' | 'ACTIVE' | 'INACTIVE' | 'EXPIRED' | 'REVOKED';
|
||||
expiresAt: string | null;
|
||||
lastUsedAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ApiKeyDetail extends ApiKey {
|
||||
decryptedKey: string;
|
||||
userId: number;
|
||||
userName: string;
|
||||
}
|
||||
|
||||
export interface ApiKeyCreateResponse {
|
||||
apiKeyId: number;
|
||||
keyName: string;
|
||||
rawKey: string;
|
||||
apiKeyPrefix: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface CreateApiKeyRequest {
|
||||
keyName: string;
|
||||
}
|
||||
|
||||
export interface ApiKeyRequest {
|
||||
requestId: number;
|
||||
userId: number;
|
||||
userName: string;
|
||||
keyName: string;
|
||||
purpose: string | null;
|
||||
requestedApiIds: number[];
|
||||
status: 'PENDING' | 'APPROVED' | 'REJECTED';
|
||||
reviewedByUserId: number | null;
|
||||
reviewerName: string | null;
|
||||
reviewComment: string | null;
|
||||
reviewedAt: string | null;
|
||||
createdAt: string;
|
||||
serviceIp: string | null;
|
||||
servicePurpose: string | null;
|
||||
dailyRequestEstimate: number | null;
|
||||
usageFromDate: string | null;
|
||||
usageToDate: string | null;
|
||||
}
|
||||
|
||||
export interface ApiKeyRequestCreateDto {
|
||||
keyName: string;
|
||||
purpose?: string;
|
||||
requestedApiIds: number[];
|
||||
serviceIp?: string;
|
||||
servicePurpose?: string;
|
||||
dailyRequestEstimate?: number;
|
||||
usageFromDate?: string;
|
||||
usageToDate?: string;
|
||||
}
|
||||
|
||||
export interface ApiKeyRequestReviewDto {
|
||||
status: 'APPROVED' | 'REJECTED';
|
||||
reviewComment?: string;
|
||||
adjustedApiIds?: number[];
|
||||
adjustedFromDate?: string;
|
||||
adjustedToDate?: string;
|
||||
}
|
||||
|
||||
export interface Permission {
|
||||
permissionId: number;
|
||||
apiId: number;
|
||||
apiPath: string;
|
||||
apiMethod: string;
|
||||
apiName: string;
|
||||
serviceName: string;
|
||||
isActive: boolean;
|
||||
grantedAt: string;
|
||||
}
|
||||
|
||||
export interface UpdatePermissionsRequest {
|
||||
apiIds: number[];
|
||||
}
|
||||
19
frontend/src/types/auth.ts
Normal file
19
frontend/src/types/auth.ts
Normal file
@ -0,0 +1,19 @@
|
||||
export interface LoginRequest {
|
||||
loginId: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
loginId: string;
|
||||
userName: string;
|
||||
role: string;
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
loginId: string;
|
||||
userName: string;
|
||||
role: string;
|
||||
}
|
||||
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;
|
||||
}
|
||||
37
frontend/src/types/monitoring.ts
Normal file
37
frontend/src/types/monitoring.ts
Normal file
@ -0,0 +1,37 @@
|
||||
export interface RequestLog {
|
||||
logId: number;
|
||||
requestUrl: string;
|
||||
requestMethod: string;
|
||||
requestStatus: string;
|
||||
requestIp: string;
|
||||
responseStatus: number | null;
|
||||
responseTime: number | null;
|
||||
responseSize: number | null;
|
||||
errorMessage: string | null;
|
||||
requestedAt: string;
|
||||
serviceId: number | null;
|
||||
serviceName: string | null;
|
||||
apiKeyPrefix: string | null;
|
||||
userName: string | null;
|
||||
requestHeaders: string | null;
|
||||
requestParams: string | null;
|
||||
}
|
||||
|
||||
export interface RequestLogSearch {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
serviceId?: number;
|
||||
requestStatus?: string;
|
||||
requestMethod?: string;
|
||||
requestIp?: string;
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface PageResponse<T> {
|
||||
content: T[];
|
||||
page: number;
|
||||
size: number;
|
||||
totalElements: number;
|
||||
totalPages: number;
|
||||
}
|
||||
95
frontend/src/types/service.ts
Normal file
95
frontend/src/types/service.ts
Normal file
@ -0,0 +1,95 @@
|
||||
export interface ServiceInfo {
|
||||
serviceId: number;
|
||||
serviceCode: string;
|
||||
serviceName: string;
|
||||
serviceUrl: string | null;
|
||||
description: string | null;
|
||||
healthCheckUrl: string | null;
|
||||
healthCheckInterval: number;
|
||||
healthStatus: 'UP' | 'DOWN' | 'UNKNOWN';
|
||||
healthCheckedAt: string | null;
|
||||
healthResponseTime: number | null;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ServiceApi {
|
||||
apiId: number;
|
||||
serviceId: number;
|
||||
apiPath: string;
|
||||
apiMethod: string;
|
||||
apiName: string;
|
||||
description: string | null;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CreateServiceRequest {
|
||||
serviceCode: string;
|
||||
serviceName: string;
|
||||
serviceUrl?: string;
|
||||
description?: string;
|
||||
healthCheckUrl?: string;
|
||||
healthCheckInterval?: number;
|
||||
}
|
||||
|
||||
export interface UpdateServiceRequest {
|
||||
serviceName?: string;
|
||||
serviceUrl?: string;
|
||||
description?: string;
|
||||
healthCheckUrl?: string;
|
||||
healthCheckInterval?: number;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateServiceApiRequest {
|
||||
apiPath: string;
|
||||
apiMethod: string;
|
||||
apiName: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface HeartbeatStatus {
|
||||
serviceId: number;
|
||||
serviceCode: string;
|
||||
serviceName: string;
|
||||
healthStatus: string;
|
||||
healthCheckedAt: string | null;
|
||||
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;
|
||||
previousStatus: string | null;
|
||||
currentStatus: string;
|
||||
responseTime: number | null;
|
||||
errorMessage: string | null;
|
||||
checkedAt: string;
|
||||
}
|
||||
21
frontend/src/types/tenant.ts
Normal file
21
frontend/src/types/tenant.ts
Normal file
@ -0,0 +1,21 @@
|
||||
export interface Tenant {
|
||||
tenantId: number;
|
||||
tenantCode: string;
|
||||
tenantName: string;
|
||||
description: string | null;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateTenantRequest {
|
||||
tenantCode: string;
|
||||
tenantName: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UpdateTenantRequest {
|
||||
tenantName: string;
|
||||
description?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
30
frontend/src/types/user.ts
Normal file
30
frontend/src/types/user.ts
Normal file
@ -0,0 +1,30 @@
|
||||
export interface UserDetail {
|
||||
userId: number;
|
||||
tenantId: number | null;
|
||||
tenantName: string | null;
|
||||
loginId: string;
|
||||
userName: string;
|
||||
email: string | null;
|
||||
role: string;
|
||||
isActive: boolean;
|
||||
lastLoginAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CreateUserRequest {
|
||||
tenantId?: number;
|
||||
loginId: string;
|
||||
password: string;
|
||||
userName: string;
|
||||
email?: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
tenantId?: number;
|
||||
userName?: string;
|
||||
email?: string;
|
||||
role?: string;
|
||||
password?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
19
pom.xml
19
pom.xml
@ -104,6 +104,25 @@
|
||||
<version>2.3.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- JWT (jjwt) -->
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>0.12.6</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>0.12.6</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
<version>0.12.6</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Test Dependencies -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
|
||||
@ -0,0 +1,114 @@
|
||||
package com.gcsc.connection.apikey.controller;
|
||||
|
||||
import com.gcsc.connection.apikey.dto.ApiKeyCreateResponse;
|
||||
import com.gcsc.connection.apikey.dto.ApiKeyDetailResponse;
|
||||
import com.gcsc.connection.apikey.dto.ApiKeyResponse;
|
||||
import com.gcsc.connection.apikey.dto.CreateApiKeyRequest;
|
||||
import com.gcsc.connection.apikey.dto.PermissionResponse;
|
||||
import com.gcsc.connection.apikey.dto.UpdatePermissionsRequest;
|
||||
import com.gcsc.connection.apikey.service.ApiKeyPermissionService;
|
||||
import com.gcsc.connection.apikey.service.ApiKeyService;
|
||||
import com.gcsc.connection.common.dto.ApiResponse;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* API Key 관리 API
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/keys")
|
||||
@RequiredArgsConstructor
|
||||
public class ApiKeyController {
|
||||
|
||||
private final ApiKeyService apiKeyService;
|
||||
private final ApiKeyPermissionService apiKeyPermissionService;
|
||||
|
||||
/**
|
||||
* 내 API Key 목록 조회
|
||||
*/
|
||||
@GetMapping
|
||||
public ResponseEntity<ApiResponse<List<ApiKeyResponse>>> getMyKeys() {
|
||||
Long userId = getCurrentUserId();
|
||||
List<ApiKeyResponse> keys = apiKeyService.getMyKeys(userId);
|
||||
return ResponseEntity.ok(ApiResponse.ok(keys));
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 API Key 목록 조회 (관리자용)
|
||||
*/
|
||||
@GetMapping("/all")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public ResponseEntity<ApiResponse<List<ApiKeyResponse>>> getAllKeys() {
|
||||
List<ApiKeyResponse> keys = apiKeyService.getAllKeys();
|
||||
return ResponseEntity.ok(ApiResponse.ok(keys));
|
||||
}
|
||||
|
||||
/**
|
||||
* API Key 상세 조회 (복호화된 키 포함, 관리자 전용)
|
||||
*/
|
||||
@GetMapping("/{id}")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public ResponseEntity<ApiResponse<ApiKeyDetailResponse>> getKeyDetail(@PathVariable Long id) {
|
||||
ApiKeyDetailResponse detail = apiKeyService.getKeyDetail(id);
|
||||
return ResponseEntity.ok(ApiResponse.ok(detail));
|
||||
}
|
||||
|
||||
/**
|
||||
* API Key 생성 (관리자 전용)
|
||||
*/
|
||||
@PostMapping
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public ResponseEntity<ApiResponse<ApiKeyCreateResponse>> createKey(
|
||||
@RequestBody @Valid CreateApiKeyRequest request) {
|
||||
Long userId = getCurrentUserId();
|
||||
ApiKeyCreateResponse response = apiKeyService.createKey(userId, request);
|
||||
return ResponseEntity.ok(ApiResponse.ok(response));
|
||||
}
|
||||
|
||||
/**
|
||||
* API Key 폐기
|
||||
*/
|
||||
@PutMapping("/{id}/revoke")
|
||||
public ResponseEntity<ApiResponse<Void>> revokeKey(@PathVariable Long id) {
|
||||
apiKeyService.revokeKey(id);
|
||||
return ResponseEntity.ok(ApiResponse.ok(null, "API Key가 폐기되었습니다"));
|
||||
}
|
||||
|
||||
/**
|
||||
* API Key 권한 목록 조회
|
||||
*/
|
||||
@GetMapping("/{id}/permissions")
|
||||
public ResponseEntity<ApiResponse<List<PermissionResponse>>> getPermissions(@PathVariable Long id) {
|
||||
List<PermissionResponse> permissions = apiKeyPermissionService.getPermissions(id);
|
||||
return ResponseEntity.ok(ApiResponse.ok(permissions));
|
||||
}
|
||||
|
||||
/**
|
||||
* API Key 권한 수정 (관리자/매니저 전용)
|
||||
*/
|
||||
@PutMapping("/{id}/permissions")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
|
||||
public ResponseEntity<ApiResponse<List<PermissionResponse>>> updatePermissions(
|
||||
@PathVariable Long id,
|
||||
@RequestBody @Valid UpdatePermissionsRequest request) {
|
||||
Long userId = getCurrentUserId();
|
||||
List<PermissionResponse> permissions = apiKeyPermissionService.updatePermissions(id, userId, request);
|
||||
return ResponseEntity.ok(ApiResponse.ok(permissions));
|
||||
}
|
||||
|
||||
private Long getCurrentUserId() {
|
||||
return Long.parseLong(SecurityContextHolder.getContext().getAuthentication().getName());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,91 @@
|
||||
package com.gcsc.connection.apikey.controller;
|
||||
|
||||
import com.gcsc.connection.apikey.dto.ApiKeyCreateResponse;
|
||||
import com.gcsc.connection.apikey.dto.ApiKeyRequestCreateDto;
|
||||
import com.gcsc.connection.apikey.dto.ApiKeyRequestResponse;
|
||||
import com.gcsc.connection.apikey.dto.ApiKeyRequestReviewDto;
|
||||
import com.gcsc.connection.apikey.service.ApiKeyRequestService;
|
||||
import com.gcsc.connection.common.dto.ApiResponse;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
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 Key 신청/심사 API
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/keys/requests")
|
||||
@RequiredArgsConstructor
|
||||
public class ApiKeyRequestController {
|
||||
|
||||
private final ApiKeyRequestService apiKeyRequestService;
|
||||
|
||||
/**
|
||||
* API Key 신청 생성
|
||||
*/
|
||||
@PostMapping
|
||||
public ResponseEntity<ApiResponse<ApiKeyRequestResponse>> createRequest(
|
||||
@RequestBody @Valid ApiKeyRequestCreateDto request) {
|
||||
Long userId = getCurrentUserId();
|
||||
ApiKeyRequestResponse response = apiKeyRequestService.createRequest(userId, request);
|
||||
return ResponseEntity.ok(ApiResponse.ok(response));
|
||||
}
|
||||
|
||||
/**
|
||||
* 내 신청 목록 조회
|
||||
*/
|
||||
@GetMapping("/my")
|
||||
public ResponseEntity<ApiResponse<List<ApiKeyRequestResponse>>> getMyRequests() {
|
||||
Long userId = getCurrentUserId();
|
||||
List<ApiKeyRequestResponse> responses = apiKeyRequestService.getMyRequests(userId);
|
||||
return ResponseEntity.ok(ApiResponse.ok(responses));
|
||||
}
|
||||
|
||||
/**
|
||||
* 신청 목록 조회 (관리자/매니저용)
|
||||
*/
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
|
||||
public ResponseEntity<ApiResponse<List<ApiKeyRequestResponse>>> getRequests(
|
||||
@RequestParam(required = false) String status) {
|
||||
List<ApiKeyRequestResponse> responses;
|
||||
if ("PENDING".equalsIgnoreCase(status)) {
|
||||
responses = apiKeyRequestService.getPendingRequests();
|
||||
} else {
|
||||
responses = apiKeyRequestService.getAllRequests();
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.ok(responses));
|
||||
}
|
||||
|
||||
/**
|
||||
* 신청 심사 (승인/거절)
|
||||
*/
|
||||
@PutMapping("/{id}/review")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
|
||||
public ResponseEntity<ApiResponse<ApiKeyCreateResponse>> reviewRequest(
|
||||
@PathVariable Long id,
|
||||
@RequestBody @Valid ApiKeyRequestReviewDto request) {
|
||||
Long reviewerId = getCurrentUserId();
|
||||
ApiKeyCreateResponse response = apiKeyRequestService.reviewRequest(id, reviewerId, request);
|
||||
if (response != null) {
|
||||
return ResponseEntity.ok(ApiResponse.ok(response, "신청이 승인되었습니다"));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.ok(null, "신청이 거절되었습니다"));
|
||||
}
|
||||
|
||||
private Long getCurrentUserId() {
|
||||
return Long.parseLong(SecurityContextHolder.getContext().getAuthentication().getName());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
package com.gcsc.connection.apikey.dto;
|
||||
|
||||
public record ApiKeyCreateResponse(
|
||||
Long apiKeyId,
|
||||
String keyName,
|
||||
String rawKey,
|
||||
String apiKeyPrefix,
|
||||
String status
|
||||
) {
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
package com.gcsc.connection.apikey.dto;
|
||||
|
||||
import com.gcsc.connection.apikey.entity.SnpApiKey;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record ApiKeyDetailResponse(
|
||||
Long apiKeyId,
|
||||
String keyName,
|
||||
String apiKeyPrefix,
|
||||
String decryptedKey,
|
||||
String status,
|
||||
Long userId,
|
||||
String userName,
|
||||
LocalDateTime expiresAt,
|
||||
LocalDateTime lastUsedAt,
|
||||
LocalDateTime createdAt
|
||||
) {
|
||||
|
||||
public static ApiKeyDetailResponse from(SnpApiKey key, String decryptedKey) {
|
||||
return new ApiKeyDetailResponse(
|
||||
key.getApiKeyId(),
|
||||
key.getKeyName(),
|
||||
key.getApiKeyPrefix(),
|
||||
decryptedKey,
|
||||
key.getStatus().name(),
|
||||
key.getUser().getUserId(),
|
||||
key.getUser().getUserName(),
|
||||
key.getExpiresAt(),
|
||||
key.getLastUsedAt(),
|
||||
key.getCreatedAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
package com.gcsc.connection.apikey.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record ApiKeyRequestCreateDto(
|
||||
@NotBlank String keyName,
|
||||
String purpose,
|
||||
List<Long> requestedApiIds,
|
||||
String serviceIp,
|
||||
String servicePurpose,
|
||||
Long dailyRequestEstimate,
|
||||
String usageFromDate,
|
||||
String usageToDate
|
||||
) {
|
||||
}
|
||||
@ -0,0 +1,71 @@
|
||||
package com.gcsc.connection.apikey.dto;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.gcsc.connection.apikey.entity.SnpApiKeyRequest;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public record ApiKeyRequestResponse(
|
||||
Long requestId,
|
||||
Long userId,
|
||||
String userName,
|
||||
String keyName,
|
||||
String purpose,
|
||||
List<Long> requestedApiIds,
|
||||
String status,
|
||||
Long reviewedByUserId,
|
||||
String reviewerName,
|
||||
String reviewComment,
|
||||
LocalDateTime reviewedAt,
|
||||
LocalDateTime createdAt,
|
||||
String serviceIp,
|
||||
String servicePurpose,
|
||||
Long dailyRequestEstimate,
|
||||
LocalDateTime usageFromDate,
|
||||
LocalDateTime usageToDate
|
||||
) {
|
||||
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
public static ApiKeyRequestResponse from(SnpApiKeyRequest request) {
|
||||
List<Long> apiIds = parseApiIds(request.getRequestedApis());
|
||||
Long reviewerUserId = request.getReviewedBy() != null
|
||||
? request.getReviewedBy().getUserId() : null;
|
||||
String reviewerName = request.getReviewedBy() != null
|
||||
? request.getReviewedBy().getUserName() : null;
|
||||
|
||||
return new ApiKeyRequestResponse(
|
||||
request.getRequestId(),
|
||||
request.getUser().getUserId(),
|
||||
request.getUser().getUserName(),
|
||||
request.getKeyName(),
|
||||
request.getPurpose(),
|
||||
apiIds,
|
||||
request.getStatus().name(),
|
||||
reviewerUserId,
|
||||
reviewerName,
|
||||
request.getReviewComment(),
|
||||
request.getReviewedAt(),
|
||||
request.getCreatedAt(),
|
||||
request.getServiceIp(),
|
||||
request.getServicePurpose(),
|
||||
request.getDailyRequestEstimate(),
|
||||
request.getUsageFromDate(),
|
||||
request.getUsageToDate()
|
||||
);
|
||||
}
|
||||
|
||||
private static List<Long> parseApiIds(String json) {
|
||||
if (json == null || json.isBlank()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
try {
|
||||
return OBJECT_MAPPER.readValue(json, new TypeReference<>() {});
|
||||
} catch (Exception e) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
package com.gcsc.connection.apikey.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record ApiKeyRequestReviewDto(
|
||||
@NotBlank String status,
|
||||
String reviewComment,
|
||||
List<Long> adjustedApiIds,
|
||||
String adjustedFromDate,
|
||||
String adjustedToDate
|
||||
) {
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
package com.gcsc.connection.apikey.dto;
|
||||
|
||||
import com.gcsc.connection.apikey.entity.SnpApiKey;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record ApiKeyResponse(
|
||||
Long apiKeyId,
|
||||
String keyName,
|
||||
String apiKeyPrefix,
|
||||
String maskedKey,
|
||||
String status,
|
||||
LocalDateTime expiresAt,
|
||||
LocalDateTime lastUsedAt,
|
||||
LocalDateTime createdAt
|
||||
) {
|
||||
|
||||
public static ApiKeyResponse from(SnpApiKey key) {
|
||||
String masked = key.getApiKeyPrefix() + "****...****";
|
||||
return new ApiKeyResponse(
|
||||
key.getApiKeyId(),
|
||||
key.getKeyName(),
|
||||
key.getApiKeyPrefix(),
|
||||
masked,
|
||||
key.getStatus().name(),
|
||||
key.getExpiresAt(),
|
||||
key.getLastUsedAt(),
|
||||
key.getCreatedAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
package com.gcsc.connection.apikey.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record CreateApiKeyRequest(
|
||||
@NotBlank String keyName
|
||||
) {
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
package com.gcsc.connection.apikey.dto;
|
||||
|
||||
import com.gcsc.connection.apikey.entity.SnpApiPermission;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record PermissionResponse(
|
||||
Long permissionId,
|
||||
Long apiId,
|
||||
String apiPath,
|
||||
String apiMethod,
|
||||
String apiName,
|
||||
String serviceName,
|
||||
Boolean isActive,
|
||||
LocalDateTime grantedAt
|
||||
) {
|
||||
|
||||
public static PermissionResponse from(SnpApiPermission perm) {
|
||||
return new PermissionResponse(
|
||||
perm.getPermissionId(),
|
||||
perm.getApi().getApiId(),
|
||||
perm.getApi().getApiPath(),
|
||||
perm.getApi().getApiMethod(),
|
||||
perm.getApi().getApiName(),
|
||||
perm.getApi().getService().getServiceName(),
|
||||
perm.getIsActive(),
|
||||
perm.getGrantedAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
package com.gcsc.connection.apikey.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record UpdatePermissionsRequest(
|
||||
List<Long> apiIds
|
||||
) {
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
package com.gcsc.connection.apikey.entity;
|
||||
|
||||
public enum ApiKeyStatus {
|
||||
PENDING,
|
||||
ACTIVE,
|
||||
INACTIVE,
|
||||
EXPIRED,
|
||||
REVOKED
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
package com.gcsc.connection.apikey.entity;
|
||||
|
||||
public enum KeyRequestStatus {
|
||||
PENDING,
|
||||
APPROVED,
|
||||
REJECTED
|
||||
}
|
||||
@ -0,0 +1,90 @@
|
||||
package com.gcsc.connection.apikey.entity;
|
||||
|
||||
import com.gcsc.connection.common.entity.BaseEntity;
|
||||
import com.gcsc.connection.user.entity.SnpUser;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Getter
|
||||
@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
|
||||
@Entity
|
||||
@Table(name = "snp_api_key", schema = "common")
|
||||
public class SnpApiKey extends BaseEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "api_key_id")
|
||||
private Long apiKeyId;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "user_id", nullable = false)
|
||||
private SnpUser user;
|
||||
|
||||
@Column(name = "api_key", length = 128, nullable = false, unique = true)
|
||||
private String apiKey;
|
||||
|
||||
@Column(name = "api_key_prefix", length = 10)
|
||||
private String apiKeyPrefix;
|
||||
|
||||
@Column(name = "key_name", length = 200, nullable = false)
|
||||
private String keyName;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", length = 20, nullable = false)
|
||||
private ApiKeyStatus status = ApiKeyStatus.PENDING;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "approved_by")
|
||||
private SnpUser approvedBy;
|
||||
|
||||
@Column(name = "approved_at")
|
||||
private LocalDateTime approvedAt;
|
||||
|
||||
@Column(name = "expires_at")
|
||||
private LocalDateTime expiresAt;
|
||||
|
||||
@Column(name = "last_used_at")
|
||||
private LocalDateTime lastUsedAt;
|
||||
|
||||
@Builder
|
||||
public SnpApiKey(SnpUser user, String apiKey, String apiKeyPrefix, String keyName,
|
||||
ApiKeyStatus status, SnpUser approvedBy, LocalDateTime approvedAt,
|
||||
LocalDateTime expiresAt) {
|
||||
this.user = user;
|
||||
this.apiKey = apiKey;
|
||||
this.apiKeyPrefix = apiKeyPrefix;
|
||||
this.keyName = keyName;
|
||||
this.status = status != null ? status : ApiKeyStatus.PENDING;
|
||||
this.approvedBy = approvedBy;
|
||||
this.approvedAt = approvedAt;
|
||||
this.expiresAt = expiresAt;
|
||||
}
|
||||
|
||||
public void revoke() {
|
||||
this.status = ApiKeyStatus.REVOKED;
|
||||
}
|
||||
|
||||
public void updateLastUsedAt() {
|
||||
this.lastUsedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
public void activate(SnpUser approvedBy) {
|
||||
this.status = ApiKeyStatus.ACTIVE;
|
||||
this.approvedBy = approvedBy;
|
||||
this.approvedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,108 @@
|
||||
package com.gcsc.connection.apikey.entity;
|
||||
|
||||
import com.gcsc.connection.common.entity.BaseEntity;
|
||||
import com.gcsc.connection.user.entity.SnpUser;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Getter
|
||||
@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
|
||||
@Entity
|
||||
@Table(name = "snp_api_key_request", schema = "common")
|
||||
public class SnpApiKeyRequest extends BaseEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "request_id")
|
||||
private Long requestId;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "user_id", nullable = false)
|
||||
private SnpUser user;
|
||||
|
||||
@Column(name = "key_name", length = 200, nullable = false)
|
||||
private String keyName;
|
||||
|
||||
@Column(name = "purpose", columnDefinition = "TEXT")
|
||||
private String purpose;
|
||||
|
||||
@Column(name = "requested_apis", columnDefinition = "TEXT")
|
||||
private String requestedApis;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", length = 20, nullable = false)
|
||||
private KeyRequestStatus status = KeyRequestStatus.PENDING;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "reviewed_by")
|
||||
private SnpUser reviewedBy;
|
||||
|
||||
@Column(name = "reviewed_at")
|
||||
private LocalDateTime reviewedAt;
|
||||
|
||||
@Column(name = "review_comment", columnDefinition = "TEXT")
|
||||
private String reviewComment;
|
||||
|
||||
@Column(name = "service_ip", length = 100)
|
||||
private String serviceIp;
|
||||
|
||||
@Column(name = "service_purpose", length = 50)
|
||||
private String servicePurpose;
|
||||
|
||||
@Column(name = "daily_request_estimate")
|
||||
private Long dailyRequestEstimate;
|
||||
|
||||
@Column(name = "usage_from_date")
|
||||
private LocalDateTime usageFromDate;
|
||||
|
||||
@Column(name = "usage_to_date")
|
||||
private LocalDateTime usageToDate;
|
||||
|
||||
@Builder
|
||||
public SnpApiKeyRequest(SnpUser user, String keyName, String purpose, String requestedApis,
|
||||
KeyRequestStatus status, String serviceIp, String servicePurpose,
|
||||
Long dailyRequestEstimate, LocalDateTime usageFromDate,
|
||||
LocalDateTime usageToDate) {
|
||||
this.user = user;
|
||||
this.keyName = keyName;
|
||||
this.purpose = purpose;
|
||||
this.requestedApis = requestedApis;
|
||||
this.status = status != null ? status : KeyRequestStatus.PENDING;
|
||||
this.serviceIp = serviceIp;
|
||||
this.servicePurpose = servicePurpose;
|
||||
this.dailyRequestEstimate = dailyRequestEstimate;
|
||||
this.usageFromDate = usageFromDate;
|
||||
this.usageToDate = usageToDate;
|
||||
}
|
||||
|
||||
public void approve(SnpUser reviewer, String adjustedApis,
|
||||
LocalDateTime adjustedFromDate, LocalDateTime adjustedToDate) {
|
||||
this.status = KeyRequestStatus.APPROVED;
|
||||
this.reviewedBy = reviewer;
|
||||
this.reviewedAt = LocalDateTime.now();
|
||||
if (adjustedApis != null) this.requestedApis = adjustedApis;
|
||||
if (adjustedFromDate != null) this.usageFromDate = adjustedFromDate;
|
||||
if (adjustedToDate != null) this.usageToDate = adjustedToDate;
|
||||
}
|
||||
|
||||
public void reject(SnpUser reviewer, String comment) {
|
||||
this.status = KeyRequestStatus.REJECTED;
|
||||
this.reviewedBy = reviewer;
|
||||
this.reviewedAt = LocalDateTime.now();
|
||||
this.reviewComment = comment;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,66 @@
|
||||
package com.gcsc.connection.apikey.entity;
|
||||
|
||||
import com.gcsc.connection.service.entity.SnpServiceApi;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Getter
|
||||
@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
|
||||
@Entity
|
||||
@Table(name = "snp_api_permission", schema = "common",
|
||||
uniqueConstraints = @UniqueConstraint(columnNames = {"api_key_id", "api_id"}))
|
||||
public class SnpApiPermission {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "permission_id")
|
||||
private Long permissionId;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "api_key_id", nullable = false)
|
||||
private SnpApiKey apiKey;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "api_id", nullable = false)
|
||||
private SnpServiceApi api;
|
||||
|
||||
@Column(name = "is_active", nullable = false)
|
||||
private Boolean isActive = true;
|
||||
|
||||
@Column(name = "granted_by")
|
||||
private Long grantedBy;
|
||||
|
||||
@Column(name = "granted_at", nullable = false)
|
||||
private LocalDateTime grantedAt;
|
||||
|
||||
@Column(name = "revoked_at")
|
||||
private LocalDateTime revokedAt;
|
||||
|
||||
@Builder
|
||||
public SnpApiPermission(SnpApiKey apiKey, SnpServiceApi api, Boolean isActive,
|
||||
Long grantedBy, LocalDateTime grantedAt) {
|
||||
this.apiKey = apiKey;
|
||||
this.api = api;
|
||||
this.isActive = isActive != null ? isActive : true;
|
||||
this.grantedBy = grantedBy;
|
||||
this.grantedAt = grantedAt != null ? grantedAt : LocalDateTime.now();
|
||||
}
|
||||
|
||||
public void revoke() {
|
||||
this.isActive = false;
|
||||
this.revokedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
package com.gcsc.connection.apikey.repository;
|
||||
|
||||
import com.gcsc.connection.apikey.entity.SnpApiKey;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface SnpApiKeyRepository extends JpaRepository<SnpApiKey, Long> {
|
||||
|
||||
Optional<SnpApiKey> findByApiKey(String apiKey);
|
||||
|
||||
List<SnpApiKey> findByUserUserId(Long userId);
|
||||
|
||||
List<SnpApiKey> findByApiKeyPrefix(String apiKeyPrefix);
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
package com.gcsc.connection.apikey.repository;
|
||||
|
||||
import com.gcsc.connection.apikey.entity.KeyRequestStatus;
|
||||
import com.gcsc.connection.apikey.entity.SnpApiKeyRequest;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface SnpApiKeyRequestRepository extends JpaRepository<SnpApiKeyRequest, Long> {
|
||||
|
||||
List<SnpApiKeyRequest> findByUserUserId(Long userId);
|
||||
|
||||
List<SnpApiKeyRequest> findByStatus(KeyRequestStatus status);
|
||||
|
||||
List<SnpApiKeyRequest> findAllByOrderByCreatedAtDesc();
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
package com.gcsc.connection.apikey.repository;
|
||||
|
||||
import com.gcsc.connection.apikey.entity.SnpApiPermission;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface SnpApiPermissionRepository extends JpaRepository<SnpApiPermission, Long> {
|
||||
|
||||
Optional<SnpApiPermission> findByApiKeyApiKeyIdAndApiApiIdAndIsActiveTrue(Long apiKeyId, Long apiId);
|
||||
|
||||
List<SnpApiPermission> findByApiKeyApiKeyId(Long apiKeyId);
|
||||
|
||||
@Modifying
|
||||
@Query("DELETE FROM SnpApiPermission p WHERE p.apiKey.apiKeyId = :apiKeyId")
|
||||
void deleteByApiKeyApiKeyId(Long apiKeyId);
|
||||
}
|
||||
@ -0,0 +1,76 @@
|
||||
package com.gcsc.connection.apikey.service;
|
||||
|
||||
import com.gcsc.connection.apikey.dto.PermissionResponse;
|
||||
import com.gcsc.connection.apikey.dto.UpdatePermissionsRequest;
|
||||
import com.gcsc.connection.apikey.entity.SnpApiKey;
|
||||
import com.gcsc.connection.apikey.entity.SnpApiPermission;
|
||||
import com.gcsc.connection.apikey.repository.SnpApiKeyRepository;
|
||||
import com.gcsc.connection.apikey.repository.SnpApiPermissionRepository;
|
||||
import com.gcsc.connection.common.exception.BusinessException;
|
||||
import com.gcsc.connection.common.exception.ErrorCode;
|
||||
import com.gcsc.connection.service.repository.SnpServiceApiRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* API Key 권한 관리 서비스
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class ApiKeyPermissionService {
|
||||
|
||||
private final SnpApiPermissionRepository snpApiPermissionRepository;
|
||||
private final SnpApiKeyRepository snpApiKeyRepository;
|
||||
private final SnpServiceApiRepository snpServiceApiRepository;
|
||||
|
||||
/**
|
||||
* API Key 권한 목록 조회
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<PermissionResponse> getPermissions(Long apiKeyId) {
|
||||
return snpApiPermissionRepository.findByApiKeyApiKeyId(apiKeyId).stream()
|
||||
.map(PermissionResponse::from)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* API Key 권한 수정 (기존 삭제 후 재생성)
|
||||
*/
|
||||
@Transactional
|
||||
public List<PermissionResponse> updatePermissions(Long apiKeyId, Long grantedByUserId,
|
||||
UpdatePermissionsRequest request) {
|
||||
SnpApiKey apiKey = snpApiKeyRepository.findById(apiKeyId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.API_KEY_NOT_FOUND));
|
||||
|
||||
snpApiPermissionRepository.deleteByApiKeyApiKeyId(apiKeyId);
|
||||
snpApiPermissionRepository.flush();
|
||||
|
||||
if (request.apiIds() == null || request.apiIds().isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
List<SnpApiPermission> permissions = request.apiIds().stream()
|
||||
.map(apiId -> snpServiceApiRepository.findById(apiId)
|
||||
.map(serviceApi -> SnpApiPermission.builder()
|
||||
.apiKey(apiKey)
|
||||
.api(serviceApi)
|
||||
.isActive(true)
|
||||
.grantedBy(grantedByUserId)
|
||||
.grantedAt(LocalDateTime.now())
|
||||
.build())
|
||||
.orElse(null))
|
||||
.filter(perm -> perm != null)
|
||||
.toList();
|
||||
|
||||
List<SnpApiPermission> saved = snpApiPermissionRepository.saveAll(permissions);
|
||||
return saved.stream()
|
||||
.map(PermissionResponse::from)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,238 @@
|
||||
package com.gcsc.connection.apikey.service;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.gcsc.connection.apikey.dto.ApiKeyCreateResponse;
|
||||
import com.gcsc.connection.apikey.dto.ApiKeyRequestCreateDto;
|
||||
import com.gcsc.connection.apikey.dto.ApiKeyRequestResponse;
|
||||
import com.gcsc.connection.apikey.dto.ApiKeyRequestReviewDto;
|
||||
import com.gcsc.connection.apikey.entity.ApiKeyStatus;
|
||||
import com.gcsc.connection.apikey.entity.KeyRequestStatus;
|
||||
import com.gcsc.connection.apikey.entity.SnpApiKey;
|
||||
import com.gcsc.connection.apikey.entity.SnpApiKeyRequest;
|
||||
import com.gcsc.connection.apikey.entity.SnpApiPermission;
|
||||
import com.gcsc.connection.apikey.repository.SnpApiKeyRepository;
|
||||
import com.gcsc.connection.apikey.repository.SnpApiKeyRequestRepository;
|
||||
import com.gcsc.connection.apikey.repository.SnpApiPermissionRepository;
|
||||
import com.gcsc.connection.common.exception.BusinessException;
|
||||
import com.gcsc.connection.common.exception.ErrorCode;
|
||||
import com.gcsc.connection.common.util.AesEncryptor;
|
||||
import com.gcsc.connection.service.repository.SnpServiceApiRepository;
|
||||
import com.gcsc.connection.user.entity.SnpUser;
|
||||
import com.gcsc.connection.user.repository.SnpUserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* API Key 신청/심사 워크플로우 서비스
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class ApiKeyRequestService {
|
||||
|
||||
private static final int RAW_KEY_HEX_LENGTH = 32;
|
||||
private static final String KEY_PREFIX = "snp_";
|
||||
private static final int PREFIX_LENGTH = 8;
|
||||
|
||||
private final SnpApiKeyRequestRepository snpApiKeyRequestRepository;
|
||||
private final SnpApiKeyRepository snpApiKeyRepository;
|
||||
private final SnpApiPermissionRepository snpApiPermissionRepository;
|
||||
private final SnpServiceApiRepository snpServiceApiRepository;
|
||||
private final SnpUserRepository snpUserRepository;
|
||||
private final AesEncryptor aesEncryptor;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* API Key 신청 생성
|
||||
*/
|
||||
@Transactional
|
||||
public ApiKeyRequestResponse createRequest(Long userId, ApiKeyRequestCreateDto dto) {
|
||||
SnpUser user = snpUserRepository.findById(userId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
|
||||
|
||||
String requestedApisJson = toJson(dto.requestedApiIds());
|
||||
|
||||
LocalDateTime fromDate = dto.usageFromDate() != null
|
||||
? LocalDate.parse(dto.usageFromDate()).atStartOfDay() : null;
|
||||
LocalDateTime toDate = dto.usageToDate() != null
|
||||
? LocalDate.parse(dto.usageToDate()).atStartOfDay() : null;
|
||||
|
||||
SnpApiKeyRequest request = SnpApiKeyRequest.builder()
|
||||
.user(user)
|
||||
.keyName(dto.keyName())
|
||||
.purpose(dto.purpose())
|
||||
.requestedApis(requestedApisJson)
|
||||
.serviceIp(dto.serviceIp())
|
||||
.servicePurpose(dto.servicePurpose())
|
||||
.dailyRequestEstimate(dto.dailyRequestEstimate())
|
||||
.usageFromDate(fromDate)
|
||||
.usageToDate(toDate)
|
||||
.build();
|
||||
|
||||
SnpApiKeyRequest saved = snpApiKeyRequestRepository.save(request);
|
||||
return ApiKeyRequestResponse.from(saved);
|
||||
}
|
||||
|
||||
/**
|
||||
* 내 신청 목록 조회
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<ApiKeyRequestResponse> getMyRequests(Long userId) {
|
||||
return snpApiKeyRequestRepository.findByUserUserId(userId).stream()
|
||||
.map(ApiKeyRequestResponse::from)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 신청 목록 조회 (최신순)
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<ApiKeyRequestResponse> getAllRequests() {
|
||||
return snpApiKeyRequestRepository.findAllByOrderByCreatedAtDesc().stream()
|
||||
.map(ApiKeyRequestResponse::from)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 대기 중인 신청 목록 조회
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<ApiKeyRequestResponse> getPendingRequests() {
|
||||
return snpApiKeyRequestRepository.findByStatus(KeyRequestStatus.PENDING).stream()
|
||||
.map(ApiKeyRequestResponse::from)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 신청 심사 (승인/거절)
|
||||
*/
|
||||
@Transactional
|
||||
public ApiKeyCreateResponse reviewRequest(Long requestId, Long reviewerId,
|
||||
ApiKeyRequestReviewDto dto) {
|
||||
SnpApiKeyRequest request = snpApiKeyRequestRepository.findById(requestId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.API_KEY_REQUEST_NOT_FOUND));
|
||||
|
||||
if (request.getStatus() != KeyRequestStatus.PENDING) {
|
||||
throw new BusinessException(ErrorCode.API_KEY_REQUEST_ALREADY_PROCESSED);
|
||||
}
|
||||
|
||||
SnpUser reviewer = snpUserRepository.findById(reviewerId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
|
||||
|
||||
if ("APPROVED".equals(dto.status())) {
|
||||
return approveRequest(request, reviewer, dto);
|
||||
} else {
|
||||
request.reject(reviewer, dto.reviewComment());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private ApiKeyCreateResponse approveRequest(SnpApiKeyRequest request, SnpUser reviewer,
|
||||
ApiKeyRequestReviewDto dto) {
|
||||
String adjustedApisJson = dto.adjustedApiIds() != null && !dto.adjustedApiIds().isEmpty()
|
||||
? toJson(dto.adjustedApiIds()) : null;
|
||||
LocalDateTime adjFrom = dto.adjustedFromDate() != null
|
||||
? LocalDate.parse(dto.adjustedFromDate()).atStartOfDay() : null;
|
||||
LocalDateTime adjTo = dto.adjustedToDate() != null
|
||||
? LocalDate.parse(dto.adjustedToDate()).atStartOfDay() : null;
|
||||
request.approve(reviewer, adjustedApisJson, adjFrom, adjTo);
|
||||
|
||||
// API Key 생성
|
||||
String rawKey = generateRawKey();
|
||||
String prefix = rawKey.substring(0, PREFIX_LENGTH);
|
||||
String encryptedKey = aesEncryptor.encrypt(rawKey);
|
||||
|
||||
SnpApiKey apiKey = SnpApiKey.builder()
|
||||
.user(request.getUser())
|
||||
.apiKey(encryptedKey)
|
||||
.apiKeyPrefix(prefix)
|
||||
.keyName(request.getKeyName())
|
||||
.status(ApiKeyStatus.ACTIVE)
|
||||
.approvedBy(reviewer)
|
||||
.approvedAt(LocalDateTime.now())
|
||||
.expiresAt(request.getUsageToDate())
|
||||
.build();
|
||||
|
||||
SnpApiKey savedKey = snpApiKeyRepository.save(apiKey);
|
||||
|
||||
// 권한 생성
|
||||
List<Long> apiIds = dto.adjustedApiIds() != null && !dto.adjustedApiIds().isEmpty()
|
||||
? dto.adjustedApiIds()
|
||||
: parseApiIds(request.getRequestedApis());
|
||||
|
||||
createPermissions(savedKey, apiIds, reviewer.getUserId());
|
||||
|
||||
return new ApiKeyCreateResponse(
|
||||
savedKey.getApiKeyId(),
|
||||
savedKey.getKeyName(),
|
||||
rawKey,
|
||||
savedKey.getApiKeyPrefix(),
|
||||
savedKey.getStatus().name()
|
||||
);
|
||||
}
|
||||
|
||||
private void createPermissions(SnpApiKey apiKey, List<Long> apiIds, Long grantedBy) {
|
||||
if (apiIds == null || apiIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<SnpApiPermission> permissions = apiIds.stream()
|
||||
.map(apiId -> snpServiceApiRepository.findById(apiId)
|
||||
.map(serviceApi -> SnpApiPermission.builder()
|
||||
.apiKey(apiKey)
|
||||
.api(serviceApi)
|
||||
.isActive(true)
|
||||
.grantedBy(grantedBy)
|
||||
.grantedAt(LocalDateTime.now())
|
||||
.build())
|
||||
.orElse(null))
|
||||
.filter(perm -> perm != null)
|
||||
.toList();
|
||||
|
||||
snpApiPermissionRepository.saveAll(permissions);
|
||||
}
|
||||
|
||||
private String generateRawKey() {
|
||||
SecureRandom random = new SecureRandom();
|
||||
byte[] bytes = new byte[RAW_KEY_HEX_LENGTH / 2];
|
||||
random.nextBytes(bytes);
|
||||
StringBuilder hex = new StringBuilder(KEY_PREFIX);
|
||||
for (byte b : bytes) {
|
||||
hex.append(String.format("%02x", b));
|
||||
}
|
||||
return hex.toString();
|
||||
}
|
||||
|
||||
private String toJson(List<Long> apiIds) {
|
||||
if (apiIds == null || apiIds.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return objectMapper.writeValueAsString(apiIds);
|
||||
} catch (JsonProcessingException e) {
|
||||
log.error("API ID 목록 JSON 변환 실패", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<Long> parseApiIds(String json) {
|
||||
if (json == null || json.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(json, List.class);
|
||||
} catch (JsonProcessingException e) {
|
||||
log.error("API ID 목록 JSON 파싱 실패", e);
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,129 @@
|
||||
package com.gcsc.connection.apikey.service;
|
||||
|
||||
import com.gcsc.connection.apikey.dto.ApiKeyCreateResponse;
|
||||
import com.gcsc.connection.apikey.dto.ApiKeyDetailResponse;
|
||||
import com.gcsc.connection.apikey.dto.ApiKeyResponse;
|
||||
import com.gcsc.connection.apikey.dto.CreateApiKeyRequest;
|
||||
import com.gcsc.connection.apikey.entity.ApiKeyStatus;
|
||||
import com.gcsc.connection.apikey.entity.SnpApiKey;
|
||||
import com.gcsc.connection.apikey.repository.SnpApiKeyRepository;
|
||||
import com.gcsc.connection.common.exception.BusinessException;
|
||||
import com.gcsc.connection.common.exception.ErrorCode;
|
||||
import com.gcsc.connection.common.util.AesEncryptor;
|
||||
import com.gcsc.connection.user.entity.SnpUser;
|
||||
import com.gcsc.connection.user.repository.SnpUserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* API Key 관리 서비스
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class ApiKeyService {
|
||||
|
||||
private static final int RAW_KEY_HEX_LENGTH = 32;
|
||||
private static final String KEY_PREFIX = "snp_";
|
||||
private static final int PREFIX_LENGTH = 8;
|
||||
|
||||
private final SnpApiKeyRepository snpApiKeyRepository;
|
||||
private final SnpUserRepository snpUserRepository;
|
||||
private final AesEncryptor aesEncryptor;
|
||||
|
||||
/**
|
||||
* 내 API Key 목록 조회
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<ApiKeyResponse> getMyKeys(Long userId) {
|
||||
return snpApiKeyRepository.findByUserUserId(userId).stream()
|
||||
.map(ApiKeyResponse::from)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* API Key 상세 조회 (복호화된 키 포함)
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public ApiKeyDetailResponse getKeyDetail(Long keyId) {
|
||||
SnpApiKey apiKey = snpApiKeyRepository.findById(keyId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.API_KEY_NOT_FOUND));
|
||||
String decryptedKey = aesEncryptor.decrypt(apiKey.getApiKey());
|
||||
return ApiKeyDetailResponse.from(apiKey, decryptedKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* API Key 생성
|
||||
*/
|
||||
@Transactional
|
||||
public ApiKeyCreateResponse createKey(Long userId, CreateApiKeyRequest request) {
|
||||
SnpUser user = snpUserRepository.findById(userId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
|
||||
|
||||
String rawKey = generateRawKey();
|
||||
String prefix = rawKey.substring(0, PREFIX_LENGTH);
|
||||
String encryptedKey = aesEncryptor.encrypt(rawKey);
|
||||
|
||||
SnpApiKey apiKey = SnpApiKey.builder()
|
||||
.user(user)
|
||||
.apiKey(encryptedKey)
|
||||
.apiKeyPrefix(prefix)
|
||||
.keyName(request.keyName())
|
||||
.status(ApiKeyStatus.ACTIVE)
|
||||
.build();
|
||||
|
||||
SnpApiKey saved = snpApiKeyRepository.save(apiKey);
|
||||
|
||||
return new ApiKeyCreateResponse(
|
||||
saved.getApiKeyId(),
|
||||
saved.getKeyName(),
|
||||
rawKey,
|
||||
saved.getApiKeyPrefix(),
|
||||
saved.getStatus().name()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* API Key 폐기
|
||||
*/
|
||||
@Transactional
|
||||
public void revokeKey(Long keyId) {
|
||||
SnpApiKey apiKey = snpApiKeyRepository.findById(keyId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.API_KEY_NOT_FOUND));
|
||||
|
||||
if (apiKey.getStatus() == ApiKeyStatus.REVOKED) {
|
||||
throw new BusinessException(ErrorCode.API_KEY_ALREADY_REVOKED);
|
||||
}
|
||||
|
||||
apiKey.revoke();
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 API Key 목록 조회 (관리자용)
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<ApiKeyResponse> getAllKeys() {
|
||||
return snpApiKeyRepository.findAll().stream()
|
||||
.map(ApiKeyResponse::from)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 랜덤 API Key 문자열 생성
|
||||
*/
|
||||
private String generateRawKey() {
|
||||
SecureRandom random = new SecureRandom();
|
||||
byte[] bytes = new byte[RAW_KEY_HEX_LENGTH / 2];
|
||||
random.nextBytes(bytes);
|
||||
StringBuilder hex = new StringBuilder(KEY_PREFIX);
|
||||
for (byte b : bytes) {
|
||||
hex.append(String.format("%02x", b));
|
||||
}
|
||||
return hex.toString();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,54 @@
|
||||
package com.gcsc.connection.auth.controller;
|
||||
|
||||
import com.gcsc.connection.auth.dto.LoginRequest;
|
||||
import com.gcsc.connection.auth.dto.LoginResponse;
|
||||
import com.gcsc.connection.auth.dto.TokenRefreshRequest;
|
||||
import com.gcsc.connection.auth.dto.TokenRefreshResponse;
|
||||
import com.gcsc.connection.auth.service.AuthService;
|
||||
import com.gcsc.connection.common.dto.ApiResponse;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 인증 관련 API (로그인, 로그아웃, 토큰 갱신)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
@RequiredArgsConstructor
|
||||
public class AuthController {
|
||||
|
||||
private final AuthService authService;
|
||||
|
||||
/**
|
||||
* 로그인 - JWT 토큰 발급
|
||||
*/
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<ApiResponse<LoginResponse>> login(
|
||||
@RequestBody @Valid LoginRequest request) {
|
||||
LoginResponse response = authService.login(request);
|
||||
return ResponseEntity.ok(ApiResponse.ok(response));
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그아웃
|
||||
*/
|
||||
@PostMapping("/logout")
|
||||
public ResponseEntity<ApiResponse<Void>> logout() {
|
||||
return ResponseEntity.ok(ApiResponse.ok(null, "로그아웃되었습니다"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 갱신 - Refresh Token으로 새 Access Token 발급
|
||||
*/
|
||||
@PostMapping("/refresh")
|
||||
public ResponseEntity<ApiResponse<TokenRefreshResponse>> refresh(
|
||||
@RequestBody @Valid TokenRefreshRequest request) {
|
||||
TokenRefreshResponse response = authService.refresh(request);
|
||||
return ResponseEntity.ok(ApiResponse.ok(response));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
package com.gcsc.connection.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record LoginRequest(
|
||||
@NotBlank String loginId,
|
||||
@NotBlank String password
|
||||
) {
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
package com.gcsc.connection.auth.dto;
|
||||
|
||||
public record LoginResponse(
|
||||
String accessToken,
|
||||
String refreshToken,
|
||||
String loginId,
|
||||
String userName,
|
||||
String role,
|
||||
long expiresIn
|
||||
) {
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
package com.gcsc.connection.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record TokenRefreshRequest(
|
||||
@NotBlank String refreshToken
|
||||
) {
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
package com.gcsc.connection.auth.dto;
|
||||
|
||||
public record TokenRefreshResponse(
|
||||
String accessToken,
|
||||
long expiresIn
|
||||
) {
|
||||
}
|
||||
@ -0,0 +1,67 @@
|
||||
package com.gcsc.connection.auth.jwt;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.AntPathMatcher;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final String AUTHORIZATION_HEADER = "Authorization";
|
||||
private static final String BEARER_PREFIX = "Bearer ";
|
||||
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
private final AntPathMatcher pathMatcher = new AntPathMatcher();
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
String token = resolveToken(request);
|
||||
|
||||
if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) {
|
||||
Long userId = jwtTokenProvider.getUserId(token);
|
||||
String loginId = jwtTokenProvider.getLoginId(token);
|
||||
String role = jwtTokenProvider.parseClaims(token).get("role", String.class);
|
||||
|
||||
List<SimpleGrantedAuthority> authorities =
|
||||
List.of(new SimpleGrantedAuthority("ROLE_" + role));
|
||||
|
||||
UsernamePasswordAuthenticationToken authentication =
|
||||
new UsernamePasswordAuthenticationToken(userId, loginId, authorities);
|
||||
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||
String path = request.getServletPath();
|
||||
// /api/ 경로가 아니거나 /api/auth/** 경로인 경우 필터 건너뜀
|
||||
return !path.startsWith("/api/") || pathMatcher.match("/api/auth/**", path);
|
||||
}
|
||||
|
||||
private String resolveToken(HttpServletRequest request) {
|
||||
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
|
||||
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
|
||||
return bearerToken.substring(BEARER_PREFIX.length());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
107
src/main/java/com/gcsc/connection/auth/jwt/JwtTokenProvider.java
Normal file
107
src/main/java/com/gcsc/connection/auth/jwt/JwtTokenProvider.java
Normal file
@ -0,0 +1,107 @@
|
||||
package com.gcsc.connection.auth.jwt;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.ExpiredJwtException;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Date;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class JwtTokenProvider {
|
||||
|
||||
private final SecretKey secretKey;
|
||||
private final long accessTokenExpiration;
|
||||
private final long refreshTokenExpiration;
|
||||
|
||||
public JwtTokenProvider(
|
||||
@Value("${app.jwt.secret}") String secret,
|
||||
@Value("${app.jwt.access-token-expiration}") long accessTokenExpiration,
|
||||
@Value("${app.jwt.refresh-token-expiration}") long refreshTokenExpiration) {
|
||||
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
|
||||
this.accessTokenExpiration = accessTokenExpiration;
|
||||
this.refreshTokenExpiration = refreshTokenExpiration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Access Token 생성
|
||||
*/
|
||||
public String generateAccessToken(Long userId, String loginId, String role) {
|
||||
Date now = new Date();
|
||||
Date expiry = new Date(now.getTime() + accessTokenExpiration);
|
||||
|
||||
return Jwts.builder()
|
||||
.subject(String.valueOf(userId))
|
||||
.claim("loginId", loginId)
|
||||
.claim("role", role)
|
||||
.issuedAt(now)
|
||||
.expiration(expiry)
|
||||
.signWith(secretKey)
|
||||
.compact();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh Token 생성
|
||||
*/
|
||||
public String generateRefreshToken(Long userId) {
|
||||
Date now = new Date();
|
||||
Date expiry = new Date(now.getTime() + refreshTokenExpiration);
|
||||
|
||||
return Jwts.builder()
|
||||
.subject(String.valueOf(userId))
|
||||
.issuedAt(now)
|
||||
.expiration(expiry)
|
||||
.signWith(secretKey)
|
||||
.compact();
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰에서 Claims 파싱
|
||||
*/
|
||||
public Claims parseClaims(String token) {
|
||||
return Jwts.parser()
|
||||
.verifyWith(secretKey)
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 유효성 검증
|
||||
*/
|
||||
public boolean validateToken(String token) {
|
||||
try {
|
||||
parseClaims(token);
|
||||
return true;
|
||||
} catch (ExpiredJwtException e) {
|
||||
log.warn("만료된 JWT 토큰입니다");
|
||||
} catch (Exception e) {
|
||||
log.warn("유효하지 않은 JWT 토큰입니다: {}", e.getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰에서 사용자 ID 추출
|
||||
*/
|
||||
public Long getUserId(String token) {
|
||||
return Long.parseLong(parseClaims(token).getSubject());
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰에서 로그인 ID 추출
|
||||
*/
|
||||
public String getLoginId(String token) {
|
||||
return parseClaims(token).get("loginId", String.class);
|
||||
}
|
||||
|
||||
public long getAccessTokenExpiration() {
|
||||
return accessTokenExpiration;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,83 @@
|
||||
package com.gcsc.connection.auth.service;
|
||||
|
||||
import com.gcsc.connection.auth.dto.LoginRequest;
|
||||
import com.gcsc.connection.auth.dto.LoginResponse;
|
||||
import com.gcsc.connection.auth.dto.TokenRefreshRequest;
|
||||
import com.gcsc.connection.auth.dto.TokenRefreshResponse;
|
||||
import com.gcsc.connection.auth.jwt.JwtTokenProvider;
|
||||
import com.gcsc.connection.common.exception.BusinessException;
|
||||
import com.gcsc.connection.common.exception.ErrorCode;
|
||||
import com.gcsc.connection.user.entity.SnpUser;
|
||||
import com.gcsc.connection.user.repository.SnpUserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AuthService {
|
||||
|
||||
private final SnpUserRepository userRepository;
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
/**
|
||||
* 로그인 처리: 사용자 인증 후 JWT 토큰 발급
|
||||
*/
|
||||
@Transactional
|
||||
public LoginResponse login(LoginRequest request) {
|
||||
SnpUser user = userRepository.findByLoginId(request.loginId())
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.INVALID_CREDENTIALS));
|
||||
|
||||
if (!Boolean.TRUE.equals(user.getIsActive())) {
|
||||
throw new BusinessException(ErrorCode.USER_DISABLED);
|
||||
}
|
||||
|
||||
if (!passwordEncoder.matches(request.password(), user.getPasswordHash())) {
|
||||
throw new BusinessException(ErrorCode.INVALID_CREDENTIALS);
|
||||
}
|
||||
|
||||
String accessToken = jwtTokenProvider.generateAccessToken(
|
||||
user.getUserId(), user.getLoginId(), user.getRole().name());
|
||||
String refreshToken = jwtTokenProvider.generateRefreshToken(user.getUserId());
|
||||
|
||||
user.updateLastLoginAt();
|
||||
|
||||
log.info("사용자 로그인 성공: {}", user.getLoginId());
|
||||
|
||||
return new LoginResponse(
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user.getLoginId(),
|
||||
user.getUserName(),
|
||||
user.getRole().name(),
|
||||
jwtTokenProvider.getAccessTokenExpiration()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 갱신: Refresh Token으로 새 Access Token 발급
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public TokenRefreshResponse refresh(TokenRefreshRequest request) {
|
||||
if (!jwtTokenProvider.validateToken(request.refreshToken())) {
|
||||
throw new BusinessException(ErrorCode.TOKEN_INVALID);
|
||||
}
|
||||
|
||||
Long userId = jwtTokenProvider.getUserId(request.refreshToken());
|
||||
SnpUser user = userRepository.findById(userId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
|
||||
|
||||
if (!Boolean.TRUE.equals(user.getIsActive())) {
|
||||
throw new BusinessException(ErrorCode.USER_DISABLED);
|
||||
}
|
||||
|
||||
String accessToken = jwtTokenProvider.generateAccessToken(
|
||||
user.getUserId(), user.getLoginId(), user.getRole().name());
|
||||
|
||||
return new TokenRefreshResponse(accessToken, jwtTokenProvider.getAccessTokenExpiration());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
package com.gcsc.connection.common.dto;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record PageResponse<T>(
|
||||
List<T> content,
|
||||
int page,
|
||||
int size,
|
||||
long totalElements,
|
||||
int totalPages
|
||||
) {
|
||||
|
||||
public static <T> PageResponse<T> from(Page<T> page) {
|
||||
return new PageResponse<>(
|
||||
page.getContent(),
|
||||
page.getNumber(),
|
||||
page.getSize(),
|
||||
page.getTotalElements(),
|
||||
page.getTotalPages()
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
package com.gcsc.connection.common.entity;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.MappedSuperclass;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.PreUpdate;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Getter
|
||||
@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
|
||||
@MappedSuperclass
|
||||
public abstract class BaseEntity {
|
||||
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
this.createdAt = LocalDateTime.now();
|
||||
this.updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
this.updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
package com.gcsc.connection.common.exception;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
public class BusinessException extends RuntimeException {
|
||||
|
||||
private final ErrorCode errorCode;
|
||||
|
||||
public BusinessException(ErrorCode errorCode) {
|
||||
super(errorCode.getMessage());
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
package com.gcsc.connection.common.exception;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
public enum ErrorCode {
|
||||
|
||||
INVALID_CREDENTIALS(401, "AUTH001", "아이디 또는 비밀번호가 올바르지 않습니다"),
|
||||
TOKEN_EXPIRED(401, "AUTH002", "토큰이 만료되었습니다"),
|
||||
TOKEN_INVALID(401, "AUTH003", "유효하지 않은 토큰입니다"),
|
||||
ACCESS_DENIED(403, "AUTH004", "접근 권한이 없습니다"),
|
||||
USER_NOT_FOUND(404, "USER001", "사용자를 찾을 수 없습니다"),
|
||||
USER_DISABLED(403, "USER002", "비활성화된 사용자입니다"),
|
||||
USER_LOGIN_ID_DUPLICATE(409, "USER003", "이미 존재하는 로그인 ID입니다"),
|
||||
TENANT_NOT_FOUND(404, "TENANT001", "테넌트를 찾을 수 없습니다"),
|
||||
TENANT_CODE_DUPLICATE(409, "TENANT002", "이미 존재하는 테넌트 코드입니다"),
|
||||
SERVICE_NOT_FOUND(404, "SVC001", "서비스를 찾을 수 없습니다"),
|
||||
SERVICE_CODE_DUPLICATE(409, "SVC002", "이미 존재하는 서비스 코드입니다"),
|
||||
SERVICE_API_NOT_FOUND(404, "SVC003", "서비스 API를 찾을 수 없습니다"),
|
||||
HEALTH_CHECK_FAILED(500, "HC001", "헬스체크 실행에 실패했습니다"),
|
||||
API_KEY_NOT_FOUND(404, "KEY001", "API Key를 찾을 수 없습니다"),
|
||||
API_KEY_ALREADY_REVOKED(409, "KEY002", "이미 폐기된 API Key입니다"),
|
||||
API_KEY_REQUEST_NOT_FOUND(404, "KEY003", "API Key 신청을 찾을 수 없습니다"),
|
||||
API_KEY_REQUEST_ALREADY_PROCESSED(409, "KEY004", "이미 처리된 신청입니다"),
|
||||
ENCRYPTION_ERROR(500, "KEY005", "암호화 처리 중 오류가 발생했습니다"),
|
||||
GATEWAY_API_KEY_MISSING(401, "GW001", "API Key가 필요합니다"),
|
||||
GATEWAY_API_KEY_INVALID(401, "GW002", "유효하지 않은 API Key입니다"),
|
||||
GATEWAY_API_KEY_EXPIRED(403, "GW003", "만료된 API Key입니다"),
|
||||
GATEWAY_SERVICE_INACTIVE(503, "GW004", "비활성 서비스입니다"),
|
||||
GATEWAY_PERMISSION_DENIED(403, "GW005", "해당 API에 대한 권한이 없습니다"),
|
||||
GATEWAY_PROXY_FAILED(502, "GW006", "서비스 요청에 실패했습니다"),
|
||||
INTERNAL_ERROR(500, "SYS001", "시스템 오류가 발생했습니다");
|
||||
|
||||
private final int status;
|
||||
private final String code;
|
||||
private final String message;
|
||||
}
|
||||
@ -4,13 +4,57 @@ import com.gcsc.connection.common.dto.ApiResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Slf4j
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
/**
|
||||
* 비즈니스 예외 처리
|
||||
*/
|
||||
@ExceptionHandler(BusinessException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleBusinessException(BusinessException e) {
|
||||
ErrorCode errorCode = e.getErrorCode();
|
||||
log.warn("Business exception: {} - {}", errorCode.getCode(), errorCode.getMessage());
|
||||
return ResponseEntity
|
||||
.status(errorCode.getStatus())
|
||||
.body(ApiResponse.error(errorCode.getMessage()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청 파라미터 유효성 검증 실패 처리
|
||||
*/
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleValidationException(
|
||||
MethodArgumentNotValidException e) {
|
||||
String message = e.getBindingResult().getFieldErrors().stream()
|
||||
.map(error -> error.getField() + ": " + error.getDefaultMessage())
|
||||
.collect(Collectors.joining(", "));
|
||||
log.warn("Validation failed: {}", message);
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.BAD_REQUEST)
|
||||
.body(ApiResponse.error(message));
|
||||
}
|
||||
|
||||
/**
|
||||
* 접근 권한 없음 예외 처리
|
||||
*/
|
||||
@ExceptionHandler(AccessDeniedException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleAccessDenied(AccessDeniedException e) {
|
||||
log.warn("Access denied: {}", e.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error("접근 권한이 없습니다"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 처리되지 않은 예외 처리
|
||||
*/
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleException(Exception e) {
|
||||
log.error("Unhandled exception: {}", e.getMessage(), e);
|
||||
|
||||
@ -0,0 +1,79 @@
|
||||
package com.gcsc.connection.common.util;
|
||||
|
||||
import com.gcsc.connection.common.exception.BusinessException;
|
||||
import com.gcsc.connection.common.exception.ErrorCode;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class AesEncryptor {
|
||||
|
||||
private static final String ALGORITHM = "AES/GCM/NoPadding";
|
||||
private static final int GCM_TAG_LENGTH = 128;
|
||||
private static final int IV_LENGTH = 12;
|
||||
|
||||
private final SecretKeySpec secretKeySpec;
|
||||
|
||||
public AesEncryptor(@Value("${app.apikey.aes-secret-key}") String base64Key) {
|
||||
byte[] keyBytes = Base64.getDecoder().decode(base64Key);
|
||||
this.secretKeySpec = new SecretKeySpec(keyBytes, "AES");
|
||||
}
|
||||
|
||||
/**
|
||||
* 평문을 AES-256-GCM으로 암호화하여 Base64 인코딩된 문자열 반환
|
||||
*/
|
||||
public String encrypt(String plainText) {
|
||||
try {
|
||||
byte[] iv = new byte[IV_LENGTH];
|
||||
new SecureRandom().nextBytes(iv);
|
||||
|
||||
Cipher cipher = Cipher.getInstance(ALGORITHM);
|
||||
GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, gcmSpec);
|
||||
|
||||
byte[] cipherText = cipher.doFinal(plainText.getBytes());
|
||||
|
||||
byte[] combined = new byte[IV_LENGTH + cipherText.length];
|
||||
System.arraycopy(iv, 0, combined, 0, IV_LENGTH);
|
||||
System.arraycopy(cipherText, 0, combined, IV_LENGTH, cipherText.length);
|
||||
|
||||
return Base64.getEncoder().encodeToString(combined);
|
||||
} catch (Exception e) {
|
||||
log.error("암호화 처리 중 오류 발생", e);
|
||||
throw new BusinessException(ErrorCode.ENCRYPTION_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 인코딩된 암호문을 복호화하여 평문 반환
|
||||
*/
|
||||
public String decrypt(String encryptedText) {
|
||||
try {
|
||||
byte[] combined = Base64.getDecoder().decode(encryptedText);
|
||||
|
||||
byte[] iv = new byte[IV_LENGTH];
|
||||
System.arraycopy(combined, 0, iv, 0, IV_LENGTH);
|
||||
|
||||
byte[] cipherText = new byte[combined.length - IV_LENGTH];
|
||||
System.arraycopy(combined, IV_LENGTH, cipherText, 0, cipherText.length);
|
||||
|
||||
Cipher cipher = Cipher.getInstance(ALGORITHM);
|
||||
GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, gcmSpec);
|
||||
|
||||
byte[] plainText = cipher.doFinal(cipherText);
|
||||
return new String(plainText);
|
||||
} catch (Exception e) {
|
||||
log.error("복호화 처리 중 오류 발생", e);
|
||||
throw new BusinessException(ErrorCode.ENCRYPTION_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
24
src/main/java/com/gcsc/connection/config/AsyncConfig.java
Normal file
24
src/main/java/com/gcsc/connection/config/AsyncConfig.java
Normal file
@ -0,0 +1,24 @@
|
||||
package com.gcsc.connection.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
@Configuration
|
||||
@EnableAsync
|
||||
public class AsyncConfig {
|
||||
|
||||
@Bean("taskExecutor")
|
||||
public Executor taskExecutor() {
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
executor.setCorePoolSize(5);
|
||||
executor.setMaxPoolSize(10);
|
||||
executor.setQueueCapacity(100);
|
||||
executor.setThreadNamePrefix("async-log-");
|
||||
executor.initialize();
|
||||
return executor;
|
||||
}
|
||||
}
|
||||
@ -1,25 +1,50 @@
|
||||
package com.gcsc.connection.config;
|
||||
|
||||
import com.gcsc.connection.auth.jwt.JwtAuthenticationFilter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity
|
||||
@RequiredArgsConstructor
|
||||
public class SecurityConfig {
|
||||
|
||||
private final JwtAuthenticationFilter jwtAuthenticationFilter;
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.headers(headers -> headers
|
||||
.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin))
|
||||
.sessionManagement(session -> session
|
||||
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.anyRequest().permitAll());
|
||||
.requestMatchers("/api/auth/**").permitAll()
|
||||
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
|
||||
.requestMatchers("/actuator/**").permitAll()
|
||||
.requestMatchers("/", "/*.html", "/assets/**", "/favicon*", "/site.webmanifest").permitAll()
|
||||
.requestMatchers("/gateway/**").permitAll()
|
||||
.requestMatchers("/api/**").authenticated()
|
||||
.anyRequest().permitAll())
|
||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
package com.gcsc.connection.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import reactor.netty.http.client.HttpClient;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
@Configuration
|
||||
public class WebClientConfig {
|
||||
|
||||
@Value("${app.heartbeat.timeout-seconds}")
|
||||
private int timeoutSeconds;
|
||||
|
||||
@Bean
|
||||
public WebClient webClient() {
|
||||
HttpClient httpClient = HttpClient.create()
|
||||
.responseTimeout(Duration.ofSeconds(timeoutSeconds));
|
||||
return WebClient.builder()
|
||||
.clientConnector(new ReactorClientHttpConnector(httpClient))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,83 @@
|
||||
package com.gcsc.connection.gateway.controller;
|
||||
|
||||
import com.gcsc.connection.common.exception.BusinessException;
|
||||
import com.gcsc.connection.gateway.service.GatewayService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMethod;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* API Gateway 프록시 엔드포인트
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/gateway")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class GatewayController {
|
||||
|
||||
private final GatewayService gatewayService;
|
||||
|
||||
/**
|
||||
* 모든 HTTP 메서드에 대한 프록시 요청 처리
|
||||
*/
|
||||
@RequestMapping(
|
||||
value = "/{serviceCode}/**",
|
||||
method = {RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT,
|
||||
RequestMethod.DELETE, RequestMethod.PATCH}
|
||||
)
|
||||
public ResponseEntity<byte[]> proxy(@PathVariable String serviceCode,
|
||||
HttpServletRequest request) {
|
||||
try {
|
||||
String remainingPath = extractRemainingPath(serviceCode, request);
|
||||
return gatewayService.proxyRequest(serviceCode, remainingPath, request);
|
||||
} catch (BusinessException e) {
|
||||
return buildErrorResponse(e.getErrorCode().getStatus(), e.getErrorCode().getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* /gateway/{serviceCode}/ 이후의 경로 추출
|
||||
*/
|
||||
private String extractRemainingPath(String serviceCode, HttpServletRequest request) {
|
||||
String fullPath = request.getRequestURI();
|
||||
String contextPath = request.getContextPath();
|
||||
String prefix = contextPath + "/gateway/" + serviceCode;
|
||||
String remainingPath = fullPath.substring(prefix.length());
|
||||
|
||||
if (remainingPath.isEmpty()) {
|
||||
remainingPath = "/";
|
||||
}
|
||||
|
||||
return remainingPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gateway 소비자용 JSON 에러 응답 생성
|
||||
*/
|
||||
private ResponseEntity<byte[]> buildErrorResponse(int status, String message) {
|
||||
String json = "{\"success\":false,\"message\":\"" + escapeJson(message) + "\"}";
|
||||
return ResponseEntity.status(status)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(json.getBytes());
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 문자열 이스케이프 처리
|
||||
*/
|
||||
private String escapeJson(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return value.replace("\\", "\\\\")
|
||||
.replace("\"", "\\\"")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
.replace("\t", "\\t");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,309 @@
|
||||
package com.gcsc.connection.gateway.service;
|
||||
|
||||
import com.gcsc.connection.apikey.entity.ApiKeyStatus;
|
||||
import com.gcsc.connection.apikey.entity.SnpApiKey;
|
||||
import com.gcsc.connection.apikey.repository.SnpApiKeyRepository;
|
||||
import com.gcsc.connection.apikey.repository.SnpApiPermissionRepository;
|
||||
import com.gcsc.connection.common.exception.BusinessException;
|
||||
import com.gcsc.connection.common.exception.ErrorCode;
|
||||
import com.gcsc.connection.common.util.AesEncryptor;
|
||||
import com.gcsc.connection.monitoring.entity.SnpApiRequestLog;
|
||||
import com.gcsc.connection.monitoring.service.RequestLogService;
|
||||
import com.gcsc.connection.service.entity.SnpService;
|
||||
import com.gcsc.connection.service.entity.SnpServiceApi;
|
||||
import com.gcsc.connection.service.repository.SnpServiceApiRepository;
|
||||
import com.gcsc.connection.service.repository.SnpServiceRepository;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import org.springframework.web.reactive.function.client.WebClientResponseException;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Enumeration;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class GatewayService {
|
||||
|
||||
private static final int API_KEY_PREFIX_LENGTH = 8;
|
||||
private static final Set<String> EXCLUDED_HEADERS = Set.of(
|
||||
"host", "x-api-key", "connection", "content-length"
|
||||
);
|
||||
|
||||
private final SnpApiKeyRepository snpApiKeyRepository;
|
||||
private final SnpApiPermissionRepository snpApiPermissionRepository;
|
||||
private final SnpServiceRepository snpServiceRepository;
|
||||
private final SnpServiceApiRepository snpServiceApiRepository;
|
||||
private final AesEncryptor aesEncryptor;
|
||||
private final WebClient webClient;
|
||||
private final RequestLogService requestLogService;
|
||||
|
||||
/**
|
||||
* API Gateway 프록시 요청 처리
|
||||
*/
|
||||
public ResponseEntity<byte[]> proxyRequest(String serviceCode, String remainingPath,
|
||||
HttpServletRequest request) {
|
||||
LocalDateTime requestedAt = LocalDateTime.now();
|
||||
long startTime = System.currentTimeMillis();
|
||||
SnpApiKey apiKey = null;
|
||||
SnpService service = null;
|
||||
String gatewayPath = "/gateway/" + serviceCode + remainingPath
|
||||
+ (request.getQueryString() != null ? "?" + request.getQueryString() : "");
|
||||
String targetUrl = null;
|
||||
|
||||
try {
|
||||
// 1. 서비스 조회 (모든 로그에 service_id 기록을 위해 최우선 수행)
|
||||
service = snpServiceRepository.findByServiceCode(serviceCode)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.SERVICE_NOT_FOUND));
|
||||
|
||||
if (!Boolean.TRUE.equals(service.getIsActive())) {
|
||||
throw new BusinessException(ErrorCode.GATEWAY_SERVICE_INACTIVE);
|
||||
}
|
||||
|
||||
// 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
|
||||
.findByServiceServiceIdAndApiPathAndApiMethod(
|
||||
service.getServiceId(), apiPath, request.getMethod())
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.GATEWAY_PERMISSION_DENIED));
|
||||
|
||||
// 6. 권한 확인
|
||||
snpApiPermissionRepository
|
||||
.findByApiKeyApiKeyIdAndApiApiIdAndIsActiveTrue(apiKey.getApiKeyId(), serviceApi.getApiId())
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.GATEWAY_PERMISSION_DENIED));
|
||||
|
||||
// 7. 프록시 요청 전송
|
||||
ResponseEntity<byte[]> response = forwardRequest(targetUrl, request);
|
||||
|
||||
// 9. 마지막 사용 시간 갱신
|
||||
apiKey.updateLastUsedAt();
|
||||
snpApiKeyRepository.save(apiKey);
|
||||
|
||||
// 10. 성공 로그 저장
|
||||
int responseTime = (int) (System.currentTimeMillis() - startTime);
|
||||
saveLog(request, service, apiKey, targetUrl, gatewayPath, "SUCCESS",
|
||||
response.getStatusCode().value(), responseTime,
|
||||
response.getBody() != null ? (long) response.getBody().length : 0L,
|
||||
null, requestedAt);
|
||||
|
||||
return response;
|
||||
|
||||
} catch (BusinessException e) {
|
||||
int responseTime = (int) (System.currentTimeMillis() - startTime);
|
||||
saveLog(request, service, apiKey, targetUrl, gatewayPath, "FAIL",
|
||||
e.getErrorCode().getStatus(), responseTime, 0L,
|
||||
e.getErrorCode().getMessage(), requestedAt);
|
||||
throw e;
|
||||
|
||||
} catch (Exception e) {
|
||||
int responseTime = (int) (System.currentTimeMillis() - startTime);
|
||||
log.error("Gateway 프록시 요청 실패: {}", e.getMessage(), e);
|
||||
saveLog(request, service, apiKey, targetUrl, gatewayPath, "ERROR",
|
||||
502, responseTime, 0L, e.getMessage(), requestedAt);
|
||||
throw new BusinessException(ErrorCode.GATEWAY_PROXY_FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* prefix 매칭 후 복호화하여 API Key 찾기
|
||||
*/
|
||||
private SnpApiKey findApiKeyByRawKey(String rawKey) {
|
||||
if (rawKey.length() < API_KEY_PREFIX_LENGTH) {
|
||||
throw new BusinessException(ErrorCode.GATEWAY_API_KEY_INVALID);
|
||||
}
|
||||
|
||||
String prefix = rawKey.substring(0, API_KEY_PREFIX_LENGTH);
|
||||
List<SnpApiKey> candidates = snpApiKeyRepository.findByApiKeyPrefix(prefix);
|
||||
|
||||
for (SnpApiKey candidate : candidates) {
|
||||
try {
|
||||
String decryptedKey = aesEncryptor.decrypt(candidate.getApiKey());
|
||||
if (rawKey.equals(decryptedKey)) {
|
||||
return candidate;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("API Key 복호화 비교 실패 (apiKeyId={})", candidate.getApiKeyId());
|
||||
}
|
||||
}
|
||||
|
||||
throw new BusinessException(ErrorCode.GATEWAY_API_KEY_INVALID);
|
||||
}
|
||||
|
||||
/**
|
||||
* API Key 상태 및 만료 검증
|
||||
*/
|
||||
private void validateApiKey(SnpApiKey apiKey) {
|
||||
if (apiKey.getStatus() != ApiKeyStatus.ACTIVE) {
|
||||
throw new BusinessException(ErrorCode.GATEWAY_API_KEY_INVALID);
|
||||
}
|
||||
|
||||
if (apiKey.getExpiresAt() != null && apiKey.getExpiresAt().isBefore(LocalDateTime.now())) {
|
||||
throw new BusinessException(ErrorCode.GATEWAY_API_KEY_EXPIRED);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 대상 URL 구성
|
||||
*/
|
||||
private String buildTargetUrl(String serviceUrl, String remainingPath, HttpServletRequest request) {
|
||||
StringBuilder url = new StringBuilder(serviceUrl);
|
||||
if (!remainingPath.isEmpty() && !"/".equals(remainingPath)) {
|
||||
if (!serviceUrl.endsWith("/") && !remainingPath.startsWith("/")) {
|
||||
url.append("/");
|
||||
}
|
||||
url.append(remainingPath);
|
||||
}
|
||||
|
||||
String queryString = request.getQueryString();
|
||||
if (queryString != null && !queryString.isEmpty()) {
|
||||
url.append("?").append(queryString);
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* WebClient를 통한 프록시 요청 전송
|
||||
*/
|
||||
private ResponseEntity<byte[]> forwardRequest(String targetUrl, HttpServletRequest request) {
|
||||
HttpMethod method = HttpMethod.valueOf(request.getMethod());
|
||||
|
||||
WebClient.RequestBodySpec requestSpec = webClient.method(method)
|
||||
.uri(targetUrl)
|
||||
.headers(headers -> copyHeaders(request, headers));
|
||||
|
||||
WebClient.RequestHeadersSpec<?> headersSpec;
|
||||
if (hasRequestBody(method)) {
|
||||
headersSpec = requestSpec.bodyValue(readRequestBody(request));
|
||||
} else {
|
||||
headersSpec = requestSpec;
|
||||
}
|
||||
|
||||
try {
|
||||
return headersSpec.retrieve()
|
||||
.toEntity(byte[].class)
|
||||
.block();
|
||||
} catch (WebClientResponseException e) {
|
||||
// 대상 서버에서 에러 응답이 와도 그대로 전달
|
||||
return ResponseEntity
|
||||
.status(e.getStatusCode())
|
||||
.headers(e.getHeaders())
|
||||
.body(e.getResponseBodyAsByteArray());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청 헤더 복사 (제외 대상 헤더 필터링)
|
||||
*/
|
||||
private void copyHeaders(HttpServletRequest request, HttpHeaders headers) {
|
||||
Enumeration<String> headerNames = request.getHeaderNames();
|
||||
while (headerNames.hasMoreElements()) {
|
||||
String headerName = headerNames.nextElement();
|
||||
if (!EXCLUDED_HEADERS.contains(headerName.toLowerCase())) {
|
||||
headers.set(headerName, request.getHeader(headerName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST/PUT/PATCH 메서드 여부 확인
|
||||
*/
|
||||
private boolean hasRequestBody(HttpMethod method) {
|
||||
return HttpMethod.POST.equals(method)
|
||||
|| HttpMethod.PUT.equals(method)
|
||||
|| HttpMethod.PATCH.equals(method);
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청 본문 읽기
|
||||
*/
|
||||
private byte[] readRequestBody(HttpServletRequest request) {
|
||||
try {
|
||||
return request.getInputStream().readAllBytes();
|
||||
} catch (Exception e) {
|
||||
log.warn("요청 본문 읽기 실패", e);
|
||||
return new byte[0];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 비동기 요청 로그 저장
|
||||
*/
|
||||
private void saveLog(HttpServletRequest request, SnpService service, SnpApiKey apiKey,
|
||||
String targetUrl, String gatewayPath, String status, Integer responseStatus,
|
||||
Integer responseTime, Long responseSize, String errorMessage,
|
||||
LocalDateTime requestedAt) {
|
||||
try {
|
||||
SnpApiRequestLog logEntry = SnpApiRequestLog.builder()
|
||||
.requestUrl(gatewayPath)
|
||||
.requestMethod(request.getMethod())
|
||||
.requestStatus(status)
|
||||
.requestIp(getClientIp(request))
|
||||
.requestHeaders(extractHeaders(request))
|
||||
.requestParams(request.getQueryString())
|
||||
.service(service)
|
||||
.user(apiKey != null ? apiKey.getUser() : null)
|
||||
.apiKey(apiKey)
|
||||
.tenant(apiKey != null && apiKey.getUser() != null ? apiKey.getUser().getTenant() : null)
|
||||
.responseStatus(responseStatus)
|
||||
.responseTime(responseTime)
|
||||
.responseSize(responseSize)
|
||||
.errorMessage(errorMessage)
|
||||
.requestedAt(requestedAt)
|
||||
.build();
|
||||
|
||||
requestLogService.saveLogAsync(logEntry);
|
||||
} catch (Exception e) {
|
||||
log.error("로그 엔트리 생성 실패", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 클라이언트 IP 추출
|
||||
*/
|
||||
private String getClientIp(HttpServletRequest request) {
|
||||
String xForwardedFor = request.getHeader("X-Forwarded-For");
|
||||
if (xForwardedFor != null && !xForwardedFor.isBlank()) {
|
||||
return xForwardedFor.split(",")[0].trim();
|
||||
}
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청 헤더를 문자열로 변환
|
||||
*/
|
||||
private String extractHeaders(HttpServletRequest request) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
Enumeration<String> headerNames = request.getHeaderNames();
|
||||
while (headerNames.hasMoreElements()) {
|
||||
String name = headerNames.nextElement();
|
||||
if (!"x-api-key".equalsIgnoreCase(name)) {
|
||||
sb.append(name).append(": ").append(request.getHeader(name)).append("\n");
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,77 @@
|
||||
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;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 헬스체크(하트비트) 모니터링 API
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/heartbeat")
|
||||
@RequiredArgsConstructor
|
||||
public class HeartbeatController {
|
||||
|
||||
private final HeartbeatService heartbeatService;
|
||||
|
||||
/**
|
||||
* 전체 서비스 헬스 상태 조회
|
||||
*/
|
||||
@GetMapping("/status")
|
||||
public ResponseEntity<ApiResponse<List<HeartbeatStatusResponse>>> getAllServiceStatus() {
|
||||
List<HeartbeatStatusResponse> statuses = heartbeatService.getAllServiceStatus();
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 서비스 헬스체크 이력 조회
|
||||
*/
|
||||
@GetMapping("/{serviceId}/history")
|
||||
public ResponseEntity<ApiResponse<List<HealthHistoryResponse>>> getHealthHistory(
|
||||
@PathVariable Long serviceId) {
|
||||
List<HealthHistoryResponse> history = heartbeatService.getHealthHistory(serviceId);
|
||||
return ResponseEntity.ok(ApiResponse.ok(history));
|
||||
}
|
||||
|
||||
/**
|
||||
* 수동 헬스체크 실행
|
||||
*/
|
||||
@PostMapping("/{serviceId}/check")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
|
||||
public ResponseEntity<ApiResponse<HeartbeatStatusResponse>> checkService(
|
||||
@PathVariable Long serviceId) {
|
||||
HeartbeatStatusResponse result = heartbeatService.checkService(serviceId);
|
||||
return ResponseEntity.ok(ApiResponse.ok(result));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
package com.gcsc.connection.monitoring.controller;
|
||||
|
||||
import com.gcsc.connection.common.dto.ApiResponse;
|
||||
import com.gcsc.connection.common.dto.PageResponse;
|
||||
import com.gcsc.connection.monitoring.dto.RequestLogResponse;
|
||||
import com.gcsc.connection.monitoring.dto.RequestLogSearchDto;
|
||||
import com.gcsc.connection.monitoring.service.RequestLogService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 요청 로그 조회 API
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/monitoring")
|
||||
@RequiredArgsConstructor
|
||||
public class RequestLogController {
|
||||
|
||||
private final RequestLogService requestLogService;
|
||||
|
||||
/**
|
||||
* 요청 로그 검색
|
||||
*/
|
||||
@GetMapping("/logs")
|
||||
public ResponseEntity<ApiResponse<PageResponse<RequestLogResponse>>> searchLogs(
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
|
||||
LocalDate startDate,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
|
||||
LocalDate endDate,
|
||||
@RequestParam(required = false) Long serviceId,
|
||||
@RequestParam(required = false) String requestStatus,
|
||||
@RequestParam(required = false) String requestMethod,
|
||||
@RequestParam(required = false) String requestIp,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size
|
||||
) {
|
||||
LocalDateTime startDateTime = startDate != null ? startDate.atStartOfDay() : null;
|
||||
LocalDateTime endDateTime = endDate != null ? endDate.plusDays(1).atStartOfDay() : null;
|
||||
RequestLogSearchDto searchDto = new RequestLogSearchDto(
|
||||
startDateTime, endDateTime, serviceId, requestStatus, requestMethod, requestIp, page, size);
|
||||
Page<RequestLogResponse> result = requestLogService.searchLogs(searchDto);
|
||||
return ResponseEntity.ok(ApiResponse.ok(PageResponse.from(result)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청 로그 상세 조회
|
||||
*/
|
||||
@GetMapping("/logs/{id}")
|
||||
public ResponseEntity<ApiResponse<RequestLogResponse>> getLogDetail(@PathVariable Long id) {
|
||||
return ResponseEntity.ok(ApiResponse.ok(requestLogService.getLogDetail(id)));
|
||||
}
|
||||
}
|
||||
@ -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,28 @@
|
||||
package com.gcsc.connection.monitoring.dto;
|
||||
|
||||
import com.gcsc.connection.monitoring.entity.SnpServiceHealthLog;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record HealthHistoryResponse(
|
||||
Long logId,
|
||||
Long serviceId,
|
||||
String previousStatus,
|
||||
String currentStatus,
|
||||
Integer responseTime,
|
||||
String errorMessage,
|
||||
LocalDateTime checkedAt
|
||||
) {
|
||||
|
||||
public static HealthHistoryResponse from(SnpServiceHealthLog log) {
|
||||
return new HealthHistoryResponse(
|
||||
log.getLogId(),
|
||||
log.getService().getServiceId(),
|
||||
log.getPreviousStatus(),
|
||||
log.getCurrentStatus(),
|
||||
log.getResponseTime(),
|
||||
log.getErrorMessage(),
|
||||
log.getCheckedAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
package com.gcsc.connection.monitoring.dto;
|
||||
|
||||
import com.gcsc.connection.service.entity.SnpService;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record HeartbeatStatusResponse(
|
||||
Long serviceId,
|
||||
String serviceCode,
|
||||
String serviceName,
|
||||
String healthStatus,
|
||||
LocalDateTime healthCheckedAt,
|
||||
Integer healthResponseTime
|
||||
) {
|
||||
|
||||
public static HeartbeatStatusResponse from(SnpService s) {
|
||||
return new HeartbeatStatusResponse(
|
||||
s.getServiceId(),
|
||||
s.getServiceCode(),
|
||||
s.getServiceName(),
|
||||
s.getHealthStatus().name(),
|
||||
s.getHealthCheckedAt(),
|
||||
s.getHealthResponseTime()
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,73 @@
|
||||
package com.gcsc.connection.monitoring.dto;
|
||||
|
||||
import com.gcsc.connection.monitoring.entity.SnpApiRequestLog;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record RequestLogResponse(
|
||||
Long logId,
|
||||
String requestUrl,
|
||||
String requestMethod,
|
||||
String requestStatus,
|
||||
String requestIp,
|
||||
Integer responseStatus,
|
||||
Integer responseTime,
|
||||
Long responseSize,
|
||||
String errorMessage,
|
||||
LocalDateTime requestedAt,
|
||||
Long serviceId,
|
||||
String serviceName,
|
||||
String apiKeyPrefix,
|
||||
String userName,
|
||||
String requestHeaders,
|
||||
String requestParams
|
||||
) {
|
||||
|
||||
/**
|
||||
* 목록 조회용 변환 (헤더/파라미터 제외)
|
||||
*/
|
||||
public static RequestLogResponse from(SnpApiRequestLog log) {
|
||||
return new RequestLogResponse(
|
||||
log.getLogId(),
|
||||
log.getRequestUrl(),
|
||||
log.getRequestMethod(),
|
||||
log.getRequestStatus(),
|
||||
log.getRequestIp(),
|
||||
log.getResponseStatus(),
|
||||
log.getResponseTime(),
|
||||
log.getResponseSize(),
|
||||
log.getErrorMessage(),
|
||||
log.getRequestedAt(),
|
||||
log.getService() != null ? log.getService().getServiceId() : null,
|
||||
log.getService() != null ? log.getService().getServiceName() : null,
|
||||
log.getApiKey() != null ? log.getApiKey().getApiKeyPrefix() : null,
|
||||
log.getUser() != null ? log.getUser().getUserName() : null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상세 조회용 변환 (모든 필드 포함)
|
||||
*/
|
||||
public static RequestLogResponse fromDetail(SnpApiRequestLog log) {
|
||||
return new RequestLogResponse(
|
||||
log.getLogId(),
|
||||
log.getRequestUrl(),
|
||||
log.getRequestMethod(),
|
||||
log.getRequestStatus(),
|
||||
log.getRequestIp(),
|
||||
log.getResponseStatus(),
|
||||
log.getResponseTime(),
|
||||
log.getResponseSize(),
|
||||
log.getErrorMessage(),
|
||||
log.getRequestedAt(),
|
||||
log.getService() != null ? log.getService().getServiceId() : null,
|
||||
log.getService() != null ? log.getService().getServiceName() : null,
|
||||
log.getApiKey() != null ? log.getApiKey().getApiKeyPrefix() : null,
|
||||
log.getUser() != null ? log.getUser().getUserName() : null,
|
||||
log.getRequestHeaders(),
|
||||
log.getRequestParams()
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
package com.gcsc.connection.monitoring.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record RequestLogSearchDto(
|
||||
LocalDateTime startDate,
|
||||
LocalDateTime endDate,
|
||||
Long serviceId,
|
||||
String requestStatus,
|
||||
String requestMethod,
|
||||
String requestIp,
|
||||
int page,
|
||||
int size
|
||||
) {
|
||||
|
||||
public RequestLogSearchDto {
|
||||
if (page < 0) page = 0;
|
||||
if (size <= 0) size = 20;
|
||||
}
|
||||
}
|
||||
@ -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) {}
|
||||
Some files were not shown because too many files have changed in this diff Show More
불러오는 중...
Reference in New Issue
Block a user