generated from gc/template-java-maven
Merge pull request 'release: 2026-04-08.2 (5건 커밋)' (#22) from develop into main
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 50s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 50s
This commit is contained in:
커밋
a5d7c84df6
@ -6,6 +6,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/ko/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2026-04-08.2]
|
||||
|
||||
### 추가
|
||||
|
||||
- 다크/라이트 모드 전체 적용 (ThemeContext, 토글 버튼, 전 페이지 dark 클래스) (#15)
|
||||
- API Key 신청 영구 사용 옵션 (#15)
|
||||
- API Key Admin 키 관리 만료일 컬럼 (#15)
|
||||
- Gateway API 경로 {변수} 패턴 매칭 지원 (#15)
|
||||
|
||||
### 변경
|
||||
|
||||
- 사이드바 아이콘 링크체인으로 변경, 헤더/사이드바 높이 통일 (#15)
|
||||
- 컨텐츠 영역 max-w-7xl 마진 통일 (#15)
|
||||
- 전체 Actions 버튼 bg-color-100 스타일 통일 (#15)
|
||||
- API Key Admin 권한 편집 제거 (승인 단계에서만 가능) (#15)
|
||||
- My Keys ADMIN 직접 생성 제거 → Request 폼 통일 (#15)
|
||||
|
||||
## [2026-04-08]
|
||||
|
||||
### 추가
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import ThemeProvider from './store/ThemeContext';
|
||||
import AuthProvider from './store/AuthContext';
|
||||
import AuthLayout from './layouts/AuthLayout';
|
||||
import MainLayout from './layouts/MainLayout';
|
||||
@ -22,6 +23,7 @@ const BASE_PATH = '/snp-connection';
|
||||
const App = () => {
|
||||
return (
|
||||
<BrowserRouter basename={BASE_PATH}>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route element={<AuthLayout />}>
|
||||
@ -47,6 +49,7 @@ const App = () => {
|
||||
</Route>
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
@ -6,8 +6,8 @@ const ProtectedRoute = () => {
|
||||
|
||||
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 className="flex min-h-screen items-center justify-center bg-gray-100 dark:bg-gray-900">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-500 border-t-transparent dark:border-blue-400 dark:border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
8
frontend/src/hooks/useTheme.ts
Normal file
8
frontend/src/hooks/useTheme.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { useContext } from 'react';
|
||||
import { ThemeContext } from '../store/ThemeContext';
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) throw new Error('useTheme must be used within ThemeProvider');
|
||||
return context;
|
||||
};
|
||||
@ -1 +1,8 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* Dark mode date input calendar icon */
|
||||
.dark input[type="date"]::-webkit-calendar-picker-indicator {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ import { Outlet } from 'react-router-dom';
|
||||
|
||||
const AuthLayout = () => {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50">
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Outlet, NavLink } from 'react-router-dom';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { useTheme } from '../hooks/useTheme';
|
||||
|
||||
interface NavGroup {
|
||||
label: string;
|
||||
@ -37,6 +38,7 @@ const navGroups: NavGroup[] = [
|
||||
|
||||
const MainLayout = () => {
|
||||
const { user, logout } = useAuth();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({
|
||||
Monitoring: true,
|
||||
'API Keys': true,
|
||||
@ -57,9 +59,9 @@ const MainLayout = () => {
|
||||
<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" />
|
||||
<div className="flex items-center gap-2 px-6 h-16 border-b border-gray-700">
|
||||
<svg className="h-6 w-6" style={{ color: '#FF2E63' }} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
<span className="text-lg font-semibold">SNP Connection</span>
|
||||
</div>
|
||||
@ -139,16 +141,31 @@ const MainLayout = () => {
|
||||
{/* 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">
|
||||
<header className="h-16 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 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>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="rounded-lg p-2 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
title={theme === 'light' ? 'Dark mode' : 'Light mode'}
|
||||
>
|
||||
{theme === 'light' ? (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">{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"
|
||||
className="rounded-lg px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
@ -156,7 +173,7 @@ const MainLayout = () => {
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<main className="p-6">
|
||||
<main className="p-6 bg-gray-100 dark:bg-gray-900 min-h-[calc(100vh-4rem)]">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@ -128,7 +128,7 @@ const DashboardPage = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Loading...</div>
|
||||
<div className="text-gray-500 dark:text-gray-400">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -137,47 +137,47 @@ const DashboardPage = () => {
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Dashboard</h1>
|
||||
{lastUpdated && (
|
||||
<span className="text-sm text-gray-500">마지막 갱신: {lastUpdated}</span>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">마지막 갱신: {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}%
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">오늘 총 요청</p>
|
||||
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100">{stats.totalRequests.toLocaleString()}</p>
|
||||
<p className={`text-sm ${stats.changePercent > 0 ? 'text-green-600' : stats.changePercent < 0 ? 'text-red-600' : 'text-gray-500 dark:text-gray-400'}`}>
|
||||
{stats.changePercent > 0 ? '\u25B2' : stats.changePercent < 0 ? '\u25BC' : ''} 전일 대비 {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>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">성공률</p>
|
||||
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100">{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 className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">평균 응답 시간</p>
|
||||
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100">{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 className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">활성 사용자</p>
|
||||
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100">{stats.activeUserCount}</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">오늘</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Row 2: Heartbeat Status Bar */}
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
||||
<div className="bg-white dark:bg-gray-800 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"
|
||||
className="flex items-center gap-2 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 rounded-lg px-2 py-1 transition-colors"
|
||||
onClick={() => navigate('/monitoring/service-status')}
|
||||
>
|
||||
<div
|
||||
@ -189,26 +189,26 @@ const DashboardPage = () => {
|
||||
: 'bg-gray-400'
|
||||
}`}
|
||||
/>
|
||||
<span className="font-medium">{svc.serviceName}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{svc.serviceName}</span>
|
||||
{svc.healthResponseTime !== null && (
|
||||
<span className="text-gray-500 text-sm">{svc.healthResponseTime}ms</span>
|
||||
<span className="text-gray-500 dark:text-gray-400 text-sm">{svc.healthResponseTime}ms</span>
|
||||
)}
|
||||
{svc.healthCheckedAt && (
|
||||
<span className="text-gray-400 text-xs">{svc.healthCheckedAt}</span>
|
||||
<span className="text-gray-400 dark:text-gray-500 text-xs">{svc.healthCheckedAt}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm">등록된 서비스가 없습니다</p>
|
||||
<p className="text-gray-500 dark:text-gray-400 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>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">시간별 요청 추이</h3>
|
||||
{hourlyTrend.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={hourlyTrend}>
|
||||
@ -222,13 +222,13 @@ const DashboardPage = () => {
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<p className="text-gray-400 text-center py-20">데이터가 없습니다</p>
|
||||
<p className="text-gray-400 dark:text-gray-500 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>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">서비스별 요청 비율</h3>
|
||||
{serviceRatio.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
@ -248,13 +248,13 @@ const DashboardPage = () => {
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<p className="text-gray-400 text-center py-20">데이터가 없습니다</p>
|
||||
<p className="text-gray-400 dark:text-gray-500 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>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">에러율 추이</h3>
|
||||
{errorTrendPivoted.data.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={errorTrendPivoted.data}>
|
||||
@ -277,13 +277,13 @@ const DashboardPage = () => {
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<p className="text-gray-400 text-center py-20">데이터가 없습니다</p>
|
||||
<p className="text-gray-400 dark:text-gray-500 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>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">상위 호출 API</h3>
|
||||
{topApis.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{topApis.map((api, idx) => {
|
||||
@ -296,30 +296,30 @@ const DashboardPage = () => {
|
||||
<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}>
|
||||
<span className="shrink-0 text-sm text-gray-900 dark:text-gray-100 w-48 truncate" title={api.apiName}>
|
||||
{api.apiName}
|
||||
</span>
|
||||
<div className="flex-1 bg-gray-100 rounded-full h-5 relative">
|
||||
<div className="flex-1 bg-gray-100 dark:bg-gray-700 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>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 w-12 text-right">{api.count}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-400 text-center py-20">데이터가 없습니다</p>
|
||||
<p className="text-gray-400 dark:text-gray-500 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>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">테넌트별 요청 비율</h3>
|
||||
{tenantRequestRatio.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
@ -339,12 +339,12 @@ const DashboardPage = () => {
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<p className="text-gray-400 text-center py-20">데이터가 없습니다</p>
|
||||
<p className="text-gray-400 dark:text-gray-500 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>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">테넌트별 사용자 비율</h3>
|
||||
{tenantUserRatio.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
@ -364,41 +364,41 @@ const DashboardPage = () => {
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<p className="text-gray-400 text-center py-20">데이터가 없습니다</p>
|
||||
<p className="text-gray-400 dark:text-gray-500 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 className="bg-white dark:bg-gray-800 rounded-lg shadow mb-6">
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">최근 요청 로그</h3>
|
||||
</div>
|
||||
{recentLogs.length > 0 ? (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<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>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">시간</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">서비스</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">사용자</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">URL</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">상태</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">응답시간</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{recentLogs.slice(0, 5).map((log) => (
|
||||
<tr
|
||||
key={log.logId}
|
||||
className="hover:bg-gray-50 cursor-pointer"
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-700 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}>
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-gray-400 whitespace-nowrap">{log.requestedAt}</td>
|
||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{log.serviceName ?? '-'}</td>
|
||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{log.userName ?? '-'}</td>
|
||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100" title={log.requestUrl}>
|
||||
{truncate(log.requestUrl, 40)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
@ -406,7 +406,7 @@ const DashboardPage = () => {
|
||||
{log.requestStatus}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600">
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">
|
||||
{log.responseTime !== null ? `${log.responseTime}ms` : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
@ -414,7 +414,7 @@ const DashboardPage = () => {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="px-6 py-3 border-t text-center">
|
||||
<div className="px-6 py-3 border-t border-gray-200 dark:border-gray-700 text-center">
|
||||
<button
|
||||
onClick={() => navigate('/monitoring/request-logs')}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 font-medium"
|
||||
@ -424,7 +424,7 @@ const DashboardPage = () => {
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-gray-400 text-center py-8">요청 로그가 없습니다</p>
|
||||
<p className="text-gray-400 dark:text-gray-500 text-center py-8">요청 로그가 없습니다</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -37,14 +37,14 @@ const LoginPage = () => {
|
||||
|
||||
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">
|
||||
<div className="rounded-xl bg-white dark:bg-gray-800 px-8 py-10 shadow-lg">
|
||||
<h1 className="mb-8 text-center text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
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 htmlFor="loginId" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
아이디
|
||||
</label>
|
||||
<input
|
||||
@ -52,14 +52,14 @@ const LoginPage = () => {
|
||||
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"
|
||||
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 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 htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
비밀번호
|
||||
</label>
|
||||
<input
|
||||
@ -67,7 +67,7 @@ const LoginPage = () => {
|
||||
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"
|
||||
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 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"
|
||||
/>
|
||||
|
||||
@ -3,8 +3,8 @@ 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>
|
||||
<h1 className="text-4xl font-bold text-gray-900 dark:text-gray-100">404 - Page Not Found</h1>
|
||||
<p className="mt-3 text-gray-600 dark:text-gray-400">요청하신 페이지를 찾을 수 없습니다.</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"
|
||||
|
||||
@ -208,13 +208,13 @@ const ServicesPage = () => {
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center py-10 text-gray-500">로딩 중...</div>;
|
||||
return <div className="max-w-7xl mx-auto text-center py-10 text-gray-500 dark:text-gray-400">로딩 중...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Services</h1>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">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"
|
||||
@ -227,21 +227,21 @@ const ServicesPage = () => {
|
||||
<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">
|
||||
<div className="overflow-x-auto bg-white dark:bg-gray-800 rounded-lg shadow mb-6">
|
||||
<table className="w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<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>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Code</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Name</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">URL</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Health Status</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Response Time</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Last Checked</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Active</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{services.map((service) => {
|
||||
const badge = HEALTH_BADGE[service.healthStatus] || HEALTH_BADGE.UNKNOWN;
|
||||
const isSelected = selectedService?.serviceId === service.serviceId;
|
||||
@ -250,12 +250,12 @@ const ServicesPage = () => {
|
||||
key={service.serviceId}
|
||||
onClick={() => handleSelectService(service)}
|
||||
className={`cursor-pointer ${
|
||||
isSelected ? 'bg-blue-50' : 'hover:bg-gray-50'
|
||||
isSelected ? 'bg-blue-50 dark:bg-blue-900/30' : 'hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<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]">
|
||||
<td className="px-4 py-3 font-mono text-gray-900 dark:text-gray-100">{service.serviceCode}</td>
|
||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{service.serviceName}</td>
|
||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400 truncate max-w-[200px]">
|
||||
{service.serviceUrl || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
@ -266,12 +266,12 @@ const ServicesPage = () => {
|
||||
{service.healthStatus}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500">
|
||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">
|
||||
{service.healthResponseTime != null
|
||||
? `${service.healthResponseTime}ms`
|
||||
: '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500">
|
||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">
|
||||
{formatRelativeTime(service.healthCheckedAt)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
@ -291,9 +291,9 @@ const ServicesPage = () => {
|
||||
e.stopPropagation();
|
||||
handleOpenEditService(service);
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-800 font-medium"
|
||||
className="bg-blue-100 hover:bg-blue-200 text-blue-700 dark:bg-blue-900/30 dark:hover:bg-blue-800/40 dark:text-blue-400 px-3 py-1 rounded-lg text-sm font-medium"
|
||||
>
|
||||
Edit
|
||||
수정
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@ -301,7 +301,7 @@ const ServicesPage = () => {
|
||||
})}
|
||||
{services.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
|
||||
<td colSpan={8} className="px-4 py-8 text-center text-gray-400 dark:text-gray-500">
|
||||
등록된 서비스가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
@ -311,9 +311,9 @@ const ServicesPage = () => {
|
||||
</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">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
APIs for {selectedService.serviceName}
|
||||
</h2>
|
||||
<button
|
||||
@ -324,19 +324,19 @@ const ServicesPage = () => {
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full divide-y divide-gray-200 text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<table className="w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<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>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Method</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Path</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Name</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Description</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Active</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{serviceApis.map((api) => (
|
||||
<tr key={api.apiId} className="hover:bg-gray-50">
|
||||
<tr key={api.apiId} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded text-xs font-bold ${
|
||||
@ -346,9 +346,9 @@ const ServicesPage = () => {
|
||||
{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 font-mono text-gray-900 dark:text-gray-100">{api.apiPath}</td>
|
||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{api.apiName}</td>
|
||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{api.description || '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
@ -364,7 +364,7 @@ const ServicesPage = () => {
|
||||
))}
|
||||
{serviceApis.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-gray-400">
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-gray-400 dark:text-gray-500">
|
||||
등록된 API가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
@ -377,9 +377,9 @@ const ServicesPage = () => {
|
||||
|
||||
{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">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{editingService ? '서비스 수정' : '서비스 생성'}
|
||||
</h2>
|
||||
</div>
|
||||
@ -389,7 +389,7 @@ const ServicesPage = () => {
|
||||
<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">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Service Code
|
||||
</label>
|
||||
<input
|
||||
@ -398,11 +398,11 @@ const ServicesPage = () => {
|
||||
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"
|
||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:bg-gray-100 disabled:text-gray-500 dark:disabled:bg-gray-600 dark:disabled:text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Service Name
|
||||
</label>
|
||||
<input
|
||||
@ -410,44 +410,44 @@ const ServicesPage = () => {
|
||||
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"
|
||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 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 className="block text-sm font-medium text-gray-700 dark:text-gray-300 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"
|
||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 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 className="block text-sm font-medium text-gray-700 dark:text-gray-300 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"
|
||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 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 className="block text-sm font-medium text-gray-700 dark:text-gray-300 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"
|
||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 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 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Health Check Interval (seconds)
|
||||
</label>
|
||||
<input
|
||||
@ -455,7 +455,7 @@ const ServicesPage = () => {
|
||||
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"
|
||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
{editingService && (
|
||||
@ -467,17 +467,17 @@ const ServicesPage = () => {
|
||||
onChange={(e) => setServiceIsActive(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<label htmlFor="serviceIsActive" className="text-sm text-gray-700">
|
||||
<label htmlFor="serviceIsActive" className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t flex justify-end gap-2">
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 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"
|
||||
className="bg-gray-200 hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@ -495,9 +495,9 @@ const ServicesPage = () => {
|
||||
|
||||
{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 className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">API 생성</h2>
|
||||
</div>
|
||||
<form onSubmit={handleApiSubmit}>
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
@ -505,11 +505,11 @@ const ServicesPage = () => {
|
||||
<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>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 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"
|
||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 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>
|
||||
@ -518,42 +518,42 @@ const ServicesPage = () => {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">API Path</label>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 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"
|
||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 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>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 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"
|
||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 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 className="block text-sm font-medium text-gray-700 dark:text-gray-300 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"
|
||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 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">
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 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"
|
||||
className="bg-gray-200 hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
@ -94,13 +94,13 @@ const TenantsPage = () => {
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center py-10 text-gray-500">로딩 중...</div>;
|
||||
return <div className="max-w-7xl mx-auto text-center py-10 text-gray-500 dark:text-gray-400">로딩 중...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Tenants</h1>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">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"
|
||||
@ -113,24 +113,24 @@ const TenantsPage = () => {
|
||||
<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">
|
||||
<div className="overflow-x-auto bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<table className="w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<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>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Code</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Name</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Description</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Active</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Created</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{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>
|
||||
<tr key={tenant.tenantId} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td className="px-4 py-3 font-mono text-gray-900 dark:text-gray-100">{tenant.tenantCode}</td>
|
||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{tenant.tenantName}</td>
|
||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{tenant.description || '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
@ -142,22 +142,22 @@ const TenantsPage = () => {
|
||||
{tenant.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500">
|
||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">
|
||||
{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"
|
||||
className="bg-blue-100 hover:bg-blue-200 text-blue-700 dark:bg-blue-900/30 dark:hover:bg-blue-800/40 dark:text-blue-400 px-3 py-1 rounded-lg text-sm font-medium"
|
||||
>
|
||||
Edit
|
||||
수정
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{tenants.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-gray-400">
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-gray-400 dark:text-gray-500">
|
||||
등록된 테넌트가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
@ -168,9 +168,9 @@ const TenantsPage = () => {
|
||||
|
||||
{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">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{editingTenant ? '테넌트 수정' : '테넌트 생성'}
|
||||
</h2>
|
||||
</div>
|
||||
@ -180,7 +180,7 @@ const TenantsPage = () => {
|
||||
<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">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Tenant Code
|
||||
</label>
|
||||
<input
|
||||
@ -189,11 +189,11 @@ const TenantsPage = () => {
|
||||
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"
|
||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:bg-gray-100 disabled:text-gray-500 dark:disabled:bg-gray-600 dark:disabled:text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Tenant Name
|
||||
</label>
|
||||
<input
|
||||
@ -201,18 +201,18 @@ const TenantsPage = () => {
|
||||
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"
|
||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 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 className="block text-sm font-medium text-gray-700 dark:text-gray-300 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"
|
||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
{editingTenant && (
|
||||
@ -224,17 +224,17 @@ const TenantsPage = () => {
|
||||
onChange={(e) => setIsActive(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<label htmlFor="isActive" className="text-sm text-gray-700">
|
||||
<label htmlFor="isActive" className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t flex justify-end gap-2">
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 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"
|
||||
className="bg-gray-200 hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
@ -135,13 +135,13 @@ const UsersPage = () => {
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center py-10 text-gray-500">로딩 중...</div>;
|
||||
return <div className="max-w-7xl mx-auto text-center py-10 text-gray-500 dark:text-gray-400">로딩 중...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Users</h1>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">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"
|
||||
@ -154,27 +154,27 @@ const UsersPage = () => {
|
||||
<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">
|
||||
<div className="overflow-x-auto bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<table className="w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<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>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Login ID</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Name</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Email</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Tenant</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Role</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Active</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Last Login</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{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>
|
||||
<tr key={user.userId} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td className="px-4 py-3 font-mono text-gray-900 dark:text-gray-100">{user.loginId}</td>
|
||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{user.userName}</td>
|
||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{user.email || '-'}</td>
|
||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{user.tenantName || '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
@ -195,7 +195,7 @@ const UsersPage = () => {
|
||||
{user.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500">
|
||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">
|
||||
{user.lastLoginAt
|
||||
? new Date(user.lastLoginAt).toLocaleString()
|
||||
: '-'}
|
||||
@ -203,14 +203,14 @@ const UsersPage = () => {
|
||||
<td className="px-4 py-3 space-x-2">
|
||||
<button
|
||||
onClick={() => handleOpenEdit(user)}
|
||||
className="text-blue-600 hover:text-blue-800 font-medium"
|
||||
className="bg-blue-100 hover:bg-blue-200 text-blue-700 dark:bg-blue-900/30 dark:hover:bg-blue-800/40 dark:text-blue-400 px-3 py-1 rounded-lg text-sm font-medium"
|
||||
>
|
||||
Edit
|
||||
수정
|
||||
</button>
|
||||
{user.isActive && (
|
||||
<button
|
||||
onClick={() => handleDeactivate(user)}
|
||||
className="text-red-600 hover:text-red-800 font-medium"
|
||||
className="bg-red-100 hover:bg-red-200 text-red-700 dark:bg-red-900/30 dark:hover:bg-red-800/40 dark:text-red-400 px-3 py-1 rounded-lg text-sm font-medium"
|
||||
>
|
||||
비활성화
|
||||
</button>
|
||||
@ -220,7 +220,7 @@ const UsersPage = () => {
|
||||
))}
|
||||
{users.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
|
||||
<td colSpan={8} className="px-4 py-8 text-center text-gray-400 dark:text-gray-500">
|
||||
등록된 사용자가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
@ -231,9 +231,9 @@ const UsersPage = () => {
|
||||
|
||||
{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">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{editingUser ? '사용자 수정' : '사용자 생성'}
|
||||
</h2>
|
||||
</div>
|
||||
@ -243,52 +243,52 @@ const UsersPage = () => {
|
||||
<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>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 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"
|
||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:bg-gray-100 disabled:text-gray-500 dark:disabled:bg-gray-600 dark:disabled:text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 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"
|
||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 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>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 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"
|
||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 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>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 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"
|
||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 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>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 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"
|
||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">-- 선택 --</option>
|
||||
{tenants.map((t) => (
|
||||
@ -299,11 +299,11 @@ const UsersPage = () => {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Role</label>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 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"
|
||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 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>
|
||||
@ -320,17 +320,17 @@ const UsersPage = () => {
|
||||
onChange={(e) => setIsActive(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<label htmlFor="isActive" className="text-sm text-gray-700">
|
||||
<label htmlFor="isActive" className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t flex justify-end gap-2">
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 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"
|
||||
className="bg-gray-200 hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
@ -7,8 +7,6 @@ import {
|
||||
revokeKey,
|
||||
getAllRequests,
|
||||
reviewRequest,
|
||||
getPermissions,
|
||||
updatePermissions,
|
||||
} from '../../services/apiKeyService';
|
||||
import { getServices, getServiceApis } from '../../services/serviceService';
|
||||
|
||||
@ -62,10 +60,6 @@ const KeyAdminPage = () => {
|
||||
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)
|
||||
@ -217,55 +211,6 @@ const KeyAdminPage = () => {
|
||||
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;
|
||||
|
||||
@ -300,12 +245,12 @@ const KeyAdminPage = () => {
|
||||
|
||||
return (
|
||||
<div key={service.serviceId} className="mb-3">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-1">{service.serviceName}</h4>
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 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"
|
||||
className="flex items-center gap-2 py-0.5 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 rounded px-1"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
@ -315,13 +260,13 @@ const KeyAdminPage = () => {
|
||||
/>
|
||||
<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'
|
||||
METHOD_COLOR[api.apiMethod] || 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{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>
|
||||
<span className="font-mono text-sm text-gray-700 dark:text-gray-300">{api.apiPath}</span>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">- {api.apiName}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
@ -331,21 +276,21 @@ const KeyAdminPage = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">API Key 관리</h1>
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-6">API Key 관리</h1>
|
||||
|
||||
{error && !isReviewModalOpen && !isDetailModalOpen && !isPermissionModalOpen && (
|
||||
{error && !isReviewModalOpen && !isDetailModalOpen && (
|
||||
<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">
|
||||
<div className="flex border-b border-gray-200 dark:border-gray-700 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'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
신청 관리
|
||||
@ -355,7 +300,7 @@ const KeyAdminPage = () => {
|
||||
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'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
키 관리
|
||||
@ -366,27 +311,27 @@ const KeyAdminPage = () => {
|
||||
{activeTab === 'requests' && (
|
||||
<div>
|
||||
{requestsLoading ? (
|
||||
<div className="text-center py-10 text-gray-500">로딩 중...</div>
|
||||
<div className="text-center py-10 text-gray-500 dark:text-gray-400">로딩 중...</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">
|
||||
<div className="overflow-x-auto bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<table className="w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<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>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Requester</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Key Name</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Purpose</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Status</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">APIs</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Created At</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{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">
|
||||
<tr key={req.requestId} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{req.userName}</td>
|
||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{req.keyName}</td>
|
||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400 max-w-[200px] truncate">
|
||||
{req.purpose || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
@ -398,13 +343,13 @@ const KeyAdminPage = () => {
|
||||
{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 text-gray-500 dark:text-gray-400">{req.requestedApiIds.length}개</td>
|
||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{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"
|
||||
className="bg-blue-100 hover:bg-blue-200 text-blue-700 dark:bg-blue-900/30 dark:hover:bg-blue-800/40 dark:text-blue-400 px-3 py-1 rounded-lg text-sm font-medium"
|
||||
>
|
||||
검토
|
||||
</button>
|
||||
@ -414,7 +359,7 @@ const KeyAdminPage = () => {
|
||||
))}
|
||||
{requests.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-gray-400">
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-gray-400 dark:text-gray-500">
|
||||
신청 내역이 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
@ -430,27 +375,28 @@ const KeyAdminPage = () => {
|
||||
{activeTab === 'keys' && (
|
||||
<div>
|
||||
{keysLoading ? (
|
||||
<div className="text-center py-10 text-gray-500">로딩 중...</div>
|
||||
<div className="text-center py-10 text-gray-500 dark:text-gray-400">로딩 중...</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">
|
||||
<div className="overflow-x-auto bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<table className="w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<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>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Key Name</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Prefix</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">User</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Status</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Last Used</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">만료일</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Created</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{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>
|
||||
<tr key={key.apiKeyId} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{key.keyName}</td>
|
||||
<td className="px-4 py-3 font-mono text-gray-600 dark:text-gray-400">{key.apiKeyPrefix}</td>
|
||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{key.maskedKey}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
@ -460,26 +406,23 @@ const KeyAdminPage = () => {
|
||||
{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 text-gray-500 dark:text-gray-400">{formatDateTime(key.lastUsedAt)}</td>
|
||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">
|
||||
{key.expiresAt ? formatDateTime(key.expiresAt) : '영구'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{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"
|
||||
className="bg-blue-100 hover:bg-blue-200 text-blue-700 dark:bg-blue-900/30 dark:hover:bg-blue-800/40 dark:text-blue-400 px-3 py-1 rounded-lg text-sm font-medium"
|
||||
>
|
||||
상세
|
||||
</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"
|
||||
className="bg-red-100 hover:bg-red-200 text-red-700 dark:bg-red-900/30 dark:hover:bg-red-800/40 dark:text-red-400 px-3 py-1 rounded-lg text-sm font-medium"
|
||||
>
|
||||
폐기
|
||||
</button>
|
||||
@ -490,7 +433,7 @@ const KeyAdminPage = () => {
|
||||
))}
|
||||
{allKeys.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-gray-400">
|
||||
<td colSpan={8} className="px-4 py-8 text-center text-gray-400 dark:text-gray-500">
|
||||
등록된 API Key가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
@ -505,9 +448,9 @@ const KeyAdminPage = () => {
|
||||
{/* 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 className="bg-white dark:bg-gray-800 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 border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">신청 검토</h2>
|
||||
</div>
|
||||
<div className="px-6 py-4 space-y-4 overflow-y-auto flex-1">
|
||||
{error && (
|
||||
@ -516,80 +459,80 @@ const KeyAdminPage = () => {
|
||||
|
||||
<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>
|
||||
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400">신청자</label>
|
||||
<p className="text-gray-900 dark:text-gray-100">{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>
|
||||
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400">Key Name</label>
|
||||
<p className="text-gray-900 dark:text-gray-100">{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>
|
||||
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400">목적</label>
|
||||
<p className="text-gray-900 dark:text-gray-100">{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>
|
||||
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400">서비스 IP</label>
|
||||
<p className="font-mono text-gray-900 dark:text-gray-100">{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>
|
||||
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400">서비스 용도</label>
|
||||
<p className="text-gray-900 dark:text-gray-100">{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>
|
||||
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400">하루 예상 요청량</label>
|
||||
<p className="text-gray-900 dark:text-gray-100">{Number(selectedRequest.dailyRequestEstimate).toLocaleString()}건</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">사용 기간 (조정 가능)</label>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 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" />
|
||||
className="border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 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" />
|
||||
className="border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 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">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
API 권한 ({adjustedApiIds.size}개 선택)
|
||||
</label>
|
||||
<div className="border rounded-lg p-3 max-h-60 overflow-y-auto">
|
||||
<div className="border border-gray-200 dark:border-gray-700 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>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 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"
|
||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 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">
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 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"
|
||||
className="bg-gray-200 hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
@ -613,26 +556,26 @@ const KeyAdminPage = () => {
|
||||
{/* 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 className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-lg mx-4">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">키 상세 정보</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>
|
||||
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400">Key Name</label>
|
||||
<p className="text-gray-900 dark:text-gray-100">{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>
|
||||
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400">User</label>
|
||||
<p className="text-gray-900 dark:text-gray-100">{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>
|
||||
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400">Prefix</label>
|
||||
<p className="font-mono text-gray-900 dark:text-gray-100">{selectedKeyDetail.apiKeyPrefix}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500">Status</label>
|
||||
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400">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'
|
||||
@ -642,22 +585,22 @@ const KeyAdminPage = () => {
|
||||
</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>
|
||||
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400">Expires At</label>
|
||||
<p className="text-gray-900 dark:text-gray-100">{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>
|
||||
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400">Last Used</label>
|
||||
<p className="text-gray-900 dark:text-gray-100">{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>
|
||||
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400">Created At</label>
|
||||
<p className="text-gray-900 dark:text-gray-100">{formatDateTime(selectedKeyDetail.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 mb-1">Decrypted Key</label>
|
||||
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400 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">
|
||||
<code className="flex-1 bg-gray-100 dark:bg-gray-700 px-3 py-2 rounded-lg text-sm font-mono break-all text-gray-900 dark:text-gray-100">
|
||||
{selectedKeyDetail.decryptedKey}
|
||||
</code>
|
||||
<button
|
||||
@ -669,13 +612,13 @@ const KeyAdminPage = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t flex justify-end">
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 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"
|
||||
className="bg-gray-200 hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
@ -684,68 +627,25 @@ const KeyAdminPage = () => {
|
||||
</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 className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-lg mx-4">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">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>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Key Name</label>
|
||||
<p className="text-gray-900 dark:text-gray-100">{rawKeyModal.keyName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">API Key</label>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 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">
|
||||
<code className="flex-1 bg-gray-100 dark:bg-gray-700 px-3 py-2 rounded-lg text-sm font-mono break-all text-gray-900 dark:text-gray-100">
|
||||
{rawKeyModal.rawKey}
|
||||
</code>
|
||||
<button
|
||||
@ -757,13 +657,13 @@ const KeyAdminPage = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t flex justify-end">
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 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"
|
||||
className="bg-gray-200 hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
|
||||
@ -24,6 +24,7 @@ const KeyRequestPage = () => {
|
||||
const [servicePurpose, setServicePurpose] = useState('');
|
||||
const [dailyRequestEstimate, setDailyRequestEstimate] = useState('');
|
||||
const [usagePeriodMode, setUsagePeriodMode] = useState<'preset' | 'custom'>('preset');
|
||||
const [isPermanent, setIsPermanent] = useState(false);
|
||||
const [usageFromDate, setUsageFromDate] = useState('');
|
||||
const [usageToDate, setUsageToDate] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@ -111,6 +112,13 @@ const KeyRequestPage = () => {
|
||||
setUsageFromDate(from.toISOString().split('T')[0]);
|
||||
setUsageToDate(to.toISOString().split('T')[0]);
|
||||
setUsagePeriodMode('preset');
|
||||
setIsPermanent(false);
|
||||
};
|
||||
|
||||
const handlePermanent = () => {
|
||||
setIsPermanent(true);
|
||||
setUsageFromDate('');
|
||||
setUsageToDate('');
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
@ -119,7 +127,7 @@ const KeyRequestPage = () => {
|
||||
setError('최소 하나의 API를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
if (!usageFromDate || !usageToDate) {
|
||||
if (!isPermanent && (!usageFromDate || !usageToDate)) {
|
||||
setError('사용 기간을 설정해주세요.');
|
||||
return;
|
||||
}
|
||||
@ -135,8 +143,8 @@ const KeyRequestPage = () => {
|
||||
serviceIp: serviceIp || undefined,
|
||||
servicePurpose: servicePurpose || undefined,
|
||||
dailyRequestEstimate: dailyRequestEstimate ? Number(dailyRequestEstimate) : undefined,
|
||||
usageFromDate,
|
||||
usageToDate,
|
||||
usageFromDate: isPermanent ? undefined : usageFromDate,
|
||||
usageToDate: isPermanent ? undefined : usageToDate,
|
||||
});
|
||||
if (res.success) {
|
||||
setSuccess(true);
|
||||
@ -151,11 +159,12 @@ const KeyRequestPage = () => {
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-center py-10 text-gray-500">로딩 중...</div>;
|
||||
return <div className="max-w-7xl mx-auto text-center py-10 text-gray-500 dark:text-gray-400">로딩 중...</div>;
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<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>
|
||||
@ -170,21 +179,22 @@ const KeyRequestPage = () => {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">API Key 신청</h1>
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 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 className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Key Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
@ -193,83 +203,97 @@ const KeyRequestPage = () => {
|
||||
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"
|
||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 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>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 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"
|
||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 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 className="block text-sm font-medium text-gray-700 dark:text-gray-300 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'}`}>
|
||||
disabled={isPermanent || usagePeriodMode === 'custom'}
|
||||
className={`px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 ${isPermanent || usagePeriodMode === 'custom' ? 'opacity-40 cursor-not-allowed' : 'hover:bg-blue-50 dark:hover:bg-gray-600'}`}>
|
||||
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'}`}>
|
||||
disabled={isPermanent || usagePeriodMode === 'custom'}
|
||||
className={`px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 ${isPermanent || usagePeriodMode === 'custom' ? 'opacity-40 cursor-not-allowed' : 'hover:bg-blue-50 dark:hover:bg-gray-600'}`}>
|
||||
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'}`}>
|
||||
disabled={isPermanent || usagePeriodMode === 'custom'}
|
||||
className={`px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 ${isPermanent || usagePeriodMode === 'custom' ? 'opacity-40 cursor-not-allowed' : 'hover:bg-blue-50 dark:hover:bg-gray-600'}`}>
|
||||
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">
|
||||
<span className="text-gray-400 dark:text-gray-600 mx-1">|</span>
|
||||
<button type="button" onClick={handlePermanent}
|
||||
className={`px-3 py-1.5 text-sm rounded-lg border font-medium ${isPermanent ? 'bg-indigo-600 text-white border-indigo-600' : 'text-indigo-600 border-indigo-300 dark:border-indigo-500 dark:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/30'}`}>
|
||||
영구
|
||||
</button>
|
||||
<span className="text-gray-400 dark:text-gray-600 mx-1">|</span>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 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' : ''}`} />
|
||||
onClick={() => {
|
||||
setUsagePeriodMode(usagePeriodMode === 'custom' ? 'preset' : 'custom');
|
||||
setIsPermanent(false);
|
||||
}}
|
||||
className={`relative w-10 h-5 rounded-full transition-colors ${usagePeriodMode === 'custom' && !isPermanent ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-600'}`}>
|
||||
<span className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform ${usagePeriodMode === 'custom' && !isPermanent ? 'translate-x-5' : ''}`} />
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
{isPermanent ? (
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-indigo-50 dark:bg-indigo-900/30 border border-indigo-200 dark:border-indigo-700 rounded-lg">
|
||||
<span className="text-indigo-700 dark:text-indigo-300 text-sm font-medium">영구 사용 (만료일 없음)</span>
|
||||
</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>
|
||||
className={`border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none ${usagePeriodMode !== 'custom' ? 'bg-gray-50 text-gray-500 dark:bg-gray-700 dark:text-gray-400' : 'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100'}`} />
|
||||
<span className="text-gray-500 dark:text-gray-400">~</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' : ''}`} />
|
||||
className={`border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none ${usagePeriodMode !== 'custom' ? 'bg-gray-50 text-gray-500 dark:bg-gray-700 dark:text-gray-400' : 'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100'}`} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 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>
|
||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none" />
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 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">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 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">
|
||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 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>
|
||||
@ -278,13 +302,13 @@ const KeyRequestPage = () => {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 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">
|
||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 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>
|
||||
@ -297,13 +321,13 @@ const KeyRequestPage = () => {
|
||||
</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>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow mb-6">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
API 선택 <span className="text-sm font-normal text-gray-500 dark:text-gray-400 dark:text-gray-400">({selectedApiIds.size}개 선택됨)</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-200">
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{services.map((service) => {
|
||||
const apis = serviceApisMap[service.serviceId] || [];
|
||||
const isExpanded = expandedServices.has(service.serviceId);
|
||||
@ -313,25 +337,25 @@ const KeyRequestPage = () => {
|
||||
return (
|
||||
<div key={service.serviceId}>
|
||||
<div
|
||||
className="px-6 py-3 flex items-center justify-between cursor-pointer hover:bg-gray-50"
|
||||
className="px-6 py-3 flex items-center justify-between cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
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>
|
||||
<span className="text-gray-400 dark:text-gray-500 text-sm">{isExpanded ? '\u25BC' : '\u25B6'}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{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>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">{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">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
@ -346,7 +370,7 @@ const KeyRequestPage = () => {
|
||||
{apis.map((api) => (
|
||||
<label
|
||||
key={api.apiId}
|
||||
className="flex items-center gap-2 py-1 cursor-pointer hover:bg-gray-50 rounded px-2"
|
||||
className="flex items-center gap-2 py-1 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 rounded px-2"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
@ -356,17 +380,17 @@ const KeyRequestPage = () => {
|
||||
/>
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded text-xs font-bold ${
|
||||
METHOD_COLOR[api.apiMethod] || 'bg-gray-100 text-gray-800'
|
||||
METHOD_COLOR[api.apiMethod] || 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{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>
|
||||
<span className="font-mono text-sm text-gray-700 dark:text-gray-300">{api.apiPath}</span>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">- {api.apiName}</span>
|
||||
</label>
|
||||
))}
|
||||
{apis.length === 0 && (
|
||||
<p className="text-sm text-gray-400 py-1">등록된 API가 없습니다.</p>
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500 py-1">등록된 API가 없습니다.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -375,7 +399,7 @@ const KeyRequestPage = () => {
|
||||
);
|
||||
})}
|
||||
{services.length === 0 && (
|
||||
<div className="px-6 py-8 text-center text-gray-400">
|
||||
<div className="px-6 py-8 text-center text-gray-400 dark:text-gray-500">
|
||||
등록된 서비스가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
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';
|
||||
import { getMyKeys, revokeKey } from '../../services/apiKeyService';
|
||||
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
ACTIVE: 'bg-green-100 text-green-800',
|
||||
@ -18,16 +17,10 @@ const formatDateTime = (dateStr: string | null): string => {
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
@ -66,25 +59,6 @@ const MyKeysPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
@ -93,59 +67,45 @@ const MyKeysPage = () => {
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center py-10 text-gray-500">로딩 중...</div>;
|
||||
return <div className="max-w-7xl mx-auto text-center py-10 text-gray-500 dark:text-gray-400">로딩 중...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">My API Keys</h1>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">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 && (
|
||||
{error && (
|
||||
<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">
|
||||
<div className="overflow-x-auto bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<table className="w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<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>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Key Name</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Prefix</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Status</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Expires At</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Last Used At</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Created At</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{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>
|
||||
<tr key={key.apiKeyId} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{key.keyName}</td>
|
||||
<td className="px-4 py-3 font-mono text-gray-600 dark:text-gray-400">{key.apiKeyPrefix}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
@ -155,14 +115,14 @@ const MyKeysPage = () => {
|
||||
{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 text-gray-500 dark:text-gray-400">{formatDateTime(key.expiresAt)}</td>
|
||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{formatDateTime(key.lastUsedAt)}</td>
|
||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{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"
|
||||
className="bg-red-100 hover:bg-red-200 text-red-700 dark:bg-red-900/30 dark:hover:bg-red-800/40 dark:text-red-400 px-3 py-1 rounded-lg text-sm font-medium"
|
||||
>
|
||||
폐기
|
||||
</button>
|
||||
@ -172,7 +132,7 @@ const MyKeysPage = () => {
|
||||
))}
|
||||
{keys.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-gray-400">
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-gray-400 dark:text-gray-500">
|
||||
등록된 API Key가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
@ -181,66 +141,24 @@ const MyKeysPage = () => {
|
||||
</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 className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-lg mx-4">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">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>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Key Name</label>
|
||||
<p className="text-gray-900 dark:text-gray-100">{rawKeyModal.keyName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">API Key</label>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 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">
|
||||
<code className="flex-1 bg-gray-100 dark:bg-gray-700 px-3 py-2 rounded-lg text-sm font-mono break-all text-gray-900 dark:text-gray-100">
|
||||
{rawKeyModal.rawKey}
|
||||
</code>
|
||||
<button
|
||||
@ -252,13 +170,13 @@ const MyKeysPage = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t flex justify-end">
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 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"
|
||||
className="bg-gray-200 hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
|
||||
@ -70,12 +70,12 @@ const RequestLogDetailPage = () => {
|
||||
}, [id]);
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center py-10 text-gray-500">로딩 중...</div>;
|
||||
return <div className="max-w-7xl mx-auto text-center py-10 text-gray-500 dark:text-gray-400">로딩 중...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div>
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="text-blue-600 hover:text-blue-800 font-medium mb-4"
|
||||
@ -93,7 +93,7 @@ const RequestLogDetailPage = () => {
|
||||
const formattedParams = formatJson(log.requestParams);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="text-blue-600 hover:text-blue-800 font-medium mb-6"
|
||||
@ -102,16 +102,16 @@ const RequestLogDetailPage = () => {
|
||||
</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="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 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>
|
||||
<span className="block text-sm font-medium text-gray-500 dark:text-gray-400">요청 시간</span>
|
||||
<span className="text-sm text-gray-900 dark:text-gray-100">{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="block text-sm font-medium text-gray-500 dark:text-gray-400">요청</span>
|
||||
<span className="text-sm text-gray-900 dark:text-gray-100">
|
||||
<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'
|
||||
@ -123,25 +123,25 @@ const RequestLogDetailPage = () => {
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-gray-500">응답 코드</span>
|
||||
<span className="text-sm text-gray-900">
|
||||
<span className="block text-sm font-medium text-gray-500 dark:text-gray-400">응답 코드</span>
|
||||
<span className="text-sm text-gray-900 dark:text-gray-100">
|
||||
{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">
|
||||
<span className="block text-sm font-medium text-gray-500 dark:text-gray-400">응답시간(ms)</span>
|
||||
<span className="text-sm text-gray-900 dark:text-gray-100">
|
||||
{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">
|
||||
<span className="block text-sm font-medium text-gray-500 dark:text-gray-400">응답크기(bytes)</span>
|
||||
<span className="text-sm text-gray-900 dark:text-gray-100">
|
||||
{log.responseSize != null ? log.responseSize : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-gray-500">상태</span>
|
||||
<span className="block text-sm font-medium text-gray-500 dark:text-gray-400">상태</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'
|
||||
@ -153,42 +153,42 @@ const RequestLogDetailPage = () => {
|
||||
</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>
|
||||
<span className="block text-sm font-medium text-gray-500 dark:text-gray-400">서비스</span>
|
||||
<span className="text-sm text-gray-900 dark:text-gray-100">{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">
|
||||
<span className="block text-sm font-medium text-gray-500 dark:text-gray-400">API Key</span>
|
||||
<span className="text-sm text-gray-900 dark:text-gray-100 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>
|
||||
<span className="block text-sm font-medium text-gray-500 dark:text-gray-400">사용자</span>
|
||||
<span className="text-sm text-gray-900 dark:text-gray-100">{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>
|
||||
<span className="block text-sm font-medium text-gray-500 dark:text-gray-400">IP</span>
|
||||
<span className="text-sm text-gray-900 dark:text-gray-100 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>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 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">
|
||||
<span className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Request Headers</span>
|
||||
<pre className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4 text-sm text-gray-800 dark:text-gray-200 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">
|
||||
<span className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Request Params</span>
|
||||
<pre className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4 text-sm text-gray-800 dark:text-gray-200 overflow-x-auto">
|
||||
{formattedParams}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
@ -163,36 +163,36 @@ const RequestLogsPage = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Request Logs</h1>
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-6">Request Logs</h1>
|
||||
|
||||
{/* Search Form */}
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<div className="bg-white dark:bg-gray-800 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>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 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"
|
||||
className="flex-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
<span className="text-gray-500">~</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">~</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"
|
||||
className="flex-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 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>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 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"
|
||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">전체</option>
|
||||
{services.map((s) => (
|
||||
@ -203,11 +203,11 @@ const RequestLogsPage = () => {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">상태</label>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 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"
|
||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 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) => (
|
||||
@ -218,11 +218,11 @@ const RequestLogsPage = () => {
|
||||
</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>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 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"
|
||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 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) => (
|
||||
@ -231,13 +231,13 @@ const RequestLogsPage = () => {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">IP</label>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 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"
|
||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 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">
|
||||
@ -249,7 +249,7 @@ const RequestLogsPage = () => {
|
||||
</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"
|
||||
className="bg-gray-200 hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
초기화
|
||||
</button>
|
||||
@ -262,35 +262,35 @@ const RequestLogsPage = () => {
|
||||
)}
|
||||
|
||||
{/* Results Table */}
|
||||
<div className="overflow-x-auto bg-white rounded-lg shadow mb-6">
|
||||
<div className="overflow-x-auto bg-white dark:bg-gray-800 rounded-lg shadow mb-6">
|
||||
{loading ? (
|
||||
<div className="text-center py-10 text-gray-500">로딩 중...</div>
|
||||
<div className="text-center py-10 text-gray-500 dark:text-gray-400">로딩 중...</div>
|
||||
) : (
|
||||
<table className="w-full divide-y divide-gray-200 text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<table className="w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<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>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">시간</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">서비스</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Method</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">URL</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Status Code</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">응답시간(ms)</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">상태</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{result && result.content.length > 0 ? (
|
||||
result.content.map((log) => (
|
||||
<tr
|
||||
key={log.logId}
|
||||
onClick={() => handleRowClick(log.logId)}
|
||||
className="cursor-pointer hover:bg-gray-50"
|
||||
className="cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
<td className="px-4 py-3 whitespace-nowrap text-gray-700 dark:text-gray-300">
|
||||
{formatDateTime(log.requestedAt)}
|
||||
</td>
|
||||
<td className="px-4 py-3">{log.serviceName || '-'}</td>
|
||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{log.serviceName || '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded text-xs font-bold ${
|
||||
@ -300,13 +300,13 @@ const RequestLogsPage = () => {
|
||||
{log.requestMethod}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500 truncate max-w-[250px]" title={log.requestUrl}>
|
||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400 truncate max-w-[250px]" title={log.requestUrl}>
|
||||
{log.requestUrl}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<td className="px-4 py-3 text-gray-700 dark:text-gray-300">
|
||||
{log.responseStatus != null ? log.responseStatus : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<td className="px-4 py-3 text-gray-700 dark:text-gray-300">
|
||||
{log.responseTime != null ? log.responseTime : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
@ -318,12 +318,12 @@ const RequestLogsPage = () => {
|
||||
{log.requestStatus}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">{log.requestIp}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-600 dark:text-gray-400">{log.requestIp}</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
|
||||
<td colSpan={8} className="px-4 py-8 text-center text-gray-400 dark:text-gray-500">
|
||||
검색 결과가 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
@ -336,21 +336,21 @@ const RequestLogsPage = () => {
|
||||
{/* Pagination */}
|
||||
{result && result.totalElements > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
총 {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"
|
||||
className="bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
이전
|
||||
</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"
|
||||
className="bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
다음
|
||||
</button>
|
||||
|
||||
@ -53,15 +53,15 @@ const ServiceStatusDetailPage = () => {
|
||||
}, [fetchData]);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-center py-20 text-gray-500">로딩 중...</div>;
|
||||
return <div className="text-center py-20 text-gray-500 dark:text-gray-400">로딩 중...</div>;
|
||||
}
|
||||
|
||||
if (!detail) {
|
||||
return <div className="text-center py-20 text-gray-500">서비스를 찾을 수 없습니다</div>;
|
||||
return <div className="text-center py-20 text-gray-500 dark:text-gray-400">서비스를 찾을 수 없습니다</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<button
|
||||
onClick={() => navigate('/monitoring/service-status')}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 mb-4 inline-block"
|
||||
@ -70,28 +70,28 @@ const ServiceStatusDetailPage = () => {
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<div className="bg-white dark:bg-gray-800 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>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">{detail.serviceName}</h1>
|
||||
<span className="text-gray-500 dark:text-gray-400">{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 className="text-sm text-gray-500 dark:text-gray-400">{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>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">90일 Uptime</h2>
|
||||
<div className="text-4xl font-bold text-gray-900 dark:text-gray-100 mb-4">{detail.uptimePercent90d.toFixed(3)}%</div>
|
||||
|
||||
{/* 90-Day Bar */}
|
||||
<div className="flex items-center gap-0.5 mb-2">
|
||||
@ -110,16 +110,16 @@ const ServiceStatusDetailPage = () => {
|
||||
</div>
|
||||
))}
|
||||
{detail.dailyUptime.length === 0 && (
|
||||
<div className="flex-1 h-10 bg-gray-100 rounded-sm" />
|
||||
<div className="flex-1 h-10 bg-gray-100 dark:bg-gray-700 rounded-sm" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-gray-400">
|
||||
<div className="flex justify-between text-xs text-gray-400 dark:text-gray-500">
|
||||
<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-4 mt-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
<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>
|
||||
@ -129,31 +129,31 @@ const ServiceStatusDetailPage = () => {
|
||||
</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 className="bg-white dark:bg-gray-800 rounded-lg shadow mb-6">
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">최근 체크 이력</h2>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<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>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">시간</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">상태</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">응답시간</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">에러</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{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>
|
||||
<tr key={idx} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-gray-400 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">
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">
|
||||
{check.responseTime !== null ? `${check.responseTime}ms` : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-red-600 text-xs max-w-xs truncate">
|
||||
@ -163,7 +163,7 @@ const ServiceStatusDetailPage = () => {
|
||||
))}
|
||||
{detail.recentChecks.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-8 text-center text-gray-400">
|
||||
<td colSpan={4} className="px-4 py-8 text-center text-gray-400 dark:text-gray-500">
|
||||
체크 이력이 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@ -57,14 +57,14 @@ const ServiceStatusPage = () => {
|
||||
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="text-center py-20 text-gray-500 dark:text-gray-400">로딩 중...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="max-w-7xl 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>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Service Status</h1>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">마지막 갱신: {lastUpdated}</span>
|
||||
</div>
|
||||
|
||||
{/* Overall Status Banner */}
|
||||
@ -80,19 +80,19 @@ const ServiceStatusPage = () => {
|
||||
{/* Service List */}
|
||||
<div className="space-y-6">
|
||||
{services.map((svc) => (
|
||||
<div key={svc.serviceId} className="bg-white rounded-lg shadow">
|
||||
<div key={svc.serviceId} className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
{/* Service Header */}
|
||||
<div className="p-6 border-b">
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<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"
|
||||
className="text-lg font-semibold text-gray-900 dark:text-gray-100 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>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">{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'}`}>
|
||||
@ -103,8 +103,8 @@ const ServiceStatusPage = () => {
|
||||
)}
|
||||
</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 className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
90일 Uptime: <span className="font-medium text-gray-900 dark:text-gray-100">{svc.uptimePercent90d.toFixed(2)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -129,10 +129,10 @@ const ServiceStatusPage = () => {
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex-1 h-8 bg-gray-100 rounded-sm" />
|
||||
<div className="flex-1 h-8 bg-gray-100 dark:bg-gray-700 rounded-sm" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between mt-1 text-xs text-gray-400">
|
||||
<div className="flex justify-between mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
<span>{svc.dailyUptime.length > 0 ? formatDate(svc.dailyUptime[0].date) : ''}</span>
|
||||
<span>Today</span>
|
||||
</div>
|
||||
@ -141,7 +141,7 @@ const ServiceStatusPage = () => {
|
||||
))}
|
||||
|
||||
{services.length === 0 && (
|
||||
<div className="text-center py-20 text-gray-400">등록된 서비스가 없습니다</div>
|
||||
<div className="text-center py-20 text-gray-400 dark:text-gray-500">등록된 서비스가 없습니다</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -8,7 +8,6 @@ import type {
|
||||
ApiKeyRequestCreateDto,
|
||||
ApiKeyRequestReviewDto,
|
||||
Permission,
|
||||
UpdatePermissionsRequest,
|
||||
} from '../types/apikey';
|
||||
|
||||
// My Keys
|
||||
@ -28,5 +27,3 @@ export const reviewRequest = (id: number, req: ApiKeyRequestReviewDto) =>
|
||||
|
||||
// Permissions
|
||||
export const getPermissions = (keyId: number) => get<Permission[]>(`/keys/${keyId}/permissions`);
|
||||
export const updatePermissions = (keyId: number, req: UpdatePermissionsRequest) =>
|
||||
put<Permission[]>(`/keys/${keyId}/permissions`, req);
|
||||
|
||||
48
frontend/src/store/ThemeContext.tsx
Normal file
48
frontend/src/store/ThemeContext.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import { createContext, useState, useEffect, useCallback } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: Theme;
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
export const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const getInitialTheme = (): Theme => {
|
||||
const stored = localStorage.getItem('theme');
|
||||
if (stored === 'light' || stored === 'dark') {
|
||||
return stored;
|
||||
}
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
};
|
||||
|
||||
const ThemeProvider = ({ children }: ThemeProviderProps) => {
|
||||
const [theme, setTheme] = useState<Theme>(getInitialTheme);
|
||||
|
||||
useEffect(() => {
|
||||
if (theme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
localStorage.setItem('theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeProvider;
|
||||
@ -83,12 +83,9 @@ public class GatewayService {
|
||||
// 5. Key 상태/만료 검증
|
||||
validateApiKey(apiKey);
|
||||
|
||||
// 6. ServiceApi 조회 (경로 + 메서드 매칭)
|
||||
// 6. ServiceApi 조회 (경로 + 메서드 매칭, {변수} 패턴 지원)
|
||||
String apiPath = remainingPath.startsWith("/") ? remainingPath : "/" + remainingPath;
|
||||
SnpServiceApi serviceApi = snpServiceApiRepository
|
||||
.findByServiceServiceIdAndApiPathAndApiMethod(
|
||||
service.getServiceId(), apiPath, request.getMethod())
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.GATEWAY_PERMISSION_DENIED));
|
||||
SnpServiceApi serviceApi = matchServiceApi(service.getServiceId(), apiPath, request.getMethod());
|
||||
|
||||
// 6. 권한 확인
|
||||
snpApiPermissionRepository
|
||||
@ -130,6 +127,37 @@ public class GatewayService {
|
||||
/**
|
||||
* prefix 매칭 후 복호화하여 API Key 찾기
|
||||
*/
|
||||
/**
|
||||
* ServiceApi 매칭 ({변수} 패턴 지원)
|
||||
* 등록된 /api/v1/tracks/haegu/{haeguNo} 가 /api/v1/tracks/haegu/101 과 매칭됨
|
||||
*/
|
||||
private SnpServiceApi matchServiceApi(Long serviceId, String requestPath, String method) {
|
||||
// 1. 정확 매칭 시도
|
||||
var exact = snpServiceApiRepository
|
||||
.findByServiceServiceIdAndApiPathAndApiMethod(serviceId, requestPath, method);
|
||||
if (exact.isPresent()) {
|
||||
return exact.get();
|
||||
}
|
||||
|
||||
// 2. {변수} 패턴 매칭
|
||||
List<SnpServiceApi> candidates = snpServiceApiRepository.findByServiceServiceId(serviceId);
|
||||
for (SnpServiceApi api : candidates) {
|
||||
if (!api.getApiMethod().equalsIgnoreCase(method)) continue;
|
||||
if (!Boolean.TRUE.equals(api.getIsActive())) continue;
|
||||
|
||||
String pattern = api.getApiPath();
|
||||
if (!pattern.contains("{")) continue;
|
||||
|
||||
// {variable}을 [^/]+ 정규식으로 치환
|
||||
String regex = pattern.replaceAll("\\{[^}]+}", "[^/]+");
|
||||
if (requestPath.matches(regex)) {
|
||||
return api;
|
||||
}
|
||||
}
|
||||
|
||||
throw new BusinessException(ErrorCode.GATEWAY_PERMISSION_DENIED);
|
||||
}
|
||||
|
||||
private SnpApiKey findApiKeyByRawKey(String rawKey) {
|
||||
if (rawKey.length() < API_KEY_PREFIX_LENGTH) {
|
||||
throw new BusinessException(ErrorCode.GATEWAY_API_KEY_INVALID);
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user