snp-connection-monitoring/frontend/src/pages/admin/ServicesPage.tsx
HYOJIN a9cdf96481 feat(api): API 관리 상세 화면 구현
- API 명세(Spec) 및 파라미터(Param) CRUD 엔드포인트 추가
- API 관리 상세 편집 페이지(ApiEditPage) 구현
- API 목록 관리 페이지(ApisPage) 구현
- 요청인자/출력결과 편집 + JSON 파싱 기능
- 프론트엔드 타입/서비스 정의 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 13:57:09 +09:00

497 lines
21 KiB
TypeScript

import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import type {
ServiceInfo,
ServiceApi,
CreateServiceRequest,
UpdateServiceRequest,
} from '../../types/service';
import {
getServices,
createService,
updateService,
deleteService,
getServiceApis,
} 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 navigate = useNavigate();
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 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) => {
if (selectedService?.serviceId === service.serviceId) {
setSelectedService(null);
setServiceApis([]);
} else {
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 handleDeleteService = async (service: ServiceInfo) => {
if (!window.confirm(`'${service.serviceName}' 서비스를 삭제하시겠습니까?`)) return;
try {
const res = await deleteService(service.serviceId);
if (!res.success) {
setError(res.message || '서비스 삭제에 실패했습니다.');
return;
}
if (selectedService?.serviceId === service.serviceId) {
setSelectedService(null);
setServiceApis([]);
}
await fetchServices();
} catch {
setError('서비스 삭제에 실패했습니다.');
}
};
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 && (
<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">
<div className="flex items-center gap-2">
<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>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteService(service);
}}
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>
</div>
</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={() => navigate('/admin/apis')}
className="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 rounded-lg text-sm font-medium"
>
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">Domain</th>
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Section</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.apiDomain || '-'}</td>
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{api.apiSection || '-'}</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={7} 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>
)}
</div>
);
};
export default ServicesPage;