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

This commit is contained in:
HYOJIN 2026-04-08 13:50:09 +09:00
커밋 4d07ac1663
137개의 변경된 파일10334개의 추가작업 그리고 27개의 파일을 삭제

파일 보기

@ -4,7 +4,7 @@ API Gateway + 모니터링 통합 플랫폼. 모든 서비스 사용자가 모
## 기술 스택 ## 기술 스택
- Java 17, Spring Boot 3.2.1, Spring Data JPA - Java 17, Spring Boot 3.2.1, Spring Data JPA
- PostgreSQL (스키마: std_snp_connection) - PostgreSQL (DB: snp_connection, 스키마: common)
- Spring Security (JWT 기반 인증 예정) - Spring Security (JWT 기반 인증 예정)
- WebFlux WebClient (Heartbeat, Gateway Proxy) - WebFlux WebClient (Heartbeat, Gateway Proxy)
- Springdoc OpenAPI 2.3.0 (Swagger) - 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] ## [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] ## [2026-04-07]
### 추가 ### 추가

파일 보기

@ -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);

파일 보기

@ -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;

파일 보기

@ -10,7 +10,8 @@
"dependencies": { "dependencies": {
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^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": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
@ -1013,6 +1014,42 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.3", "version": "1.0.0-rc.3",
"resolved": "https://nexus.gc-si.dev/repository/npm-public/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", "resolved": "https://nexus.gc-si.dev/repository/npm-public/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
@ -1370,6 +1407,18 @@
"win32" "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": { "node_modules/@tailwindcss/node": {
"version": "4.2.2", "version": "4.2.2",
"resolved": "https://nexus.gc-si.dev/repository/npm-public/@tailwindcss/node/-/node-4.2.2.tgz", "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" "@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": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://nexus.gc-si.dev/repository/npm-public/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://nexus.gc-si.dev/repository/npm-public/@types/estree/-/estree-1.0.8.tgz",
@ -1715,7 +1827,7 @@
"version": "19.2.14", "version": "19.2.14",
"resolved": "https://nexus.gc-si.dev/repository/npm-public/@types/react/-/react-19.2.14.tgz", "resolved": "https://nexus.gc-si.dev/repository/npm-public/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
@ -1731,6 +1843,12 @@
"@types/react": "^19.2.0" "@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": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.58.0", "version": "8.58.0",
"resolved": "https://nexus.gc-si.dev/repository/npm-public/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz", "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" "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": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://nexus.gc-si.dev/repository/npm-public/color-convert/-/color-convert-2.0.1.tgz", "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", "version": "3.2.3",
"resolved": "https://nexus.gc-si.dev/repository/npm-public/csstype/-/csstype-3.2.3.tgz", "resolved": "https://nexus.gc-si.dev/repository/npm-public/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true, "devOptional": true,
"license": "MIT" "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": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://nexus.gc-si.dev/repository/npm-public/debug/-/debug-4.4.3.tgz", "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": { "node_modules/deep-is": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://nexus.gc-si.dev/repository/npm-public/deep-is/-/deep-is-0.1.4.tgz", "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": ">=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": { "node_modules/esbuild": {
"version": "0.27.7", "version": "0.27.7",
"resolved": "https://nexus.gc-si.dev/repository/npm-public/esbuild/-/esbuild-0.27.7.tgz", "resolved": "https://nexus.gc-si.dev/repository/npm-public/esbuild/-/esbuild-0.27.7.tgz",
@ -2597,6 +2861,12 @@
"node": ">=0.10.0" "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": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://nexus.gc-si.dev/repository/npm-public/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "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": ">= 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": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://nexus.gc-si.dev/repository/npm-public/import-fresh/-/import-fresh-3.3.1.tgz", "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": ">=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": { "node_modules/is-extglob": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://nexus.gc-si.dev/repository/npm-public/is-extglob/-/is-extglob-2.1.1.tgz", "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" "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": { "node_modules/react-refresh": {
"version": "0.18.0", "version": "0.18.0",
"resolved": "https://nexus.gc-si.dev/repository/npm-public/react-refresh/-/react-refresh-0.18.0.tgz", "resolved": "https://nexus.gc-si.dev/repository/npm-public/react-refresh/-/react-refresh-0.18.0.tgz",
@ -3534,6 +3853,57 @@
"react-dom": ">=18" "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": { "node_modules/resolve-from": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://nexus.gc-si.dev/repository/npm-public/resolve-from/-/resolve-from-4.0.0.tgz", "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" "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": { "node_modules/tinyglobby": {
"version": "0.2.15", "version": "0.2.15",
"resolved": "https://nexus.gc-si.dev/repository/npm-public/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://nexus.gc-si.dev/repository/npm-public/tinyglobby/-/tinyglobby-0.2.15.tgz",
@ -3820,6 +4196,37 @@
"punycode": "^2.1.0" "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": { "node_modules/vite": {
"version": "7.3.2", "version": "7.3.2",
"resolved": "https://nexus.gc-si.dev/repository/npm-public/vite/-/vite-7.3.2.tgz", "resolved": "https://nexus.gc-si.dev/repository/npm-public/vite/-/vite-7.3.2.tgz",

파일 보기

@ -13,7 +13,8 @@
"dependencies": { "dependencies": {
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^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": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",

파일 보기

@ -1,26 +1,54 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; 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'; const BASE_PATH = '/snp-connection';
function App() { const App = () => {
return ( return (
<BrowserRouter basename={BASE_PATH}> <BrowserRouter basename={BASE_PATH}>
<Routes> <AuthProvider>
<Route path="/" element={<Navigate to="/dashboard" replace />} /> <Routes>
<Route <Route element={<AuthLayout />}>
path="/dashboard" <Route path="/login" element={<LoginPage />} />
element={ </Route>
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="text-center"> <Route element={<ProtectedRoute />}>
<h1 className="text-3xl font-bold text-gray-900">SNP Connection Monitoring</h1> <Route element={<MainLayout />}>
<p className="mt-2 text-gray-600">Dashboard - Coming Soon</p> <Route path="/" element={<Navigate to="/dashboard" replace />} />
</div> <Route path="/dashboard" element={<DashboardPage />} />
</div> <Route path="/monitoring/request-logs" element={<RequestLogsPage />} />
} <Route path="/monitoring/request-logs/:id" element={<RequestLogDetailPage />} />
/> <Route path="/monitoring/service-status" element={<ServiceStatusPage />} />
</Routes> <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> </BrowserRouter>
); );
} };
export default App; export default App;

파일 보기

@ -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;

파일 보기

@ -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;
};

파일 보기

@ -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;

파일 보기

@ -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;

파일 보기

@ -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;

파일 보기

@ -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;

파일 보기

@ -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;

파일 보기

@ -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;

파일 보기

@ -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;

파일 보기

@ -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;

파일 보기

@ -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;

파일 보기

@ -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;

파일 보기

@ -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;

파일 보기

@ -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"
>
&larr;
</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"
>
&larr;
</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;

파일 보기

@ -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;

파일 보기

@ -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" /> &lt;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;

파일 보기

@ -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;

파일 보기

@ -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' });
};

파일 보기

@ -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);

파일 보기

@ -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);
};

파일 보기

@ -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');

파일 보기

@ -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`);

파일 보기

@ -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}`);

파일 보기

@ -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);

파일 보기

@ -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);

파일 보기

@ -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}`);

파일 보기

@ -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;

파일 보기

@ -0,0 +1,5 @@
export interface ApiResponse<T> {
success: boolean;
message?: string;
data?: T;
}

파일 보기

@ -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[];
}

파일 보기

@ -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;
}

파일 보기

@ -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;
}

파일 보기

@ -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;
}

파일 보기

@ -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;
}

파일 보기

@ -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;
}

파일 보기

@ -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
파일 보기

@ -104,6 +104,25 @@
<version>2.3.0</version> <version>2.3.0</version>
</dependency> </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 --> <!-- Test Dependencies -->
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <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;
}
}

파일 보기

@ -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 lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; 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.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.stream.Collectors;
@Slf4j @Slf4j
@RestControllerAdvice @RestControllerAdvice
public class GlobalExceptionHandler { 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) @ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleException(Exception e) { public ResponseEntity<ApiResponse<Void>> handleException(Exception e) {
log.error("Unhandled exception: {}", e.getMessage(), 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);
}
}
}

파일 보기

@ -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; 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.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; 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.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; 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.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig { public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean @Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http http
.csrf(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable)
.headers(headers -> headers .headers(headers -> headers
.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)) .frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin))
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth .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(); 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