snp-connection-monitoring/frontend/src/pages/admin/ServicesPage.tsx
HYOJIN d4aa982e1a feat(ui): 피드백 반영 - 다크모드, API Key UX, 레이아웃 개선
공통:
- 다크/라이트 모드 (ThemeContext, Tailwind dark variant, 전체 페이지 적용)
- 사이드바 아이콘 링크체인 (#FF2E63), 헤더/사이드바 높이 통일
- 컨텐츠 영역 max-w-7xl 마진 통일 (대시보드 제외)
- 전체 Actions 버튼 bg-color-100 스타일 통일
- date input 달력 아이콘 다크모드 (filter invert)

API Keys:
- Request: 영구 사용 옵션 추가, 프리셋/영구 버튼 다크모드
- My Keys: ADMIN 직접 생성 제거 → Request 페이지 정식 폼으로 통일
- Admin: 키 관리 만료일 컬럼 추가, 권한 편집 제거 (승인 단계에서만 가능)

Gateway:
- API 경로 {변수} 패턴 매칭 지원

Closes #15

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 16:54:29 +09:00

576 lines
25 KiB
TypeScript

import { useState, useEffect } from 'react';
import type {
ServiceInfo,
ServiceApi,
CreateServiceRequest,
UpdateServiceRequest,
CreateServiceApiRequest,
} from '../../types/service';
import {
getServices,
createService,
updateService,
getServiceApis,
createServiceApi,
} from '../../services/serviceService';
const HEALTH_BADGE: Record<string, { dot: string; bg: string; text: string }> = {
UP: { dot: 'bg-green-500', bg: 'bg-green-100', text: 'text-green-800' },
DOWN: { dot: 'bg-red-500', bg: 'bg-red-100', text: 'text-red-800' },
UNKNOWN: { dot: 'bg-gray-400', bg: 'bg-gray-100', text: 'text-gray-800' },
};
const METHOD_COLOR: Record<string, string> = {
GET: 'bg-green-100 text-green-800',
POST: 'bg-blue-100 text-blue-800',
PUT: 'bg-orange-100 text-orange-800',
DELETE: 'bg-red-100 text-red-800',
};
const formatRelativeTime = (dateStr: string | null): string => {
if (!dateStr) return '-';
const diff = Date.now() - new Date(dateStr).getTime();
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return `${seconds}초 전`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}분 전`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}시간 전`;
const days = Math.floor(hours / 24);
return `${days}일 전`;
};
const ServicesPage = () => {
const [services, setServices] = useState<ServiceInfo[]>([]);
const [selectedService, setSelectedService] = useState<ServiceInfo | null>(null);
const [serviceApis, setServiceApis] = useState<ServiceApi[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isServiceModalOpen, setIsServiceModalOpen] = useState(false);
const [editingService, setEditingService] = useState<ServiceInfo | null>(null);
const [serviceCode, setServiceCode] = useState('');
const [serviceName, setServiceName] = useState('');
const [serviceUrl, setServiceUrl] = useState('');
const [serviceDescription, setServiceDescription] = useState('');
const [healthCheckUrl, setHealthCheckUrl] = useState('');
const [healthCheckInterval, setHealthCheckInterval] = useState(60);
const [serviceIsActive, setServiceIsActive] = useState(true);
const [isApiModalOpen, setIsApiModalOpen] = useState(false);
const [apiMethod, setApiMethod] = useState('GET');
const [apiPath, setApiPath] = useState('');
const [apiName, setApiName] = useState('');
const [apiDescription, setApiDescription] = useState('');
const fetchServices = async () => {
try {
setLoading(true);
const res = await getServices();
if (res.success && res.data) {
setServices(res.data);
} else {
setError(res.message || '서비스 목록을 불러오는데 실패했습니다.');
}
} catch {
setError('서비스 목록을 불러오는데 실패했습니다.');
} finally {
setLoading(false);
}
};
const fetchApis = async (serviceId: number) => {
try {
const res = await getServiceApis(serviceId);
if (res.success && res.data) {
setServiceApis(res.data);
}
} catch {
setServiceApis([]);
}
};
useEffect(() => {
fetchServices();
}, []);
const handleSelectService = (service: ServiceInfo) => {
setSelectedService(service);
fetchApis(service.serviceId);
};
const handleOpenCreateService = () => {
setEditingService(null);
setServiceCode('');
setServiceName('');
setServiceUrl('');
setServiceDescription('');
setHealthCheckUrl('');
setHealthCheckInterval(60);
setServiceIsActive(true);
setIsServiceModalOpen(true);
};
const handleOpenEditService = (service: ServiceInfo) => {
setEditingService(service);
setServiceCode(service.serviceCode);
setServiceName(service.serviceName);
setServiceUrl(service.serviceUrl || '');
setServiceDescription(service.description || '');
setHealthCheckUrl(service.healthCheckUrl || '');
setHealthCheckInterval(service.healthCheckInterval);
setServiceIsActive(service.isActive);
setIsServiceModalOpen(true);
};
const handleCloseServiceModal = () => {
setIsServiceModalOpen(false);
setEditingService(null);
setError(null);
};
const handleServiceSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
try {
if (editingService) {
const req: UpdateServiceRequest = {
serviceName,
serviceUrl: serviceUrl || undefined,
description: serviceDescription || undefined,
healthCheckUrl: healthCheckUrl || undefined,
healthCheckInterval,
isActive: serviceIsActive,
};
const res = await updateService(editingService.serviceId, req);
if (!res.success) {
setError(res.message || '서비스 수정에 실패했습니다.');
return;
}
} else {
const req: CreateServiceRequest = {
serviceCode,
serviceName,
serviceUrl: serviceUrl || undefined,
description: serviceDescription || undefined,
healthCheckUrl: healthCheckUrl || undefined,
healthCheckInterval,
};
const res = await createService(req);
if (!res.success) {
setError(res.message || '서비스 생성에 실패했습니다.');
return;
}
}
handleCloseServiceModal();
await fetchServices();
} catch {
setError(editingService ? '서비스 수정에 실패했습니다.' : '서비스 생성에 실패했습니다.');
}
};
const handleOpenCreateApi = () => {
setApiMethod('GET');
setApiPath('');
setApiName('');
setApiDescription('');
setIsApiModalOpen(true);
};
const handleCloseApiModal = () => {
setIsApiModalOpen(false);
setError(null);
};
const handleApiSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedService) return;
setError(null);
try {
const req: CreateServiceApiRequest = {
apiMethod,
apiPath,
apiName,
description: apiDescription || undefined,
};
const res = await createServiceApi(selectedService.serviceId, req);
if (!res.success) {
setError(res.message || 'API 생성에 실패했습니다.');
return;
}
handleCloseApiModal();
await fetchApis(selectedService.serviceId);
} catch {
setError('API 생성에 실패했습니다.');
}
};
if (loading) {
return <div className="max-w-7xl mx-auto text-center py-10 text-gray-500 dark:text-gray-400"> ...</div>;
}
return (
<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 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"
>
Create Service
</button>
</div>
{error && !isServiceModalOpen && !isApiModalOpen && (
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
)}
<div className="overflow-x-auto bg-white 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 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 dark:divide-gray-700">
{services.map((service) => {
const badge = HEALTH_BADGE[service.healthStatus] || HEALTH_BADGE.UNKNOWN;
const isSelected = selectedService?.serviceId === service.serviceId;
return (
<tr
key={service.serviceId}
onClick={() => handleSelectService(service)}
className={`cursor-pointer ${
isSelected ? 'bg-blue-50 dark:bg-blue-900/30' : 'hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
<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">
<span
className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${badge.bg} ${badge.text}`}
>
<span className={`w-2 h-2 rounded-full ${badge.dot}`} />
{service.healthStatus}
</span>
</td>
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">
{service.healthResponseTime != null
? `${service.healthResponseTime}ms`
: '-'}
</td>
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">
{formatRelativeTime(service.healthCheckedAt)}
</td>
<td className="px-4 py-3">
<span
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
service.isActive
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{service.isActive ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-4 py-3">
<button
onClick={(e) => {
e.stopPropagation();
handleOpenEditService(service);
}}
className="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>
</td>
</tr>
);
})}
{services.length === 0 && (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-gray-400 dark:text-gray-500">
.
</td>
</tr>
)}
</tbody>
</table>
</div>
{selectedService && (
<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
onClick={handleOpenCreateApi}
className="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 rounded-lg text-sm font-medium"
>
Add API
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full divide-y divide-gray-200 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 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 dark:divide-gray-700">
{serviceApis.map((api) => (
<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 ${
METHOD_COLOR[api.apiMethod] || 'bg-gray-100 text-gray-800'
}`}
>
{api.apiMethod}
</span>
</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 ${
api.isActive
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{api.isActive ? 'Active' : 'Inactive'}
</span>
</td>
</tr>
))}
{serviceApis.length === 0 && (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-gray-400 dark:text-gray-500">
API가 .
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)}
{isServiceModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white 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>
<form onSubmit={handleServiceSubmit}>
<div className="px-6 py-4 space-y-4 max-h-[70vh] overflow-y-auto">
{error && (
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Service Code
</label>
<input
type="text"
value={serviceCode}
onChange={(e) => setServiceCode(e.target.value)}
disabled={!!editingService}
required
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 dark:text-gray-300 mb-1">
Service Name
</label>
<input
type="text"
value={serviceName}
onChange={(e) => setServiceName(e.target.value)}
required
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 dark:text-gray-300 mb-1">
Service URL
</label>
<input
type="text"
value={serviceUrl}
onChange={(e) => setServiceUrl(e.target.value)}
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 dark:text-gray-300 mb-1">
Description
</label>
<textarea
value={serviceDescription}
onChange={(e) => setServiceDescription(e.target.value)}
rows={3}
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 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 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 dark:text-gray-300 mb-1">
Health Check Interval (seconds)
</label>
<input
type="number"
value={healthCheckInterval}
onChange={(e) => setHealthCheckInterval(Number(e.target.value))}
min={10}
className="w-full border 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 && (
<div className="flex items-center gap-2">
<input
type="checkbox"
id="serviceIsActive"
checked={serviceIsActive}
onChange={(e) => setServiceIsActive(e.target.checked)}
className="rounded"
/>
<label htmlFor="serviceIsActive" className="text-sm text-gray-700 dark:text-gray-300">
Active
</label>
</div>
)}
</div>
<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 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>
<button
type="submit"
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
>
Save
</button>
</div>
</form>
</div>
</div>
)}
{isApiModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white 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">
{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 dark:text-gray-300 mb-1">Method</label>
<select
value={apiMethod}
onChange={(e) => setApiMethod(e.target.value)}
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>
<option value="PUT">PUT</option>
<option value="DELETE">DELETE</option>
</select>
</div>
<div>
<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 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 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 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 dark:text-gray-300 mb-1">
Description
</label>
<textarea
value={apiDescription}
onChange={(e) => setApiDescription(e.target.value)}
rows={3}
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 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 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>
<button
type="submit"
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
>
Save
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default ServicesPage;