snp-connection-monitoring/frontend/src/pages/admin/ServicesPage.tsx
HYOJIN c2a71c1b77 feat(design): 디자인 시스템 적용 (CSS 토큰, Button/Badge, 차트, 다크모드) (#48)
- 디자인 시스템 가이드 문서 11개 생성 (docs/design/)
- CSS 변수 토큰 시스템 (@theme + :root/.dark 전환)
- cn() 유틸리티 (clsx + tailwind-merge)
- Button/Badge 공통 컴포넌트 (variant/size, 다크모드 대응)
- 하드코딩 Tailwind 색상 → CSS 변수 토큰 리팩토링 (30개 파일)
- 차트 팔레트 다크모드 색상 업데이트 (CHART_COLORS_HEX)
- 버튼 다크모드 채도/대비 강화 (primary-600 기반)

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

474 lines
20 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';
import Button from '../../components/ui/Button';
import Badge from '../../components/ui/Badge';
const HEALTH_DOT: Record<string, string> = {
UP: 'bg-green-500',
DOWN: 'bg-red-500',
UNKNOWN: 'bg-gray-400',
};
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-[var(--color-text-secondary)]"> ...</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-[var(--color-text-primary)]">Services</h1>
<Button onClick={handleOpenCreateService}>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-[var(--color-bg-surface)] rounded-lg shadow mb-6">
<table className="w-full divide-y divide-[var(--color-border)] text-sm">
<thead className="bg-[var(--color-bg-base)]">
<tr>
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Code</th>
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Name</th>
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">URL</th>
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Health Status</th>
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Response Time</th>
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Last Checked</th>
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Active</th>
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--color-border)]">
{services.map((service) => {
const dot = HEALTH_DOT[service.healthStatus] ?? HEALTH_DOT.UNKNOWN;
const healthVariant =
service.healthStatus === 'UP'
? 'success'
: service.healthStatus === 'DOWN'
? 'danger'
: 'default';
const isSelected = selectedService?.serviceId === service.serviceId;
return (
<tr
key={service.serviceId}
onClick={() => handleSelectService(service)}
className={`cursor-pointer ${
isSelected ? 'bg-[var(--color-primary-subtle)]' : 'hover:bg-[var(--color-bg-base)]'
}`}
>
<td className="px-4 py-3 font-mono text-[var(--color-text-primary)]">{service.serviceCode}</td>
<td className="px-4 py-3 text-[var(--color-text-primary)]">{service.serviceName}</td>
<td className="px-4 py-3 text-[var(--color-text-secondary)] truncate max-w-[200px]">
{service.serviceUrl || '-'}
</td>
<td className="px-4 py-3">
<Badge variant={healthVariant} className="gap-1.5">
<span className={`w-2 h-2 rounded-full ${dot}`} />
{service.healthStatus}
</Badge>
</td>
<td className="px-4 py-3 text-[var(--color-text-secondary)]">
{service.healthResponseTime != null
? `${service.healthResponseTime}ms`
: '-'}
</td>
<td className="px-4 py-3 text-[var(--color-text-secondary)]">
{formatRelativeTime(service.healthCheckedAt)}
</td>
<td className="px-4 py-3">
<Badge variant={service.isActive ? 'success' : 'danger'}>
{service.isActive ? 'Active' : 'Inactive'}
</Badge>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleOpenEditService(service);
}}
>
</Button>
<Button
variant="danger"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleDeleteService(service);
}}
>
</Button>
</div>
</td>
</tr>
);
})}
{services.length === 0 && (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-[var(--color-text-tertiary)]">
.
</td>
</tr>
)}
</tbody>
</table>
</div>
{selectedService && (
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow">
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--color-border)]">
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">
APIs for {selectedService.serviceName}
</h2>
<Button size="sm" onClick={() => navigate('/admin/apis')}>API </Button>
</div>
<div className="overflow-x-auto">
<table className="w-full divide-y divide-[var(--color-border)] text-sm">
<thead className="bg-[var(--color-bg-base)]">
<tr>
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Method</th>
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Path</th>
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Name</th>
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Domain</th>
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Section</th>
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Description</th>
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Active</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--color-border)]">
{serviceApis.map((api) => (
<tr key={api.apiId} className="hover:bg-[var(--color-bg-base)]">
<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-[var(--color-text-primary)]">{api.apiPath}</td>
<td className="px-4 py-3 text-[var(--color-text-primary)]">{api.apiName}</td>
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{api.apiDomain || '-'}</td>
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{api.apiSection || '-'}</td>
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{api.description || '-'}</td>
<td className="px-4 py-3">
<Badge variant={api.isActive ? 'success' : 'danger'}>
{api.isActive ? 'Active' : 'Inactive'}
</Badge>
</td>
</tr>
))}
{serviceApis.length === 0 && (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-[var(--color-text-tertiary)]">
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-[var(--color-bg-surface)] rounded-lg shadow-xl w-full max-w-md mx-4">
<div className="px-6 py-4 border-b border-[var(--color-border)]">
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">
{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-[var(--color-text-primary)] mb-1">
Service Code
</label>
<input
type="text"
value={serviceCode}
onChange={(e) => setServiceCode(e.target.value)}
disabled={!!editingService}
required
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none disabled:opacity-50"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
Service Name
</label>
<input
type="text"
value={serviceName}
onChange={(e) => setServiceName(e.target.value)}
required
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
Service URL
</label>
<input
type="text"
value={serviceUrl}
onChange={(e) => setServiceUrl(e.target.value)}
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
Description
</label>
<textarea
value={serviceDescription}
onChange={(e) => setServiceDescription(e.target.value)}
rows={3}
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
Health Check URL
</label>
<input
type="text"
value={healthCheckUrl}
onChange={(e) => setHealthCheckUrl(e.target.value)}
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--color-text-primary)] 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-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] 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-[var(--color-text-primary)]">
Active
</label>
</div>
)}
</div>
<div className="px-6 py-4 border-t border-[var(--color-border)] flex justify-end gap-2">
<Button type="button" variant="outline" onClick={handleCloseServiceModal}>
Cancel
</Button>
<Button type="submit">Save</Button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default ServicesPage;