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

This commit is contained in:
HYOJIN 2026-04-08 16:58:23 +09:00
커밋 a5d7c84df6
23개의 변경된 파일712개의 추가작업 그리고 745개의 파일을 삭제

파일 보기

@ -6,6 +6,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/ko/1.0.0/).
## [Unreleased] ## [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] ## [2026-04-08]
### 추가 ### 추가

파일 보기

@ -1,4 +1,5 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import ThemeProvider from './store/ThemeContext';
import AuthProvider from './store/AuthContext'; import AuthProvider from './store/AuthContext';
import AuthLayout from './layouts/AuthLayout'; import AuthLayout from './layouts/AuthLayout';
import MainLayout from './layouts/MainLayout'; import MainLayout from './layouts/MainLayout';
@ -22,6 +23,7 @@ const BASE_PATH = '/snp-connection';
const App = () => { const App = () => {
return ( return (
<BrowserRouter basename={BASE_PATH}> <BrowserRouter basename={BASE_PATH}>
<ThemeProvider>
<AuthProvider> <AuthProvider>
<Routes> <Routes>
<Route element={<AuthLayout />}> <Route element={<AuthLayout />}>
@ -47,6 +49,7 @@ const App = () => {
</Route> </Route>
</Routes> </Routes>
</AuthProvider> </AuthProvider>
</ThemeProvider>
</BrowserRouter> </BrowserRouter>
); );
}; };

파일 보기

@ -6,8 +6,8 @@ const ProtectedRoute = () => {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex min-h-screen items-center justify-center"> <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" /> <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> </div>
); );
} }

파일 보기

@ -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'; @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 = () => { const AuthLayout = () => {
return ( 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 /> <Outlet />
</div> </div>
); );

파일 보기

@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { Outlet, NavLink } from 'react-router-dom'; import { Outlet, NavLink } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth'; import { useAuth } from '../hooks/useAuth';
import { useTheme } from '../hooks/useTheme';
interface NavGroup { interface NavGroup {
label: string; label: string;
@ -37,6 +38,7 @@ const navGroups: NavGroup[] = [
const MainLayout = () => { const MainLayout = () => {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const { theme, toggleTheme } = useTheme();
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({ const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({
Monitoring: true, Monitoring: true,
'API Keys': true, 'API Keys': true,
@ -57,9 +59,9 @@ const MainLayout = () => {
<div className="flex min-h-screen"> <div className="flex min-h-screen">
{/* Sidebar */} {/* Sidebar */}
<aside className="fixed left-0 top-0 h-screen w-64 bg-gray-900 text-white flex flex-col"> <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"> <div className="flex items-center gap-2 px-6 h-16 border-b border-gray-700">
<svg className="h-6 w-6 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <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 10V3L4 14h7v7l9-11h-7z" /> <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> </svg>
<span className="text-lg font-semibold">SNP Connection</span> <span className="text-lg font-semibold">SNP Connection</span>
</div> </div>
@ -139,16 +141,31 @@ const MainLayout = () => {
{/* Main Content */} {/* Main Content */}
<div className="flex-1 ml-64"> <div className="flex-1 ml-64">
{/* Header */} {/* 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 />
<div className="flex items-center gap-4"> <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"> <span className="rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800">
{user?.role} {user?.role}
</span> </span>
<button <button
onClick={handleLogout} 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 Logout
</button> </button>
@ -156,7 +173,7 @@ const MainLayout = () => {
</header> </header>
{/* Content */} {/* Content */}
<main className="p-6"> <main className="p-6 bg-gray-100 dark:bg-gray-900 min-h-[calc(100vh-4rem)]">
<Outlet /> <Outlet />
</main> </main>
</div> </div>

파일 보기

@ -128,7 +128,7 @@ const DashboardPage = () => {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center h-64"> <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> </div>
); );
} }
@ -137,47 +137,47 @@ const DashboardPage = () => {
<div> <div>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between mb-6"> <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 && ( {lastUpdated && (
<span className="text-sm text-gray-500"> : {lastUpdated}</span> <span className="text-sm text-gray-500 dark:text-gray-400"> : {lastUpdated}</span>
)} )}
</div> </div>
{/* Row 1: Summary Cards */} {/* Row 1: Summary Cards */}
{stats && ( {stats && (
<div className="grid grid-cols-4 gap-4 mb-6"> <div className="grid grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-lg shadow p-6"> <div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<p className="text-sm text-gray-500"> </p> <p className="text-sm text-gray-500 dark:text-gray-400"> </p>
<p className="text-3xl font-bold">{stats.totalRequests.toLocaleString()}</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'}`}> <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 ? '' : stats.changePercent < 0 ? '' : ''} {stats.changePercent}% {stats.changePercent > 0 ? '\u25B2' : stats.changePercent < 0 ? '\u25BC' : ''} {stats.changePercent}%
</p> </p>
</div> </div>
<div className="bg-white rounded-lg shadow p-6"> <div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<p className="text-sm text-gray-500"></p> <p className="text-sm text-gray-500 dark:text-gray-400"></p>
<p className="text-3xl font-bold">{stats.successRate.toFixed(1)}%</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> <p className="text-sm text-red-500"> {stats.failureCount}</p>
</div> </div>
<div className="bg-white rounded-lg shadow p-6"> <div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<p className="text-sm text-gray-500"> </p> <p className="text-sm text-gray-500 dark:text-gray-400"> </p>
<p className="text-3xl font-bold">{stats.avgResponseTime.toFixed(0)}ms</p> <p className="text-3xl font-bold text-gray-900 dark:text-gray-100">{stats.avgResponseTime.toFixed(0)}ms</p>
</div> </div>
<div className="bg-white rounded-lg shadow p-6"> <div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<p className="text-sm text-gray-500"> </p> <p className="text-sm text-gray-500 dark:text-gray-400"> </p>
<p className="text-3xl font-bold">{stats.activeUserCount}</p> <p className="text-3xl font-bold text-gray-900 dark:text-gray-100">{stats.activeUserCount}</p>
<p className="text-sm text-gray-500"></p> <p className="text-sm text-gray-500 dark:text-gray-400"></p>
</div> </div>
</div> </div>
)} )}
{/* Row 2: Heartbeat Status Bar */} {/* 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 ? ( {heartbeat.length > 0 ? (
<div className="flex flex-row gap-6"> <div className="flex flex-row gap-6">
{heartbeat.map((svc) => ( {heartbeat.map((svc) => (
<div <div
key={svc.serviceId} 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')} onClick={() => navigate('/monitoring/service-status')}
> >
<div <div
@ -189,26 +189,26 @@ const DashboardPage = () => {
: 'bg-gray-400' : '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 && ( {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 && ( {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>
))} ))}
</div> </div>
) : ( ) : (
<p className="text-gray-500 text-sm"> </p> <p className="text-gray-500 dark:text-gray-400 text-sm"> </p>
)} )}
</div> </div>
{/* Row 3: Charts 2x2 */} {/* Row 3: Charts 2x2 */}
<div className="grid grid-cols-2 gap-6 mb-6"> <div className="grid grid-cols-2 gap-6 mb-6">
{/* Chart 1: Hourly Trend */} {/* Chart 1: Hourly Trend */}
<div className="bg-white rounded-lg shadow p-6"> <div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4"> </h3> <h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4"> </h3>
{hourlyTrend.length > 0 ? ( {hourlyTrend.length > 0 ? (
<ResponsiveContainer width="100%" height={300}> <ResponsiveContainer width="100%" height={300}>
<LineChart data={hourlyTrend}> <LineChart data={hourlyTrend}>
@ -222,13 +222,13 @@ const DashboardPage = () => {
</LineChart> </LineChart>
</ResponsiveContainer> </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>
{/* Chart 2: Service Ratio */} {/* Chart 2: Service Ratio */}
<div className="bg-white rounded-lg shadow p-6"> <div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4"> </h3> <h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4"> </h3>
{serviceRatio.length > 0 ? ( {serviceRatio.length > 0 ? (
<ResponsiveContainer width="100%" height={300}> <ResponsiveContainer width="100%" height={300}>
<PieChart> <PieChart>
@ -248,13 +248,13 @@ const DashboardPage = () => {
</PieChart> </PieChart>
</ResponsiveContainer> </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>
{/* Chart 3: Error Trend */} {/* Chart 3: Error Trend */}
<div className="bg-white rounded-lg shadow p-6"> <div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4"> </h3> <h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4"> </h3>
{errorTrendPivoted.data.length > 0 ? ( {errorTrendPivoted.data.length > 0 ? (
<ResponsiveContainer width="100%" height={300}> <ResponsiveContainer width="100%" height={300}>
<AreaChart data={errorTrendPivoted.data}> <AreaChart data={errorTrendPivoted.data}>
@ -277,13 +277,13 @@ const DashboardPage = () => {
</AreaChart> </AreaChart>
</ResponsiveContainer> </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>
{/* Chart 4: Top APIs */} {/* Chart 4: Top APIs */}
<div className="bg-white rounded-lg shadow p-6"> <div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4"> API</h3> <h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4"> API</h3>
{topApis.length > 0 ? ( {topApis.length > 0 ? (
<div className="space-y-2"> <div className="space-y-2">
{topApis.map((api, idx) => { {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}`}> <span className={`shrink-0 px-1.5 py-0.5 rounded text-xs font-medium ${colors.tag}`}>
{api.serviceName} {api.serviceName}
</span> </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} {api.apiName}
</span> </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 <div
className="h-5 rounded-full" className="h-5 rounded-full"
style={{ width: `${pct}%`, backgroundColor: colors.bar }} style={{ width: `${pct}%`, backgroundColor: colors.bar }}
/> />
</div> </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>
); );
})} })}
</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>
</div> </div>
{/* Row 4: Tenant Stats */} {/* Row 4: Tenant Stats */}
<div className="grid grid-cols-2 gap-6 mb-6"> <div className="grid grid-cols-2 gap-6 mb-6">
<div className="bg-white rounded-lg shadow p-6"> <div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4"> </h3> <h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4"> </h3>
{tenantRequestRatio.length > 0 ? ( {tenantRequestRatio.length > 0 ? (
<ResponsiveContainer width="100%" height={300}> <ResponsiveContainer width="100%" height={300}>
<PieChart> <PieChart>
@ -339,12 +339,12 @@ const DashboardPage = () => {
</PieChart> </PieChart>
</ResponsiveContainer> </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>
<div className="bg-white rounded-lg shadow p-6"> <div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4"> </h3> <h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4"> </h3>
{tenantUserRatio.length > 0 ? ( {tenantUserRatio.length > 0 ? (
<ResponsiveContainer width="100%" height={300}> <ResponsiveContainer width="100%" height={300}>
<PieChart> <PieChart>
@ -364,41 +364,41 @@ const DashboardPage = () => {
</PieChart> </PieChart>
</ResponsiveContainer> </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>
</div> </div>
{/* Row 5: Recent Logs */} {/* Row 5: Recent Logs */}
<div className="bg-white rounded-lg shadow mb-6"> <div className="bg-white dark:bg-gray-800 rounded-lg shadow mb-6">
<div className="p-6 border-b border-gray-200"> <div className="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900"> </h3> <h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100"> </h3>
</div> </div>
{recentLogs.length > 0 ? ( {recentLogs.length > 0 ? (
<> <>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-gray-50"> <thead className="bg-gray-50 dark:bg-gray-700">
<tr> <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 dark:text-gray-400 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 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 uppercase">URL</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 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 uppercase"></th> <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase"></th>
</tr> </tr>
</thead> </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) => ( {recentLogs.slice(0, 5).map((log) => (
<tr <tr
key={log.logId} 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}`)} 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-600 dark:text-gray-400 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 dark:text-gray-100">{log.serviceName ?? '-'}</td>
<td className="px-4 py-3 text-gray-900">{log.userName ?? '-'}</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" title={log.requestUrl}> <td className="px-4 py-3 text-gray-900 dark:text-gray-100" title={log.requestUrl}>
{truncate(log.requestUrl, 40)} {truncate(log.requestUrl, 40)}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
@ -406,7 +406,7 @@ const DashboardPage = () => {
{log.requestStatus} {log.requestStatus}
</span> </span>
</td> </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` : '-'} {log.responseTime !== null ? `${log.responseTime}ms` : '-'}
</td> </td>
</tr> </tr>
@ -414,7 +414,7 @@ const DashboardPage = () => {
</tbody> </tbody>
</table> </table>
</div> </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 <button
onClick={() => navigate('/monitoring/request-logs')} onClick={() => navigate('/monitoring/request-logs')}
className="text-sm text-blue-600 hover:text-blue-800 font-medium" className="text-sm text-blue-600 hover:text-blue-800 font-medium"
@ -424,7 +424,7 @@ const DashboardPage = () => {
</div> </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>
</div> </div>

파일 보기

@ -37,14 +37,14 @@ const LoginPage = () => {
return ( return (
<div className="w-full max-w-md"> <div className="w-full max-w-md">
<div className="rounded-xl bg-white px-8 py-10 shadow-lg"> <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"> <h1 className="mb-8 text-center text-2xl font-bold text-gray-900 dark:text-gray-100">
SNP Connection Monitoring SNP Connection Monitoring
</h1> </h1>
<form onSubmit={handleSubmit} className="space-y-5"> <form onSubmit={handleSubmit} className="space-y-5">
<div> <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> </label>
<input <input
@ -52,14 +52,14 @@ const LoginPage = () => {
type="text" type="text"
value={loginId} value={loginId}
onChange={(e) => setLoginId(e.target.value)} 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="아이디를 입력하세요" placeholder="아이디를 입력하세요"
autoComplete="username" autoComplete="username"
/> />
</div> </div>
<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> </label>
<input <input
@ -67,7 +67,7 @@ const LoginPage = () => {
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} 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="비밀번호를 입력하세요" placeholder="비밀번호를 입력하세요"
autoComplete="current-password" autoComplete="current-password"
/> />

파일 보기

@ -3,8 +3,8 @@ import { Link } from 'react-router-dom';
const NotFoundPage = () => { const NotFoundPage = () => {
return ( return (
<div className="flex min-h-[60vh] flex-col items-center justify-center text-center"> <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> <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"> .</p> <p className="mt-3 text-gray-600 dark:text-gray-400"> .</p>
<Link <Link
to="/dashboard" 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" 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) { 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 ( return (
<div> <div className="max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-6"> <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 <button
onClick={handleOpenCreateService} onClick={handleOpenCreateService}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium" 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="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"> <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 text-sm"> <table className="w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
<thead className="bg-gray-50"> <thead className="bg-gray-50 dark:bg-gray-700">
<tr> <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 dark:text-gray-400">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 dark:text-gray-400">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 dark:text-gray-400">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 dark:text-gray-400">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 dark:text-gray-400">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 dark:text-gray-400">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 dark:text-gray-400">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">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-200"> <tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{services.map((service) => { {services.map((service) => {
const badge = HEALTH_BADGE[service.healthStatus] || HEALTH_BADGE.UNKNOWN; const badge = HEALTH_BADGE[service.healthStatus] || HEALTH_BADGE.UNKNOWN;
const isSelected = selectedService?.serviceId === service.serviceId; const isSelected = selectedService?.serviceId === service.serviceId;
@ -250,12 +250,12 @@ const ServicesPage = () => {
key={service.serviceId} key={service.serviceId}
onClick={() => handleSelectService(service)} onClick={() => handleSelectService(service)}
className={`cursor-pointer ${ 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 font-mono text-gray-900 dark:text-gray-100">{service.serviceCode}</td>
<td className="px-4 py-3">{service.serviceName}</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 truncate max-w-[200px]"> <td className="px-4 py-3 text-gray-500 dark:text-gray-400 truncate max-w-[200px]">
{service.serviceUrl || '-'} {service.serviceUrl || '-'}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
@ -266,12 +266,12 @@ const ServicesPage = () => {
{service.healthStatus} {service.healthStatus}
</span> </span>
</td> </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 != null
? `${service.healthResponseTime}ms` ? `${service.healthResponseTime}ms`
: '-'} : '-'}
</td> </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)} {formatRelativeTime(service.healthCheckedAt)}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
@ -291,9 +291,9 @@ const ServicesPage = () => {
e.stopPropagation(); e.stopPropagation();
handleOpenEditService(service); 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> </button>
</td> </td>
</tr> </tr>
@ -301,7 +301,7 @@ const ServicesPage = () => {
})} })}
{services.length === 0 && ( {services.length === 0 && (
<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> </td>
</tr> </tr>
@ -311,9 +311,9 @@ const ServicesPage = () => {
</div> </div>
{selectedService && ( {selectedService && (
<div className="bg-white rounded-lg shadow"> <div className="bg-white dark:bg-gray-800 rounded-lg shadow">
<div className="flex items-center justify-between px-6 py-4 border-b"> <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"> <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
APIs for {selectedService.serviceName} APIs for {selectedService.serviceName}
</h2> </h2>
<button <button
@ -324,19 +324,19 @@ const ServicesPage = () => {
</button> </button>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full divide-y divide-gray-200 text-sm"> <table className="w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
<thead className="bg-gray-50"> <thead className="bg-gray-50 dark:bg-gray-700">
<tr> <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 dark:text-gray-400">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 dark:text-gray-400">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 dark:text-gray-400">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 dark:text-gray-400">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">Active</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-200"> <tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{serviceApis.map((api) => ( {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"> <td className="px-4 py-3">
<span <span
className={`inline-block px-2 py-0.5 rounded text-xs font-bold ${ className={`inline-block px-2 py-0.5 rounded text-xs font-bold ${
@ -346,9 +346,9 @@ const ServicesPage = () => {
{api.apiMethod} {api.apiMethod}
</span> </span>
</td> </td>
<td className="px-4 py-3 font-mono">{api.apiPath}</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">{api.apiName}</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">{api.description || '-'}</td> <td className="px-4 py-3 text-gray-500 dark:text-gray-400">{api.description || '-'}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<span <span
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${ className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
@ -364,7 +364,7 @@ const ServicesPage = () => {
))} ))}
{serviceApis.length === 0 && ( {serviceApis.length === 0 && (
<tr> <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가 . API가 .
</td> </td>
</tr> </tr>
@ -377,9 +377,9 @@ const ServicesPage = () => {
{isServiceModalOpen && ( {isServiceModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <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="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"> <div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900"> <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{editingService ? '서비스 수정' : '서비스 생성'} {editingService ? '서비스 수정' : '서비스 생성'}
</h2> </h2>
</div> </div>
@ -389,7 +389,7 @@ const ServicesPage = () => {
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div> <div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</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">
Service Code Service Code
</label> </label>
<input <input
@ -398,11 +398,11 @@ const ServicesPage = () => {
onChange={(e) => setServiceCode(e.target.value)} onChange={(e) => setServiceCode(e.target.value)}
disabled={!!editingService} disabled={!!editingService}
required 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>
<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 Service Name
</label> </label>
<input <input
@ -410,44 +410,44 @@ const ServicesPage = () => {
value={serviceName} value={serviceName}
onChange={(e) => setServiceName(e.target.value)} onChange={(e) => setServiceName(e.target.value)}
required 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>
<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 Service URL
</label> </label>
<input <input
type="text" type="text"
value={serviceUrl} value={serviceUrl}
onChange={(e) => setServiceUrl(e.target.value)} 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>
<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 Description
</label> </label>
<textarea <textarea
value={serviceDescription} value={serviceDescription}
onChange={(e) => setServiceDescription(e.target.value)} onChange={(e) => setServiceDescription(e.target.value)}
rows={3} 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> <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 Health Check URL
</label> </label>
<input <input
type="text" type="text"
value={healthCheckUrl} value={healthCheckUrl}
onChange={(e) => setHealthCheckUrl(e.target.value)} 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>
<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) Health Check Interval (seconds)
</label> </label>
<input <input
@ -455,7 +455,7 @@ const ServicesPage = () => {
value={healthCheckInterval} value={healthCheckInterval}
onChange={(e) => setHealthCheckInterval(Number(e.target.value))} onChange={(e) => setHealthCheckInterval(Number(e.target.value))}
min={10} 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> </div>
{editingService && ( {editingService && (
@ -467,17 +467,17 @@ const ServicesPage = () => {
onChange={(e) => setServiceIsActive(e.target.checked)} onChange={(e) => setServiceIsActive(e.target.checked)}
className="rounded" 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 Active
</label> </label>
</div> </div>
)} )}
</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 <button
type="button" type="button"
onClick={handleCloseServiceModal} 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 Cancel
</button> </button>
@ -495,9 +495,9 @@ const ServicesPage = () => {
{isApiModalOpen && ( {isApiModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <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="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"> <div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900">API </h2> <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">API </h2>
</div> </div>
<form onSubmit={handleApiSubmit}> <form onSubmit={handleApiSubmit}>
<div className="px-6 py-4 space-y-4"> <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 className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
)} )}
<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 <select
value={apiMethod} value={apiMethod}
onChange={(e) => setApiMethod(e.target.value)} 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="GET">GET</option>
<option value="POST">POST</option> <option value="POST">POST</option>
@ -518,42 +518,42 @@ const ServicesPage = () => {
</select> </select>
</div> </div>
<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 <input
type="text" type="text"
value={apiPath} value={apiPath}
onChange={(e) => setApiPath(e.target.value)} onChange={(e) => setApiPath(e.target.value)}
required 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>
<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 <input
type="text" type="text"
value={apiName} value={apiName}
onChange={(e) => setApiName(e.target.value)} onChange={(e) => setApiName(e.target.value)}
required 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>
<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 Description
</label> </label>
<textarea <textarea
value={apiDescription} value={apiDescription}
onChange={(e) => setApiDescription(e.target.value)} onChange={(e) => setApiDescription(e.target.value)}
rows={3} 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> </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 <button
type="button" type="button"
onClick={handleCloseApiModal} 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 Cancel
</button> </button>

파일 보기

@ -94,13 +94,13 @@ const TenantsPage = () => {
}; };
if (loading) { 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 ( return (
<div> <div className="max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-6"> <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 <button
onClick={handleOpenCreate} onClick={handleOpenCreate}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium" 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="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"> <div className="overflow-x-auto bg-white dark:bg-gray-800 rounded-lg shadow">
<table className="w-full divide-y divide-gray-200 text-sm"> <table className="w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
<thead className="bg-gray-50"> <thead className="bg-gray-50 dark:bg-gray-700">
<tr> <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 dark:text-gray-400">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 dark:text-gray-400">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 dark:text-gray-400">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">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 dark:text-gray-400">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">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-200"> <tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{tenants.map((tenant) => ( {tenants.map((tenant) => (
<tr key={tenant.tenantId} className="hover:bg-gray-50"> <tr key={tenant.tenantId} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-4 py-3 font-mono">{tenant.tenantCode}</td> <td className="px-4 py-3 font-mono text-gray-900 dark:text-gray-100">{tenant.tenantCode}</td>
<td className="px-4 py-3">{tenant.tenantName}</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">{tenant.description || '-'}</td> <td className="px-4 py-3 text-gray-500 dark:text-gray-400">{tenant.description || '-'}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<span <span
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${ className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
@ -142,22 +142,22 @@ const TenantsPage = () => {
{tenant.isActive ? 'Active' : 'Inactive'} {tenant.isActive ? 'Active' : 'Inactive'}
</span> </span>
</td> </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()} {new Date(tenant.createdAt).toLocaleDateString()}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<button <button
onClick={() => handleOpenEdit(tenant)} 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> </button>
</td> </td>
</tr> </tr>
))} ))}
{tenants.length === 0 && ( {tenants.length === 0 && (
<tr> <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> </td>
</tr> </tr>
@ -168,9 +168,9 @@ const TenantsPage = () => {
{isModalOpen && ( {isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <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="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"> <div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900"> <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{editingTenant ? '테넌트 수정' : '테넌트 생성'} {editingTenant ? '테넌트 수정' : '테넌트 생성'}
</h2> </h2>
</div> </div>
@ -180,7 +180,7 @@ const TenantsPage = () => {
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div> <div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</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">
Tenant Code Tenant Code
</label> </label>
<input <input
@ -189,11 +189,11 @@ const TenantsPage = () => {
onChange={(e) => setTenantCode(e.target.value)} onChange={(e) => setTenantCode(e.target.value)}
disabled={!!editingTenant} disabled={!!editingTenant}
required 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>
<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 Tenant Name
</label> </label>
<input <input
@ -201,18 +201,18 @@ const TenantsPage = () => {
value={tenantName} value={tenantName}
onChange={(e) => setTenantName(e.target.value)} onChange={(e) => setTenantName(e.target.value)}
required 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>
<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 Description
</label> </label>
<textarea <textarea
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
rows={3} 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>
{editingTenant && ( {editingTenant && (
@ -224,17 +224,17 @@ const TenantsPage = () => {
onChange={(e) => setIsActive(e.target.checked)} onChange={(e) => setIsActive(e.target.checked)}
className="rounded" 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 Active
</label> </label>
</div> </div>
)} )}
</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 <button
type="button" type="button"
onClick={handleCloseModal} 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 Cancel
</button> </button>

파일 보기

@ -135,13 +135,13 @@ const UsersPage = () => {
}; };
if (loading) { 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 ( return (
<div> <div className="max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-6"> <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 <button
onClick={handleOpenCreate} onClick={handleOpenCreate}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium" 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="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"> <div className="overflow-x-auto bg-white dark:bg-gray-800 rounded-lg shadow">
<table className="w-full divide-y divide-gray-200 text-sm"> <table className="w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
<thead className="bg-gray-50"> <thead className="bg-gray-50 dark:bg-gray-700">
<tr> <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 dark:text-gray-400">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 dark:text-gray-400">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 dark:text-gray-400">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 dark:text-gray-400">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 dark:text-gray-400">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 dark:text-gray-400">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 dark:text-gray-400">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">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-200"> <tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{users.map((user) => ( {users.map((user) => (
<tr key={user.userId} className="hover:bg-gray-50"> <tr key={user.userId} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-4 py-3 font-mono">{user.loginId}</td> <td className="px-4 py-3 font-mono text-gray-900 dark:text-gray-100">{user.loginId}</td>
<td className="px-4 py-3">{user.userName}</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">{user.email || '-'}</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">{user.tenantName || '-'}</td> <td className="px-4 py-3 text-gray-500 dark:text-gray-400">{user.tenantName || '-'}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<span <span
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${ className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
@ -195,7 +195,7 @@ const UsersPage = () => {
{user.isActive ? 'Active' : 'Inactive'} {user.isActive ? 'Active' : 'Inactive'}
</span> </span>
</td> </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 {user.lastLoginAt
? new Date(user.lastLoginAt).toLocaleString() ? new Date(user.lastLoginAt).toLocaleString()
: '-'} : '-'}
@ -203,14 +203,14 @@ const UsersPage = () => {
<td className="px-4 py-3 space-x-2"> <td className="px-4 py-3 space-x-2">
<button <button
onClick={() => handleOpenEdit(user)} 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> </button>
{user.isActive && ( {user.isActive && (
<button <button
onClick={() => handleDeactivate(user)} 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> </button>
@ -220,7 +220,7 @@ const UsersPage = () => {
))} ))}
{users.length === 0 && ( {users.length === 0 && (
<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> </td>
</tr> </tr>
@ -231,9 +231,9 @@ const UsersPage = () => {
{isModalOpen && ( {isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <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="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"> <div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900"> <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{editingUser ? '사용자 수정' : '사용자 생성'} {editingUser ? '사용자 수정' : '사용자 생성'}
</h2> </h2>
</div> </div>
@ -243,52 +243,52 @@ const UsersPage = () => {
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div> <div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
)} )}
<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 <input
type="text" type="text"
value={loginId} value={loginId}
onChange={(e) => setLoginId(e.target.value)} onChange={(e) => setLoginId(e.target.value)}
disabled={!!editingUser} disabled={!!editingUser}
required 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>
<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 <input
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required={!editingUser} required={!editingUser}
placeholder={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>
<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 <input
type="text" type="text"
value={userName} value={userName}
onChange={(e) => setUserName(e.target.value)} onChange={(e) => setUserName(e.target.value)}
required 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>
<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 <input
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} 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>
<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 <select
value={tenantId} value={tenantId}
onChange={(e) => setTenantId(e.target.value)} 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> <option value="">-- --</option>
{tenants.map((t) => ( {tenants.map((t) => (
@ -299,11 +299,11 @@ const UsersPage = () => {
</select> </select>
</div> </div>
<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 <select
value={role} value={role}
onChange={(e) => setRole(e.target.value)} 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="ADMIN">ADMIN</option>
<option value="MANAGER">MANAGER</option> <option value="MANAGER">MANAGER</option>
@ -320,17 +320,17 @@ const UsersPage = () => {
onChange={(e) => setIsActive(e.target.checked)} onChange={(e) => setIsActive(e.target.checked)}
className="rounded" 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 Active
</label> </label>
</div> </div>
)} )}
</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 <button
type="button" type="button"
onClick={handleCloseModal} 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 Cancel
</button> </button>

파일 보기

@ -7,8 +7,6 @@ import {
revokeKey, revokeKey,
getAllRequests, getAllRequests,
reviewRequest, reviewRequest,
getPermissions,
updatePermissions,
} from '../../services/apiKeyService'; } from '../../services/apiKeyService';
import { getServices, getServiceApis } from '../../services/serviceService'; import { getServices, getServiceApis } from '../../services/serviceService';
@ -62,10 +60,6 @@ const KeyAdminPage = () => {
const [keysLoading, setKeysLoading] = useState(true); const [keysLoading, setKeysLoading] = useState(true);
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false); const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
const [selectedKeyDetail, setSelectedKeyDetail] = useState<ApiKeyDetail | null>(null); 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); const [detailCopied, setDetailCopied] = useState(false);
// Raw key modal (after approve) // Raw key modal (after approve)
@ -217,55 +211,6 @@ const KeyAdminPage = () => {
setTimeout(() => setDetailCopied(false), 2000); 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) => { const handleRevokeKey = async (key: ApiKey) => {
if (!window.confirm(`'${key.keyName}' 키를 폐기하시겠습니까? 이 작업은 되돌릴 수 없습니다.`)) return; if (!window.confirm(`'${key.keyName}' 키를 폐기하시겠습니까? 이 작업은 되돌릴 수 없습니다.`)) return;
@ -300,12 +245,12 @@ const KeyAdminPage = () => {
return ( return (
<div key={service.serviceId} className="mb-3"> <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"> <div className="space-y-1 pl-4">
{apis.map((api) => ( {apis.map((api) => (
<label <label
key={api.apiId} 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 <input
type="checkbox" type="checkbox"
@ -315,13 +260,13 @@ const KeyAdminPage = () => {
/> />
<span <span
className={`inline-block px-1.5 py-0.5 rounded text-xs font-bold ${ 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} {api.apiMethod}
</span> </span>
<span className="font-mono text-sm text-gray-700">{api.apiPath}</span> <span className="font-mono text-sm text-gray-700 dark:text-gray-300">{api.apiPath}</span>
<span className="text-sm text-gray-500">- {api.apiName}</span> <span className="text-sm text-gray-500 dark:text-gray-400">- {api.apiName}</span>
</label> </label>
))} ))}
</div> </div>
@ -331,21 +276,21 @@ const KeyAdminPage = () => {
}; };
return ( return (
<div> <div className="max-w-7xl mx-auto">
<h1 className="text-2xl font-bold text-gray-900 mb-6">API Key </h1> <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> <div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
)} )}
{/* Tabs */} {/* Tabs */}
<div className="flex border-b mb-6"> <div className="flex border-b border-gray-200 dark:border-gray-700 mb-6">
<button <button
onClick={() => setActiveTab('requests')} onClick={() => setActiveTab('requests')}
className={`px-4 py-2 text-sm font-medium -mb-px ${ className={`px-4 py-2 text-sm font-medium -mb-px ${
activeTab === 'requests' activeTab === 'requests'
? 'border-b-2 border-blue-600 text-blue-600' ? '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 ${ className={`px-4 py-2 text-sm font-medium -mb-px ${
activeTab === 'keys' activeTab === 'keys'
? 'border-b-2 border-blue-600 text-blue-600' ? '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' && ( {activeTab === 'requests' && (
<div> <div>
{requestsLoading ? ( {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"> <div className="overflow-x-auto bg-white dark:bg-gray-800 rounded-lg shadow">
<table className="w-full divide-y divide-gray-200 text-sm"> <table className="w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
<thead className="bg-gray-50"> <thead className="bg-gray-50 dark:bg-gray-700">
<tr> <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 dark:text-gray-400">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 dark:text-gray-400">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 dark:text-gray-400">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 dark:text-gray-400">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 dark:text-gray-400">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 dark:text-gray-400">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">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-200"> <tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{requests.map((req) => ( {requests.map((req) => (
<tr key={req.requestId} className="hover:bg-gray-50"> <tr key={req.requestId} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-4 py-3">{req.userName}</td> <td className="px-4 py-3 text-gray-900 dark:text-gray-100">{req.userName}</td>
<td className="px-4 py-3">{req.keyName}</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 max-w-[200px] truncate"> <td className="px-4 py-3 text-gray-500 dark:text-gray-400 max-w-[200px] truncate">
{req.purpose || '-'} {req.purpose || '-'}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
@ -398,13 +343,13 @@ const KeyAdminPage = () => {
{req.status} {req.status}
</span> </span>
</td> </td>
<td className="px-4 py-3 text-gray-500">{req.requestedApiIds.length}</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">{formatDateTime(req.createdAt)}</td> <td className="px-4 py-3 text-gray-500 dark:text-gray-400">{formatDateTime(req.createdAt)}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
{req.status === 'PENDING' && ( {req.status === 'PENDING' && (
<button <button
onClick={() => handleOpenReview(req)} 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> </button>
@ -414,7 +359,7 @@ const KeyAdminPage = () => {
))} ))}
{requests.length === 0 && ( {requests.length === 0 && (
<tr> <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> </td>
</tr> </tr>
@ -430,27 +375,28 @@ const KeyAdminPage = () => {
{activeTab === 'keys' && ( {activeTab === 'keys' && (
<div> <div>
{keysLoading ? ( {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"> <div className="overflow-x-auto bg-white dark:bg-gray-800 rounded-lg shadow">
<table className="w-full divide-y divide-gray-200 text-sm"> <table className="w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
<thead className="bg-gray-50"> <thead className="bg-gray-50 dark:bg-gray-700">
<tr> <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 dark:text-gray-400">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 dark:text-gray-400">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 dark:text-gray-400">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 dark:text-gray-400">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 dark:text-gray-400">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 dark:text-gray-400"></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">Created</th>
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-200"> <tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{allKeys.map((key) => ( {allKeys.map((key) => (
<tr key={key.apiKeyId} className="hover:bg-gray-50"> <tr key={key.apiKeyId} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-4 py-3">{key.keyName}</td> <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">{key.apiKeyPrefix}</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">{key.maskedKey}</td> <td className="px-4 py-3 text-gray-500 dark:text-gray-400">{key.maskedKey}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<span <span
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${ className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
@ -460,26 +406,23 @@ const KeyAdminPage = () => {
{key.status} {key.status}
</span> </span>
</td> </td>
<td className="px-4 py-3 text-gray-500">{formatDateTime(key.lastUsedAt)}</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">{formatDateTime(key.createdAt)}</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"> <td className="px-4 py-3">
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={() => handleViewDetail(key)} 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>
<button
onClick={() => handleOpenPermissions(key)}
className="text-purple-600 hover:text-purple-800 font-medium text-sm"
>
</button>
{key.status === 'ACTIVE' && ( {key.status === 'ACTIVE' && (
<button <button
onClick={() => handleRevokeKey(key)} 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> </button>
@ -490,7 +433,7 @@ const KeyAdminPage = () => {
))} ))}
{allKeys.length === 0 && ( {allKeys.length === 0 && (
<tr> <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가 . API Key가 .
</td> </td>
</tr> </tr>
@ -505,9 +448,9 @@ const KeyAdminPage = () => {
{/* Review Modal */} {/* Review Modal */}
{isReviewModalOpen && selectedRequest && ( {isReviewModalOpen && selectedRequest && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <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="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"> <div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900"> </h2> <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100"> </h2>
</div> </div>
<div className="px-6 py-4 space-y-4 overflow-y-auto flex-1"> <div className="px-6 py-4 space-y-4 overflow-y-auto flex-1">
{error && ( {error && (
@ -516,80 +459,80 @@ const KeyAdminPage = () => {
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-500"></label> <label className="block text-sm font-medium text-gray-500 dark:text-gray-400"></label>
<p className="text-gray-900">{selectedRequest.userName}</p> <p className="text-gray-900 dark:text-gray-100">{selectedRequest.userName}</p>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-500">Key Name</label> <label className="block text-sm font-medium text-gray-500 dark:text-gray-400">Key Name</label>
<p className="text-gray-900">{selectedRequest.keyName}</p> <p className="text-gray-900 dark:text-gray-100">{selectedRequest.keyName}</p>
</div> </div>
</div> </div>
{selectedRequest.purpose && ( {selectedRequest.purpose && (
<div> <div>
<label className="block text-sm font-medium text-gray-500"></label> <label className="block text-sm font-medium text-gray-500 dark:text-gray-400"></label>
<p className="text-gray-900">{selectedRequest.purpose}</p> <p className="text-gray-900 dark:text-gray-100">{selectedRequest.purpose}</p>
</div> </div>
)} )}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
{selectedRequest.serviceIp && ( {selectedRequest.serviceIp && (
<div> <div>
<label className="block text-sm font-medium text-gray-500"> IP</label> <label className="block text-sm font-medium text-gray-500 dark:text-gray-400"> IP</label>
<p className="font-mono text-gray-900">{selectedRequest.serviceIp}</p> <p className="font-mono text-gray-900 dark:text-gray-100">{selectedRequest.serviceIp}</p>
</div> </div>
)} )}
{selectedRequest.servicePurpose && ( {selectedRequest.servicePurpose && (
<div> <div>
<label className="block text-sm font-medium text-gray-500"> </label> <label className="block text-sm font-medium text-gray-500 dark:text-gray-400"> </label>
<p className="text-gray-900">{selectedRequest.servicePurpose}</p> <p className="text-gray-900 dark:text-gray-100">{selectedRequest.servicePurpose}</p>
</div> </div>
)} )}
{selectedRequest.dailyRequestEstimate != null && ( {selectedRequest.dailyRequestEstimate != null && (
<div> <div>
<label className="block text-sm font-medium text-gray-500"> </label> <label className="block text-sm font-medium text-gray-500 dark:text-gray-400"> </label>
<p className="text-gray-900">{Number(selectedRequest.dailyRequestEstimate).toLocaleString()}</p> <p className="text-gray-900 dark:text-gray-100">{Number(selectedRequest.dailyRequestEstimate).toLocaleString()}</p>
</div> </div>
)} )}
</div> </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"> <div className="flex items-center gap-2">
<input type="date" value={adjustedFromDate} <input type="date" value={adjustedFromDate}
onChange={(e) => setAdjustedFromDate(e.target.value)} 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> <span className="text-gray-500">~</span>
<input type="date" value={adjustedToDate} <input type="date" value={adjustedToDate}
onChange={(e) => setAdjustedToDate(e.target.value)} 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> </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} ) API ({adjustedApiIds.size} )
</label> </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)} {renderApiCheckboxes(adjustedApiIds, handleToggleReviewApi)}
</div> </div>
</div> </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 <textarea
value={reviewComment} value={reviewComment}
onChange={(e) => setReviewComment(e.target.value)} onChange={(e) => setReviewComment(e.target.value)}
rows={3} rows={3}
placeholder="검토 의견을 입력하세요" 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> </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 <button
type="button" type="button"
onClick={handleCloseReview} 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> </button>
@ -613,26 +556,26 @@ const KeyAdminPage = () => {
{/* Detail Modal */} {/* Detail Modal */}
{isDetailModalOpen && selectedKeyDetail && ( {isDetailModalOpen && selectedKeyDetail && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <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="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"> <div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900"> </h2> <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100"> </h2>
</div> </div>
<div className="px-6 py-4 space-y-3"> <div className="px-6 py-4 space-y-3">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-sm font-medium text-gray-500">Key Name</label> <label className="block text-sm font-medium text-gray-500 dark:text-gray-400">Key Name</label>
<p className="text-gray-900">{selectedKeyDetail.keyName}</p> <p className="text-gray-900 dark:text-gray-100">{selectedKeyDetail.keyName}</p>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-500">User</label> <label className="block text-sm font-medium text-gray-500 dark:text-gray-400">User</label>
<p className="text-gray-900">{selectedKeyDetail.userName}</p> <p className="text-gray-900 dark:text-gray-100">{selectedKeyDetail.userName}</p>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-500">Prefix</label> <label className="block text-sm font-medium text-gray-500 dark:text-gray-400">Prefix</label>
<p className="font-mono text-gray-900">{selectedKeyDetail.apiKeyPrefix}</p> <p className="font-mono text-gray-900 dark:text-gray-100">{selectedKeyDetail.apiKeyPrefix}</p>
</div> </div>
<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 <span
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${ className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
STATUS_BADGE[selectedKeyDetail.status] || 'bg-gray-100 text-gray-800' STATUS_BADGE[selectedKeyDetail.status] || 'bg-gray-100 text-gray-800'
@ -642,22 +585,22 @@ const KeyAdminPage = () => {
</span> </span>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-500">Expires At</label> <label className="block text-sm font-medium text-gray-500 dark:text-gray-400">Expires At</label>
<p className="text-gray-900">{formatDateTime(selectedKeyDetail.expiresAt)}</p> <p className="text-gray-900 dark:text-gray-100">{formatDateTime(selectedKeyDetail.expiresAt)}</p>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-500">Last Used</label> <label className="block text-sm font-medium text-gray-500 dark:text-gray-400">Last Used</label>
<p className="text-gray-900">{formatDateTime(selectedKeyDetail.lastUsedAt)}</p> <p className="text-gray-900 dark:text-gray-100">{formatDateTime(selectedKeyDetail.lastUsedAt)}</p>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-500">Created At</label> <label className="block text-sm font-medium text-gray-500 dark:text-gray-400">Created At</label>
<p className="text-gray-900">{formatDateTime(selectedKeyDetail.createdAt)}</p> <p className="text-gray-900 dark:text-gray-100">{formatDateTime(selectedKeyDetail.createdAt)}</p>
</div> </div>
</div> </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"> <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} {selectedKeyDetail.decryptedKey}
</code> </code>
<button <button
@ -669,13 +612,13 @@ const KeyAdminPage = () => {
</div> </div>
</div> </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 <button
onClick={() => { onClick={() => {
setIsDetailModalOpen(false); setIsDetailModalOpen(false);
setSelectedKeyDetail(null); 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> </button>
@ -684,68 +627,25 @@ const KeyAdminPage = () => {
</div> </div>
)} )}
{/* Permission Modal */}
{isPermissionModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl mx-4 max-h-[90vh] flex flex-col">
<div className="px-6 py-4 border-b">
<h2 className="text-lg font-semibold text-gray-900">
- {permissionKeyName}
</h2>
</div>
<div className="px-6 py-4 overflow-y-auto flex-1">
{error && (
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm mb-4">{error}</div>
)}
<p className="text-sm text-gray-500 mb-3">
{permissionApiIds.size} API
</p>
<div className="border rounded-lg p-3">
{renderApiCheckboxes(permissionApiIds, handleTogglePermissionApi)}
</div>
</div>
<div className="px-6 py-4 border-t flex justify-end gap-2">
<button
type="button"
onClick={() => {
setIsPermissionModalOpen(false);
setPermissionKeyId(null);
setError(null);
}}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium"
>
</button>
<button
onClick={handleSavePermissions}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
>
</button>
</div>
</div>
</div>
)}
{/* Raw Key Modal (after approve) */} {/* Raw Key Modal (after approve) */}
{rawKeyModal && ( {rawKeyModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <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="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"> <div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900">API Key </h2> <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">API Key </h2>
</div> </div>
<div className="px-6 py-4 space-y-4"> <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 className="p-3 bg-yellow-50 border border-yellow-200 text-yellow-800 rounded-lg text-sm">
. . . .
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Key Name</label> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Key Name</label>
<p className="text-gray-900">{rawKeyModal.keyName}</p> <p className="text-gray-900 dark:text-gray-100">{rawKeyModal.keyName}</p>
</div> </div>
<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"> <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} {rawKeyModal.rawKey}
</code> </code>
<button <button
@ -757,13 +657,13 @@ const KeyAdminPage = () => {
</div> </div>
</div> </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 <button
onClick={() => { onClick={() => {
setRawKeyModal(null); setRawKeyModal(null);
setRawKeyCopied(false); 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> </button>

파일 보기

@ -24,6 +24,7 @@ const KeyRequestPage = () => {
const [servicePurpose, setServicePurpose] = useState(''); const [servicePurpose, setServicePurpose] = useState('');
const [dailyRequestEstimate, setDailyRequestEstimate] = useState(''); const [dailyRequestEstimate, setDailyRequestEstimate] = useState('');
const [usagePeriodMode, setUsagePeriodMode] = useState<'preset' | 'custom'>('preset'); const [usagePeriodMode, setUsagePeriodMode] = useState<'preset' | 'custom'>('preset');
const [isPermanent, setIsPermanent] = useState(false);
const [usageFromDate, setUsageFromDate] = useState(''); const [usageFromDate, setUsageFromDate] = useState('');
const [usageToDate, setUsageToDate] = useState(''); const [usageToDate, setUsageToDate] = useState('');
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@ -111,6 +112,13 @@ const KeyRequestPage = () => {
setUsageFromDate(from.toISOString().split('T')[0]); setUsageFromDate(from.toISOString().split('T')[0]);
setUsageToDate(to.toISOString().split('T')[0]); setUsageToDate(to.toISOString().split('T')[0]);
setUsagePeriodMode('preset'); setUsagePeriodMode('preset');
setIsPermanent(false);
};
const handlePermanent = () => {
setIsPermanent(true);
setUsageFromDate('');
setUsageToDate('');
}; };
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
@ -119,7 +127,7 @@ const KeyRequestPage = () => {
setError('최소 하나의 API를 선택해주세요.'); setError('최소 하나의 API를 선택해주세요.');
return; return;
} }
if (!usageFromDate || !usageToDate) { if (!isPermanent && (!usageFromDate || !usageToDate)) {
setError('사용 기간을 설정해주세요.'); setError('사용 기간을 설정해주세요.');
return; return;
} }
@ -135,8 +143,8 @@ const KeyRequestPage = () => {
serviceIp: serviceIp || undefined, serviceIp: serviceIp || undefined,
servicePurpose: servicePurpose || undefined, servicePurpose: servicePurpose || undefined,
dailyRequestEstimate: dailyRequestEstimate ? Number(dailyRequestEstimate) : undefined, dailyRequestEstimate: dailyRequestEstimate ? Number(dailyRequestEstimate) : undefined,
usageFromDate, usageFromDate: isPermanent ? undefined : usageFromDate,
usageToDate, usageToDate: isPermanent ? undefined : usageToDate,
}); });
if (res.success) { if (res.success) {
setSuccess(true); setSuccess(true);
@ -151,11 +159,12 @@ const KeyRequestPage = () => {
}; };
if (isLoading) { 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) { if (success) {
return ( return (
<div className="max-w-7xl mx-auto">
<div className="max-w-lg mx-auto mt-10 text-center"> <div className="max-w-lg mx-auto mt-10 text-center">
<div className="bg-green-50 border border-green-200 rounded-lg p-6"> <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> <h2 className="text-lg font-semibold text-green-800 mb-2"> </h2>
@ -170,21 +179,22 @@ const KeyRequestPage = () => {
</button> </button>
</div> </div>
</div> </div>
</div>
); );
} }
return ( return (
<div> <div className="max-w-7xl mx-auto">
<h1 className="text-2xl font-bold text-gray-900 mb-6">API Key </h1> <h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-6">API Key </h1>
{error && ( {error && (
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div> <div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
)} )}
<form onSubmit={handleSubmit}> <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> <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> Key Name <span className="text-red-500">*</span>
</label> </label>
<input <input
@ -193,83 +203,97 @@ const KeyRequestPage = () => {
onChange={(e) => setKeyName(e.target.value)} onChange={(e) => setKeyName(e.target.value)}
required required
placeholder="API Key 이름을 입력하세요" 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>
<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 <textarea
value={purpose} value={purpose}
onChange={(e) => setPurpose(e.target.value)} onChange={(e) => setPurpose(e.target.value)}
rows={2} rows={2}
placeholder="사용 목적을 입력하세요" 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> <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> <span className="text-red-500">*</span>
</label> </label>
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<button type="button" onClick={() => handlePresetPeriod(3)} <button type="button" onClick={() => handlePresetPeriod(3)}
disabled={usagePeriodMode === 'custom'} disabled={isPermanent || usagePeriodMode === 'custom'}
className={`px-3 py-1.5 text-sm rounded-lg border ${usagePeriodMode === 'custom' ? 'opacity-40 cursor-not-allowed' : 'hover:bg-blue-50'}`}> 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 3
</button> </button>
<button type="button" onClick={() => handlePresetPeriod(6)} <button type="button" onClick={() => handlePresetPeriod(6)}
disabled={usagePeriodMode === 'custom'} disabled={isPermanent || usagePeriodMode === 'custom'}
className={`px-3 py-1.5 text-sm rounded-lg border ${usagePeriodMode === 'custom' ? 'opacity-40 cursor-not-allowed' : 'hover:bg-blue-50'}`}> 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 6
</button> </button>
<button type="button" onClick={() => handlePresetPeriod(9)} <button type="button" onClick={() => handlePresetPeriod(9)}
disabled={usagePeriodMode === 'custom'} disabled={isPermanent || usagePeriodMode === 'custom'}
className={`px-3 py-1.5 text-sm rounded-lg border ${usagePeriodMode === 'custom' ? 'opacity-40 cursor-not-allowed' : 'hover:bg-blue-50'}`}> 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 9
</button> </button>
<span className="text-gray-400 mx-1">|</span> <span className="text-gray-400 dark:text-gray-600 mx-1">|</span>
<label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer select-none"> <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" <button type="button"
onClick={() => setUsagePeriodMode(usagePeriodMode === 'custom' ? 'preset' : 'custom')} onClick={() => {
className={`relative w-10 h-5 rounded-full transition-colors ${usagePeriodMode === 'custom' ? 'bg-blue-600' : 'bg-gray-300'}`}> setUsagePeriodMode(usagePeriodMode === 'custom' ? 'preset' : 'custom');
<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' : ''}`} /> 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> </button>
</label> </label>
</div> </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"> <div className="flex items-center gap-2">
<input type="date" value={usageFromDate} <input type="date" value={usageFromDate}
onChange={(e) => setUsageFromDate(e.target.value)} onChange={(e) => setUsageFromDate(e.target.value)}
readOnly={usagePeriodMode !== 'custom'} 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'}`} />
<span className="text-gray-500">~</span> <span className="text-gray-500 dark:text-gray-400">~</span>
<input type="date" value={usageToDate} <input type="date" value={usageToDate}
onChange={(e) => setUsageToDate(e.target.value)} onChange={(e) => setUsageToDate(e.target.value)}
readOnly={usagePeriodMode !== 'custom'} 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> </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> IP <span className="text-red-500">*</span>
</label> </label>
<input type="text" value={serviceIp} <input type="text" value={serviceIp}
onChange={(e) => setServiceIp(e.target.value)} onChange={(e) => setServiceIp(e.target.value)}
required placeholder="예: 192.168.1.100" 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" /> 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 mt-1"> API Key로 IP</p> <p className="text-xs text-gray-400 dark:text-gray-500 mt-1"> API Key로 IP</p>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<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> <span className="text-red-500">*</span>
</label> </label>
<select value={servicePurpose} <select value={servicePurpose}
onChange={(e) => setServicePurpose(e.target.value)} onChange={(e) => setServicePurpose(e.target.value)}
required 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> <option value="로컬 환경"> </option>
<option value="개발 서버"> </option> <option value="개발 서버"> </option>
@ -278,13 +302,13 @@ const KeyRequestPage = () => {
</select> </select>
</div> </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">
<span className="text-red-500">*</span> <span className="text-red-500">*</span>
</label> </label>
<select value={dailyRequestEstimate} <select value={dailyRequestEstimate}
onChange={(e) => setDailyRequestEstimate(e.target.value)} onChange={(e) => setDailyRequestEstimate(e.target.value)}
required 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="100">100 </option> <option value="100">100 </option>
<option value="500">100~500</option> <option value="500">100~500</option>
@ -297,13 +321,13 @@ const KeyRequestPage = () => {
</div> </div>
</div> </div>
<div className="bg-white rounded-lg shadow mb-6"> <div className="bg-white dark:bg-gray-800 rounded-lg shadow mb-6">
<div className="px-6 py-4 border-b"> <div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900"> <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
API <span className="text-sm font-normal text-gray-500">({selectedApiIds.size} )</span> API <span className="text-sm font-normal text-gray-500 dark:text-gray-400 dark:text-gray-400">({selectedApiIds.size} )</span>
</h2> </h2>
</div> </div>
<div className="divide-y divide-gray-200"> <div className="divide-y divide-gray-200 dark:divide-gray-700">
{services.map((service) => { {services.map((service) => {
const apis = serviceApisMap[service.serviceId] || []; const apis = serviceApisMap[service.serviceId] || [];
const isExpanded = expandedServices.has(service.serviceId); const isExpanded = expandedServices.has(service.serviceId);
@ -313,25 +337,25 @@ const KeyRequestPage = () => {
return ( return (
<div key={service.serviceId}> <div key={service.serviceId}>
<div <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)} onClick={() => handleToggleService(service.serviceId)}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-gray-400 text-sm">{isExpanded ? '\u25BC' : '\u25B6'}</span> <span className="text-gray-400 dark:text-gray-500 text-sm">{isExpanded ? '\u25BC' : '\u25B6'}</span>
<span className="font-medium text-gray-900">{service.serviceName}</span> <span className="font-medium text-gray-900 dark:text-gray-100">{service.serviceName}</span>
{selectedCount > 0 && ( {selectedCount > 0 && (
<span className="text-xs bg-blue-100 text-blue-800 px-2 py-0.5 rounded-full"> <span className="text-xs bg-blue-100 text-blue-800 px-2 py-0.5 rounded-full">
{selectedCount}/{apis.length} {selectedCount}/{apis.length}
</span> </span>
)} )}
</div> </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> </div>
{isExpanded && ( {isExpanded && (
<div className="px-6 pb-3"> <div className="px-6 pb-3">
{apis.length > 0 && ( {apis.length > 0 && (
<div className="mb-2 pl-6"> <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 <input
type="checkbox" type="checkbox"
checked={allSelected} checked={allSelected}
@ -346,7 +370,7 @@ const KeyRequestPage = () => {
{apis.map((api) => ( {apis.map((api) => (
<label <label
key={api.apiId} 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 <input
type="checkbox" type="checkbox"
@ -356,17 +380,17 @@ const KeyRequestPage = () => {
/> />
<span <span
className={`inline-block px-2 py-0.5 rounded text-xs font-bold ${ 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} {api.apiMethod}
</span> </span>
<span className="font-mono text-sm text-gray-700">{api.apiPath}</span> <span className="font-mono text-sm text-gray-700 dark:text-gray-300">{api.apiPath}</span>
<span className="text-sm text-gray-500">- {api.apiName}</span> <span className="text-sm text-gray-500 dark:text-gray-400">- {api.apiName}</span>
</label> </label>
))} ))}
{apis.length === 0 && ( {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>
</div> </div>
@ -375,7 +399,7 @@ const KeyRequestPage = () => {
); );
})} })}
{services.length === 0 && ( {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> </div>
)} )}

파일 보기

@ -1,8 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import type { ApiKey } from '../../types/apikey'; import type { ApiKey } from '../../types/apikey';
import { getMyKeys, createKey, revokeKey } from '../../services/apiKeyService'; import { getMyKeys, revokeKey } from '../../services/apiKeyService';
import { useAuth } from '../../hooks/useAuth';
const STATUS_BADGE: Record<string, string> = { const STATUS_BADGE: Record<string, string> = {
ACTIVE: 'bg-green-100 text-green-800', ACTIVE: 'bg-green-100 text-green-800',
@ -18,16 +17,10 @@ const formatDateTime = (dateStr: string | null): string => {
}; };
const MyKeysPage = () => { const MyKeysPage = () => {
const { user } = useAuth();
const isAdmin = user?.role === 'ADMIN';
const [keys, setKeys] = useState<ApiKey[]>([]); const [keys, setKeys] = useState<ApiKey[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); 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 [rawKeyModal, setRawKeyModal] = useState<{ keyName: string; rawKey: string } | null>(null);
const [copied, setCopied] = useState(false); 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 () => { const handleCopyRawKey = async () => {
if (!rawKeyModal) return; if (!rawKeyModal) return;
await navigator.clipboard.writeText(rawKeyModal.rawKey); await navigator.clipboard.writeText(rawKeyModal.rawKey);
@ -93,59 +67,45 @@ const MyKeysPage = () => {
}; };
if (loading) { 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 ( return (
<div> <div className="max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-6"> <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"> <div className="flex gap-2">
{!isAdmin && (
<Link <Link
to="/apikeys/request" to="/apikeys/request"
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium" className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
> >
API Key API Key
</Link> </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>
</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="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"> <div className="overflow-x-auto bg-white dark:bg-gray-800 rounded-lg shadow">
<table className="w-full divide-y divide-gray-200 text-sm"> <table className="w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
<thead className="bg-gray-50"> <thead className="bg-gray-50 dark:bg-gray-700">
<tr> <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 dark:text-gray-400">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 dark:text-gray-400">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 dark:text-gray-400">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 dark:text-gray-400">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 dark:text-gray-400">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 dark:text-gray-400">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">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-200"> <tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{keys.map((key) => ( {keys.map((key) => (
<tr key={key.apiKeyId} className="hover:bg-gray-50"> <tr key={key.apiKeyId} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-4 py-3">{key.keyName}</td> <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">{key.apiKeyPrefix}</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"> <td className="px-4 py-3">
<span <span
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${ className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
@ -155,14 +115,14 @@ const MyKeysPage = () => {
{key.status} {key.status}
</span> </span>
</td> </td>
<td className="px-4 py-3 text-gray-500">{formatDateTime(key.expiresAt)}</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">{formatDateTime(key.lastUsedAt)}</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">{formatDateTime(key.createdAt)}</td> <td className="px-4 py-3 text-gray-500 dark:text-gray-400">{formatDateTime(key.createdAt)}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
{key.status === 'ACTIVE' && ( {key.status === 'ACTIVE' && (
<button <button
onClick={() => handleRevoke(key)} 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> </button>
@ -172,7 +132,7 @@ const MyKeysPage = () => {
))} ))}
{keys.length === 0 && ( {keys.length === 0 && (
<tr> <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가 . API Key가 .
</td> </td>
</tr> </tr>
@ -181,66 +141,24 @@ const MyKeysPage = () => {
</table> </table>
</div> </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 && ( {rawKeyModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <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="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"> <div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900">API Key </h2> <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">API Key </h2>
</div> </div>
<div className="px-6 py-4 space-y-4"> <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 className="p-3 bg-yellow-50 border border-yellow-200 text-yellow-800 rounded-lg text-sm">
. . . .
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Key Name</label> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Key Name</label>
<p className="text-gray-900">{rawKeyModal.keyName}</p> <p className="text-gray-900 dark:text-gray-100">{rawKeyModal.keyName}</p>
</div> </div>
<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"> <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} {rawKeyModal.rawKey}
</code> </code>
<button <button
@ -252,13 +170,13 @@ const MyKeysPage = () => {
</div> </div>
</div> </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 <button
onClick={() => { onClick={() => {
setRawKeyModal(null); setRawKeyModal(null);
setCopied(false); 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> </button>

파일 보기

@ -70,12 +70,12 @@ const RequestLogDetailPage = () => {
}, [id]); }, [id]);
if (loading) { 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) { if (error) {
return ( return (
<div> <div className="max-w-7xl mx-auto">
<button <button
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
className="text-blue-600 hover:text-blue-800 font-medium mb-4" className="text-blue-600 hover:text-blue-800 font-medium mb-4"
@ -93,7 +93,7 @@ const RequestLogDetailPage = () => {
const formattedParams = formatJson(log.requestParams); const formattedParams = formatJson(log.requestParams);
return ( return (
<div> <div className="max-w-7xl mx-auto">
<button <button
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
className="text-blue-600 hover:text-blue-800 font-medium mb-6" className="text-blue-600 hover:text-blue-800 font-medium mb-6"
@ -102,16 +102,16 @@ const RequestLogDetailPage = () => {
</button> </button>
{/* 기본 정보 */} {/* 기본 정보 */}
<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">
<h2 className="text-lg font-semibold text-gray-900 mb-4"> </h2> <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 className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-4">
<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="text-sm text-gray-900">{formatDateTime(log.requestedAt)}</span> <span className="text-sm text-gray-900 dark:text-gray-100">{formatDateTime(log.requestedAt)}</span>
</div> </div>
<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="text-sm text-gray-900"> <span className="text-sm text-gray-900 dark:text-gray-100">
<span <span
className={`inline-block px-2 py-0.5 rounded text-xs font-bold mr-2 ${ 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' METHOD_COLOR[log.requestMethod] || 'bg-gray-100 text-gray-800'
@ -123,25 +123,25 @@ const RequestLogDetailPage = () => {
</span> </span>
</div> </div>
<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="text-sm text-gray-900"> <span className="text-sm text-gray-900 dark:text-gray-100">
{log.responseStatus != null ? log.responseStatus : '-'} {log.responseStatus != null ? log.responseStatus : '-'}
</span> </span>
</div> </div>
<div> <div>
<span className="block text-sm font-medium text-gray-500">(ms)</span> <span className="block text-sm font-medium text-gray-500 dark:text-gray-400">(ms)</span>
<span className="text-sm text-gray-900"> <span className="text-sm text-gray-900 dark:text-gray-100">
{log.responseTime != null ? log.responseTime : '-'} {log.responseTime != null ? log.responseTime : '-'}
</span> </span>
</div> </div>
<div> <div>
<span className="block text-sm font-medium text-gray-500">(bytes)</span> <span className="block text-sm font-medium text-gray-500 dark:text-gray-400">(bytes)</span>
<span className="text-sm text-gray-900"> <span className="text-sm text-gray-900 dark:text-gray-100">
{log.responseSize != null ? log.responseSize : '-'} {log.responseSize != null ? log.responseSize : '-'}
</span> </span>
</div> </div>
<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 <span
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${ className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
STATUS_BADGE[log.requestStatus] || 'bg-gray-100 text-gray-800' STATUS_BADGE[log.requestStatus] || 'bg-gray-100 text-gray-800'
@ -153,42 +153,42 @@ const RequestLogDetailPage = () => {
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<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="text-sm text-gray-900">{log.serviceName || '-'}</span> <span className="text-sm text-gray-900 dark:text-gray-100">{log.serviceName || '-'}</span>
</div> </div>
<div> <div>
<span className="block text-sm font-medium text-gray-500">API Key</span> <span className="block text-sm font-medium text-gray-500 dark:text-gray-400">API Key</span>
<span className="text-sm text-gray-900 font-mono"> <span className="text-sm text-gray-900 dark:text-gray-100 font-mono">
{log.apiKeyPrefix || '-'} {log.apiKeyPrefix || '-'}
</span> </span>
</div> </div>
<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="text-sm text-gray-900">{log.userName || '-'}</span> <span className="text-sm text-gray-900 dark:text-gray-100">{log.userName || '-'}</span>
</div> </div>
<div> <div>
<span className="block text-sm font-medium text-gray-500">IP</span> <span className="block text-sm font-medium text-gray-500 dark:text-gray-400">IP</span>
<span className="text-sm text-gray-900 font-mono">{log.requestIp}</span> <span className="text-sm text-gray-900 dark:text-gray-100 font-mono">{log.requestIp}</span>
</div> </div>
</div> </div>
</div> </div>
{/* 요청 정보 */} {/* 요청 정보 */}
{(formattedHeaders || formattedParams) && ( {(formattedHeaders || formattedParams) && (
<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">
<h2 className="text-lg font-semibold text-gray-900 mb-4"> </h2> <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4"> </h2>
{formattedHeaders && ( {formattedHeaders && (
<div className="mb-4"> <div className="mb-4">
<span className="block text-sm font-medium text-gray-500 mb-1">Request Headers</span> <span className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Request Headers</span>
<pre className="bg-gray-50 rounded-lg p-4 text-sm text-gray-800 overflow-x-auto"> <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} {formattedHeaders}
</pre> </pre>
</div> </div>
)} )}
{formattedParams && ( {formattedParams && (
<div> <div>
<span className="block text-sm font-medium text-gray-500 mb-1">Request Params</span> <span className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Request Params</span>
<pre className="bg-gray-50 rounded-lg p-4 text-sm text-gray-800 overflow-x-auto"> <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} {formattedParams}
</pre> </pre>
</div> </div>

파일 보기

@ -163,36 +163,36 @@ const RequestLogsPage = () => {
}; };
return ( return (
<div> <div className="max-w-7xl mx-auto">
<h1 className="text-2xl font-bold text-gray-900 mb-6">Request Logs</h1> <h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-6">Request Logs</h1>
{/* Search Form */} {/* 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 className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<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"> <div className="flex items-center gap-2">
<input <input
type="date" type="date"
value={startDate} value={startDate}
onChange={(e) => setStartDate(e.target.value)} 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 <input
type="date" type="date"
value={endDate} value={endDate}
onChange={(e) => setEndDate(e.target.value)} 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> </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 <select
value={serviceId} value={serviceId}
onChange={(e) => setServiceId(e.target.value)} 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> <option value=""></option>
{services.map((s) => ( {services.map((s) => (
@ -203,11 +203,11 @@ const RequestLogsPage = () => {
</select> </select>
</div> </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 <select
value={requestStatus} value={requestStatus}
onChange={(e) => setRequestStatus(e.target.value)} 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> <option value=""></option>
{REQUEST_STATUSES.map((s) => ( {REQUEST_STATUSES.map((s) => (
@ -218,11 +218,11 @@ const RequestLogsPage = () => {
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div> <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 <select
value={requestMethod} value={requestMethod}
onChange={(e) => setRequestMethod(e.target.value)} 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> <option value=""></option>
{HTTP_METHODS.map((m) => ( {HTTP_METHODS.map((m) => (
@ -231,13 +231,13 @@ const RequestLogsPage = () => {
</select> </select>
</div> </div>
<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 <input
type="text" type="text"
value={requestIp} value={requestIp}
onChange={(e) => setRequestIp(e.target.value)} onChange={(e) => setRequestIp(e.target.value)}
placeholder="IP 주소" 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>
<div className="flex items-end gap-2"> <div className="flex items-end gap-2">
@ -249,7 +249,7 @@ const RequestLogsPage = () => {
</button> </button>
<button <button
onClick={handleResetAndSearch} 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> </button>
@ -262,35 +262,35 @@ const RequestLogsPage = () => {
)} )}
{/* Results Table */} {/* 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 ? ( {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"> <table className="w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
<thead className="bg-gray-50"> <thead className="bg-gray-50 dark:bg-gray-700">
<tr> <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 dark:text-gray-400"></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">Method</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">URL</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">Status Code</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">(ms)</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"></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">IP</th> <th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">IP</th>
</tr> </tr>
</thead> </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 && result.content.length > 0 ? (
result.content.map((log) => ( result.content.map((log) => (
<tr <tr
key={log.logId} key={log.logId}
onClick={() => handleRowClick(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)} {formatDateTime(log.requestedAt)}
</td> </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"> <td className="px-4 py-3">
<span <span
className={`inline-block px-2 py-0.5 rounded text-xs font-bold ${ className={`inline-block px-2 py-0.5 rounded text-xs font-bold ${
@ -300,13 +300,13 @@ const RequestLogsPage = () => {
{log.requestMethod} {log.requestMethod}
</span> </span>
</td> </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} {log.requestUrl}
</td> </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 : '-'} {log.responseStatus != null ? log.responseStatus : '-'}
</td> </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 : '-'} {log.responseTime != null ? log.responseTime : '-'}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
@ -318,12 +318,12 @@ const RequestLogsPage = () => {
{log.requestStatus} {log.requestStatus}
</span> </span>
</td> </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>
)) ))
) : ( ) : (
<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> </td>
</tr> </tr>
@ -336,21 +336,21 @@ const RequestLogsPage = () => {
{/* Pagination */} {/* Pagination */}
{result && result.totalElements > 0 && ( {result && result.totalElements > 0 && (
<div className="flex items-center justify-between"> <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} {result.totalElements} / {result.page + 1} / {result.totalPages}
</span> </span>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={handlePrev} onClick={handlePrev}
disabled={currentPage === 0} 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>
<button <button
onClick={handleNext} onClick={handleNext}
disabled={!result || currentPage >= result.totalPages - 1} 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> </button>

파일 보기

@ -53,15 +53,15 @@ const ServiceStatusDetailPage = () => {
}, [fetchData]); }, [fetchData]);
if (isLoading) { 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) { 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 ( return (
<div className="max-w-4xl mx-auto"> <div className="max-w-7xl mx-auto">
<button <button
onClick={() => navigate('/monitoring/service-status')} onClick={() => navigate('/monitoring/service-status')}
className="text-sm text-blue-600 hover:text-blue-800 mb-4 inline-block" className="text-sm text-blue-600 hover:text-blue-800 mb-4 inline-block"
@ -70,28 +70,28 @@ const ServiceStatusDetailPage = () => {
</button> </button>
{/* Header */} {/* 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 justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className={`w-4 h-4 rounded-full ${STATUS_COLOR[detail.currentStatus] || 'bg-gray-400'}`} /> <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> <h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">{detail.serviceName}</h1>
<span className="text-gray-500">{detail.serviceCode}</span> <span className="text-gray-500 dark:text-gray-400">{detail.serviceCode}</span>
</div> </div>
<div className="text-right"> <div className="text-right">
<div className={`text-lg font-semibold ${detail.currentStatus === 'UP' ? 'text-green-600' : 'text-red-600'}`}> <div className={`text-lg font-semibold ${detail.currentStatus === 'UP' ? 'text-green-600' : 'text-red-600'}`}>
{detail.currentStatus === 'UP' ? 'Operational' : detail.currentStatus === 'DOWN' ? 'Down' : 'Unknown'} {detail.currentStatus === 'UP' ? 'Operational' : detail.currentStatus === 'DOWN' ? 'Down' : 'Unknown'}
</div> </div>
{detail.lastResponseTime !== null && ( {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> </div>
</div> </div>
{/* Uptime Summary */} {/* Uptime Summary */}
<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">
<h2 className="text-lg font-semibold text-gray-900 mb-4">90 Uptime</h2> <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 mb-4">{detail.uptimePercent90d.toFixed(3)}%</div> <div className="text-4xl font-bold text-gray-900 dark:text-gray-100 mb-4">{detail.uptimePercent90d.toFixed(3)}%</div>
{/* 90-Day Bar */} {/* 90-Day Bar */}
<div className="flex items-center gap-0.5 mb-2"> <div className="flex items-center gap-0.5 mb-2">
@ -110,16 +110,16 @@ const ServiceStatusDetailPage = () => {
</div> </div>
))} ))}
{detail.dailyUptime.length === 0 && ( {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>
<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>{detail.dailyUptime.length > 0 ? formatDate(detail.dailyUptime[0].date) : ''}</span>
<span>Today</span> <span>Today</span>
</div> </div>
{/* Daily Uptime Legend */} {/* 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-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-green-400" /> 99%+</div>
<div className="flex items-center gap-1"><div className="w-3 h-3 rounded-sm bg-yellow-400" /> 95%+</div> <div className="flex items-center gap-1"><div className="w-3 h-3 rounded-sm bg-yellow-400" /> 95%+</div>
@ -129,31 +129,31 @@ const ServiceStatusDetailPage = () => {
</div> </div>
{/* Recent Checks */} {/* Recent Checks */}
<div className="bg-white rounded-lg shadow mb-6"> <div className="bg-white dark:bg-gray-800 rounded-lg shadow mb-6">
<div className="p-6 border-b"> <div className="p-6 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900"> </h2> <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100"> </h2>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-gray-50"> <thead className="bg-gray-50 dark:bg-gray-700">
<tr> <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 dark:text-gray-400"></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"></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"></th> <th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400"></th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-200"> <tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{detail.recentChecks.map((check, idx) => ( {detail.recentChecks.map((check, idx) => (
<tr key={idx} className="hover:bg-gray-50"> <tr key={idx} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-4 py-3 text-gray-600 whitespace-nowrap">{formatTime(check.checkedAt)}</td> <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"> <td className="px-4 py-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className={`w-2.5 h-2.5 rounded-full ${STATUS_COLOR[check.status] || 'bg-gray-400'}`} /> <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> <span className={check.status === 'UP' ? 'text-green-700' : 'text-red-700'}>{check.status}</span>
</div> </div>
</td> </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` : '-'} {check.responseTime !== null ? `${check.responseTime}ms` : '-'}
</td> </td>
<td className="px-4 py-3 text-red-600 text-xs max-w-xs truncate"> <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 && ( {detail.recentChecks.length === 0 && (
<tr> <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> </td>
</tr> </tr>

파일 보기

@ -57,14 +57,14 @@ const ServiceStatusPage = () => {
const allOperational = services.length > 0 && services.every((s) => s.currentStatus === 'UP'); const allOperational = services.length > 0 && services.every((s) => s.currentStatus === 'UP');
if (isLoading) { 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 ( return (
<div className="max-w-4xl mx-auto"> <div className="max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-8"> <div className="flex items-center justify-between mb-8">
<h1 className="text-2xl font-bold text-gray-900">Service Status</h1> <h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Service Status</h1>
<span className="text-sm text-gray-500"> : {lastUpdated}</span> <span className="text-sm text-gray-500 dark:text-gray-400"> : {lastUpdated}</span>
</div> </div>
{/* Overall Status Banner */} {/* Overall Status Banner */}
@ -80,19 +80,19 @@ const ServiceStatusPage = () => {
{/* Service List */} {/* Service List */}
<div className="space-y-6"> <div className="space-y-6">
{services.map((svc) => ( {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 */} {/* 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 justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className={`w-3 h-3 rounded-full ${STATUS_COLOR[svc.currentStatus] || 'bg-gray-400'}`} /> <div className={`w-3 h-3 rounded-full ${STATUS_COLOR[svc.currentStatus] || 'bg-gray-400'}`} />
<h2 <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}`)} onClick={() => navigate(`/monitoring/service-status/${svc.serviceId}`)}
> >
{svc.serviceName} {svc.serviceName}
</h2> </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>
<div className="flex items-center gap-4"> <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'}`}> <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> </div>
<div className="mt-1 text-sm text-gray-500"> <div className="mt-1 text-sm text-gray-500 dark:text-gray-400">
90 Uptime: <span className="font-medium text-gray-900">{svc.uptimePercent90d.toFixed(2)}%</span> 90 Uptime: <span className="font-medium text-gray-900 dark:text-gray-100">{svc.uptimePercent90d.toFixed(2)}%</span>
</div> </div>
</div> </div>
@ -129,10 +129,10 @@ const ServiceStatusPage = () => {
</div> </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>
<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>{svc.dailyUptime.length > 0 ? formatDate(svc.dailyUptime[0].date) : ''}</span>
<span>Today</span> <span>Today</span>
</div> </div>
@ -141,7 +141,7 @@ const ServiceStatusPage = () => {
))} ))}
{services.length === 0 && ( {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>
</div> </div>

파일 보기

@ -8,7 +8,6 @@ import type {
ApiKeyRequestCreateDto, ApiKeyRequestCreateDto,
ApiKeyRequestReviewDto, ApiKeyRequestReviewDto,
Permission, Permission,
UpdatePermissionsRequest,
} from '../types/apikey'; } from '../types/apikey';
// My Keys // My Keys
@ -28,5 +27,3 @@ export const reviewRequest = (id: number, req: ApiKeyRequestReviewDto) =>
// Permissions // Permissions
export const getPermissions = (keyId: number) => get<Permission[]>(`/keys/${keyId}/permissions`); export const getPermissions = (keyId: number) => get<Permission[]>(`/keys/${keyId}/permissions`);
export const updatePermissions = (keyId: number, req: UpdatePermissionsRequest) =>
put<Permission[]>(`/keys/${keyId}/permissions`, req);

파일 보기

@ -0,0 +1,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 상태/만료 검증 // 5. Key 상태/만료 검증
validateApiKey(apiKey); validateApiKey(apiKey);
// 6. ServiceApi 조회 (경로 + 메서드 매칭) // 6. ServiceApi 조회 (경로 + 메서드 매칭, {변수} 패턴 지원)
String apiPath = remainingPath.startsWith("/") ? remainingPath : "/" + remainingPath; String apiPath = remainingPath.startsWith("/") ? remainingPath : "/" + remainingPath;
SnpServiceApi serviceApi = snpServiceApiRepository SnpServiceApi serviceApi = matchServiceApi(service.getServiceId(), apiPath, request.getMethod());
.findByServiceServiceIdAndApiPathAndApiMethod(
service.getServiceId(), apiPath, request.getMethod())
.orElseThrow(() -> new BusinessException(ErrorCode.GATEWAY_PERMISSION_DENIED));
// 6. 권한 확인 // 6. 권한 확인
snpApiPermissionRepository snpApiPermissionRepository
@ -130,6 +127,37 @@ public class GatewayService {
/** /**
* prefix 매칭 복호화하여 API Key 찾기 * 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) { private SnpApiKey findApiKeyByRawKey(String rawKey) {
if (rawKey.length() < API_KEY_PREFIX_LENGTH) { if (rawKey.length() < API_KEY_PREFIX_LENGTH) {
throw new BusinessException(ErrorCode.GATEWAY_API_KEY_INVALID); throw new BusinessException(ErrorCode.GATEWAY_API_KEY_INVALID);