Merge pull request 'feat(ui): 피드백 반영 - 다크모드, API Key UX, 레이아웃 개선' (#20) from feature/ISSUE-15-feedback into develop

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

파일 보기

@ -6,6 +6,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/ko/1.0.0/).
## [Unreleased]
### 추가
- 다크/라이트 모드 전체 적용 (ThemeContext, 토글 버튼, 전 페이지 dark 클래스) (#15)
- API Key 신청 영구 사용 옵션 (#15)
- API Key Admin 키 관리 만료일 컬럼 (#15)
- Gateway API 경로 {변수} 패턴 매칭 지원 (#15)
### 변경
- 사이드바 아이콘 링크체인으로 변경, 헤더/사이드바 높이 통일 (#15)
- 컨텐츠 영역 max-w-7xl 마진 통일 (#15)
- 전체 Actions 버튼 bg-color-100 스타일 통일 (#15)
- API Key Admin 권한 편집 제거 (승인 단계에서만 가능) (#15)
- My Keys ADMIN 직접 생성 제거 → Request 폼 통일 (#15)
## [2026-04-08]
### 추가

파일 보기

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

파일 보기

@ -6,8 +6,8 @@ const ProtectedRoute = () => {
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-500 border-t-transparent" />
<div className="flex min-h-screen items-center justify-center bg-gray-100 dark:bg-gray-900">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-500 border-t-transparent dark:border-blue-400 dark:border-t-transparent" />
</div>
);
}

파일 보기

@ -0,0 +1,8 @@
import { useContext } from 'react';
import { ThemeContext } from '../store/ThemeContext';
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) throw new Error('useTheme must be used within ThemeProvider');
return context;
};

파일 보기

@ -1 +1,8 @@
@import 'tailwindcss';
@custom-variant dark (&:where(.dark, .dark *));
/* Dark mode date input calendar icon */
.dark input[type="date"]::-webkit-calendar-picker-indicator {
filter: invert(1);
}

파일 보기

@ -2,7 +2,7 @@ import { Outlet } from 'react-router-dom';
const AuthLayout = () => {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-gray-900">
<Outlet />
</div>
);

파일 보기

@ -1,6 +1,7 @@
import { useState } from 'react';
import { Outlet, NavLink } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
import { useTheme } from '../hooks/useTheme';
interface NavGroup {
label: string;
@ -37,6 +38,7 @@ const navGroups: NavGroup[] = [
const MainLayout = () => {
const { user, logout } = useAuth();
const { theme, toggleTheme } = useTheme();
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({
Monitoring: true,
'API Keys': true,
@ -57,9 +59,9 @@ const MainLayout = () => {
<div className="flex min-h-screen">
{/* Sidebar */}
<aside className="fixed left-0 top-0 h-screen w-64 bg-gray-900 text-white flex flex-col">
<div className="flex items-center gap-2 px-6 py-5 border-b border-gray-700">
<svg className="h-6 w-6 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
<div className="flex items-center gap-2 px-6 h-16 border-b border-gray-700">
<svg className="h-6 w-6" style={{ color: '#FF2E63' }} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
<span className="text-lg font-semibold">SNP Connection</span>
</div>
@ -139,16 +141,31 @@ const MainLayout = () => {
{/* Main Content */}
<div className="flex-1 ml-64">
{/* Header */}
<header className="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-6">
<header className="h-16 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between px-6">
<div />
<div className="flex items-center gap-4">
<span className="text-sm text-gray-700">{user?.userName}</span>
<button
onClick={toggleTheme}
className="rounded-lg p-2 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title={theme === 'light' ? 'Dark mode' : 'Light mode'}
>
{theme === 'light' ? (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
) : (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
)}
</button>
<span className="text-sm text-gray-700 dark:text-gray-300">{user?.userName}</span>
<span className="rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800">
{user?.role}
</span>
<button
onClick={handleLogout}
className="rounded-lg px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition-colors"
className="rounded-lg px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
>
Logout
</button>
@ -156,7 +173,7 @@ const MainLayout = () => {
</header>
{/* Content */}
<main className="p-6">
<main className="p-6 bg-gray-100 dark:bg-gray-900 min-h-[calc(100vh-4rem)]">
<Outlet />
</main>
</div>

파일 보기

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

파일 보기

@ -37,14 +37,14 @@ const LoginPage = () => {
return (
<div className="w-full max-w-md">
<div className="rounded-xl bg-white px-8 py-10 shadow-lg">
<h1 className="mb-8 text-center text-2xl font-bold text-gray-900">
<div className="rounded-xl bg-white dark:bg-gray-800 px-8 py-10 shadow-lg">
<h1 className="mb-8 text-center text-2xl font-bold text-gray-900 dark:text-gray-100">
SNP Connection Monitoring
</h1>
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor="loginId" className="block text-sm font-medium text-gray-700 mb-1">
<label htmlFor="loginId" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
</label>
<input
@ -52,14 +52,14 @@ const LoginPage = () => {
type="text"
value={loginId}
onChange={(e) => setLoginId(e.target.value)}
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 px-4 py-2.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="아이디를 입력하세요"
autoComplete="username"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
</label>
<input
@ -67,7 +67,7 @@ const LoginPage = () => {
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 px-4 py-2.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="비밀번호를 입력하세요"
autoComplete="current-password"
/>

파일 보기

@ -3,8 +3,8 @@ import { Link } from 'react-router-dom';
const NotFoundPage = () => {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center text-center">
<h1 className="text-4xl font-bold text-gray-900">404 - Page Not Found</h1>
<p className="mt-3 text-gray-600"> .</p>
<h1 className="text-4xl font-bold text-gray-900 dark:text-gray-100">404 - Page Not Found</h1>
<p className="mt-3 text-gray-600 dark:text-gray-400"> .</p>
<Link
to="/dashboard"
className="mt-6 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 transition-colors"

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -1,8 +1,7 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import type { ApiKey } from '../../types/apikey';
import { getMyKeys, createKey, revokeKey } from '../../services/apiKeyService';
import { useAuth } from '../../hooks/useAuth';
import { getMyKeys, revokeKey } from '../../services/apiKeyService';
const STATUS_BADGE: Record<string, string> = {
ACTIVE: 'bg-green-100 text-green-800',
@ -18,16 +17,10 @@ const formatDateTime = (dateStr: string | null): string => {
};
const MyKeysPage = () => {
const { user } = useAuth();
const isAdmin = user?.role === 'ADMIN';
const [keys, setKeys] = useState<ApiKey[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [keyName, setKeyName] = useState('');
const [rawKeyModal, setRawKeyModal] = useState<{ keyName: string; rawKey: string } | null>(null);
const [copied, setCopied] = useState(false);
@ -66,25 +59,6 @@ const MyKeysPage = () => {
}
};
const handleCreateSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
try {
const res = await createKey({ keyName });
if (res.success && res.data) {
setIsCreateModalOpen(false);
setKeyName('');
setRawKeyModal({ keyName: res.data.keyName, rawKey: res.data.rawKey });
await fetchKeys();
} else {
setError(res.message || 'API Key 생성에 실패했습니다.');
}
} catch {
setError('API Key 생성에 실패했습니다.');
}
};
const handleCopyRawKey = async () => {
if (!rawKeyModal) return;
await navigator.clipboard.writeText(rawKeyModal.rawKey);
@ -93,59 +67,45 @@ const MyKeysPage = () => {
};
if (loading) {
return <div className="text-center py-10 text-gray-500"> ...</div>;
return <div className="max-w-7xl mx-auto text-center py-10 text-gray-500 dark:text-gray-400"> ...</div>;
}
return (
<div>
<div className="max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900">My API Keys</h1>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">My API Keys</h1>
<div className="flex gap-2">
{!isAdmin && (
<Link
to="/apikeys/request"
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
>
API Key
</Link>
)}
{isAdmin && (
<button
onClick={() => {
setKeyName('');
setError(null);
setIsCreateModalOpen(true);
}}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
>
API Key
</button>
)}
<Link
to="/apikeys/request"
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
>
API Key
</Link>
</div>
</div>
{error && !isCreateModalOpen && (
{error && (
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
)}
<div className="overflow-x-auto bg-white rounded-lg shadow">
<table className="w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-50">
<div className="overflow-x-auto bg-white dark:bg-gray-800 rounded-lg shadow">
<table className="w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-500">Key Name</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Prefix</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Status</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Expires At</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Last Used At</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Created At</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Actions</th>
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Key Name</th>
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Prefix</th>
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Status</th>
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Expires At</th>
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Last Used At</th>
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Created At</th>
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{keys.map((key) => (
<tr key={key.apiKeyId} className="hover:bg-gray-50">
<td className="px-4 py-3">{key.keyName}</td>
<td className="px-4 py-3 font-mono text-gray-600">{key.apiKeyPrefix}</td>
<tr key={key.apiKeyId} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{key.keyName}</td>
<td className="px-4 py-3 font-mono text-gray-600 dark:text-gray-400">{key.apiKeyPrefix}</td>
<td className="px-4 py-3">
<span
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
@ -155,14 +115,14 @@ const MyKeysPage = () => {
{key.status}
</span>
</td>
<td className="px-4 py-3 text-gray-500">{formatDateTime(key.expiresAt)}</td>
<td className="px-4 py-3 text-gray-500">{formatDateTime(key.lastUsedAt)}</td>
<td className="px-4 py-3 text-gray-500">{formatDateTime(key.createdAt)}</td>
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{formatDateTime(key.expiresAt)}</td>
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{formatDateTime(key.lastUsedAt)}</td>
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{formatDateTime(key.createdAt)}</td>
<td className="px-4 py-3">
{key.status === 'ACTIVE' && (
<button
onClick={() => handleRevoke(key)}
className="text-red-600 hover:text-red-800 font-medium text-sm"
className="bg-red-100 hover:bg-red-200 text-red-700 dark:bg-red-900/30 dark:hover:bg-red-800/40 dark:text-red-400 px-3 py-1 rounded-lg text-sm font-medium"
>
</button>
@ -172,7 +132,7 @@ const MyKeysPage = () => {
))}
{keys.length === 0 && (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-gray-400">
<td colSpan={7} className="px-4 py-8 text-center text-gray-400 dark:text-gray-500">
API Key가 .
</td>
</tr>
@ -181,66 +141,24 @@ const MyKeysPage = () => {
</table>
</div>
{isCreateModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
<div className="px-6 py-4 border-b">
<h2 className="text-lg font-semibold text-gray-900">API Key </h2>
</div>
<form onSubmit={handleCreateSubmit}>
<div className="px-6 py-4 space-y-4">
{error && (
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Key Name</label>
<input
type="text"
value={keyName}
onChange={(e) => setKeyName(e.target.value)}
required
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
</div>
</div>
<div className="px-6 py-4 border-t flex justify-end gap-2">
<button
type="button"
onClick={() => setIsCreateModalOpen(false)}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium"
>
</button>
<button
type="submit"
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
>
</button>
</div>
</form>
</div>
</div>
)}
{rawKeyModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-lg mx-4">
<div className="px-6 py-4 border-b">
<h2 className="text-lg font-semibold text-gray-900">API Key </h2>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-lg mx-4">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">API Key </h2>
</div>
<div className="px-6 py-4 space-y-4">
<div className="p-3 bg-yellow-50 border border-yellow-200 text-yellow-800 rounded-lg text-sm">
. .
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Key Name</label>
<p className="text-gray-900">{rawKeyModal.keyName}</p>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Key Name</label>
<p className="text-gray-900 dark:text-gray-100">{rawKeyModal.keyName}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">API Key</label>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">API Key</label>
<div className="flex items-center gap-2">
<code className="flex-1 bg-gray-100 px-3 py-2 rounded-lg text-sm font-mono break-all">
<code className="flex-1 bg-gray-100 dark:bg-gray-700 px-3 py-2 rounded-lg text-sm font-mono break-all text-gray-900 dark:text-gray-100">
{rawKeyModal.rawKey}
</code>
<button
@ -252,13 +170,13 @@ const MyKeysPage = () => {
</div>
</div>
</div>
<div className="px-6 py-4 border-t flex justify-end">
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end">
<button
onClick={() => {
setRawKeyModal(null);
setCopied(false);
}}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium"
className="bg-gray-200 hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 px-4 py-2 rounded-lg text-sm font-medium"
>
</button>

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -0,0 +1,48 @@
import { createContext, useState, useEffect, useCallback } from 'react';
import type { ReactNode } from 'react';
type Theme = 'light' | 'dark';
interface ThemeContextValue {
theme: Theme;
toggleTheme: () => void;
}
export const ThemeContext = createContext<ThemeContextValue | null>(null);
interface ThemeProviderProps {
children: ReactNode;
}
const getInitialTheme = (): Theme => {
const stored = localStorage.getItem('theme');
if (stored === 'light' || stored === 'dark') {
return stored;
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
};
const ThemeProvider = ({ children }: ThemeProviderProps) => {
const [theme, setTheme] = useState<Theme>(getInitialTheme);
useEffect(() => {
if (theme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
localStorage.setItem('theme', theme);
}, [theme]);
const toggleTheme = useCallback(() => {
setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));
}, []);
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
};
export default ThemeProvider;

파일 보기

@ -83,12 +83,9 @@ public class GatewayService {
// 5. Key 상태/만료 검증
validateApiKey(apiKey);
// 6. ServiceApi 조회 (경로 + 메서드 매칭)
// 6. ServiceApi 조회 (경로 + 메서드 매칭, {변수} 패턴 지원)
String apiPath = remainingPath.startsWith("/") ? remainingPath : "/" + remainingPath;
SnpServiceApi serviceApi = snpServiceApiRepository
.findByServiceServiceIdAndApiPathAndApiMethod(
service.getServiceId(), apiPath, request.getMethod())
.orElseThrow(() -> new BusinessException(ErrorCode.GATEWAY_PERMISSION_DENIED));
SnpServiceApi serviceApi = matchServiceApi(service.getServiceId(), apiPath, request.getMethod());
// 6. 권한 확인
snpApiPermissionRepository
@ -130,6 +127,37 @@ public class GatewayService {
/**
* prefix 매칭 복호화하여 API Key 찾기
*/
/**
* ServiceApi 매칭 ({변수} 패턴 지원)
* 등록된 /api/v1/tracks/haegu/{haeguNo} /api/v1/tracks/haegu/101 매칭됨
*/
private SnpServiceApi matchServiceApi(Long serviceId, String requestPath, String method) {
// 1. 정확 매칭 시도
var exact = snpServiceApiRepository
.findByServiceServiceIdAndApiPathAndApiMethod(serviceId, requestPath, method);
if (exact.isPresent()) {
return exact.get();
}
// 2. {변수} 패턴 매칭
List<SnpServiceApi> candidates = snpServiceApiRepository.findByServiceServiceId(serviceId);
for (SnpServiceApi api : candidates) {
if (!api.getApiMethod().equalsIgnoreCase(method)) continue;
if (!Boolean.TRUE.equals(api.getIsActive())) continue;
String pattern = api.getApiPath();
if (!pattern.contains("{")) continue;
// {variable} [^/]+ 정규식으로 치환
String regex = pattern.replaceAll("\\{[^}]+}", "[^/]+");
if (requestPath.matches(regex)) {
return api;
}
}
throw new BusinessException(ErrorCode.GATEWAY_PERMISSION_DENIED);
}
private SnpApiKey findApiKeyByRawKey(String rawKey) {
if (rawKey.length() < API_KEY_PREFIX_LENGTH) {
throw new BusinessException(ErrorCode.GATEWAY_API_KEY_INVALID);