snp-connection-monitoring/frontend/src/pages/apihub/ApiHubServicePage.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

224 lines
8.7 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import type { ServiceCatalog, ServiceApiItem } from '../../types/apihub';
import { getServiceCatalog } from '../../services/apiHubService';
const METHOD_COLORS: Record<string, string> = {
GET: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
POST: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
PUT: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
PATCH: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
DELETE: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
};
const HEALTH_DOT: Record<string, string> = {
UP: 'bg-green-500',
DOWN: 'bg-red-500',
UNKNOWN: 'bg-gray-400',
};
const HEALTH_BADGE: Record<string, string> = {
UP: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
DOWN: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
UNKNOWN: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300',
};
const HEALTH_LABEL: Record<string, string> = {
UP: '정상',
DOWN: '중단',
UNKNOWN: '알 수 없음',
};
interface DomainSectionProps {
domainName: string;
apis: ServiceApiItem[];
serviceId: number;
onNavigate: (serviceId: number, apiId: number) => void;
}
const DomainSection = ({ domainName, apis, serviceId, onNavigate }: DomainSectionProps) => (
<div className="mb-6">
<div className="flex items-center gap-3 mb-3">
<h3 className="text-base font-semibold text-[var(--color-text-primary)]">{domainName}</h3>
<span className="text-xs px-2 py-0.5 rounded-full bg-[var(--color-bg-base)] text-[var(--color-text-secondary)]">
{apis.length}
</span>
</div>
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow overflow-hidden border border-[var(--color-border)]">
<table className="w-full text-sm table-fixed">
<colgroup>
<col className="w-[8%]" />
<col className="w-[27%]" />
<col className="w-[20%]" />
<col className="w-[40%]" />
<col className="w-[5%]" />
</colgroup>
<thead className="bg-[var(--color-bg-base)]">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">API명</th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase"></th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--color-border)]">
{apis.map((api) => (
<tr
key={api.apiId}
className="hover:bg-blue-50 dark:hover:bg-gray-700 cursor-pointer transition-colors"
onClick={() => onNavigate(serviceId, api.apiId)}
>
<td className="px-4 py-3">
<span
className={`inline-block px-2 py-0.5 rounded text-xs font-bold uppercase ${METHOD_COLORS[api.apiMethod] ?? 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'}`}
>
{api.apiMethod}
</span>
</td>
<td className="px-4 py-3 font-mono text-xs text-[var(--color-text-primary)] truncate" title={api.apiPath}>
{api.apiPath}
</td>
<td className="px-4 py-3 text-[var(--color-text-primary)] font-medium truncate" title={api.apiName}>
{api.apiName}
</td>
<td className="px-4 py-3 text-[var(--color-text-secondary)] truncate" title={api.description || ''}>
{api.description || <span className="text-[var(--color-text-tertiary)]">-</span>}
</td>
<td className="px-4 py-3">
{api.isActive ? (
<span className="inline-block w-2 h-2 rounded-full bg-green-500" title="활성" />
) : (
<span className="inline-block w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600" title="비활성" />
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
const ApiHubServicePage = () => {
const { serviceId } = useParams<{ serviceId: string }>();
const navigate = useNavigate();
const [service, setService] = useState<ServiceCatalog | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
if (!serviceId) return;
try {
const res = await getServiceCatalog(Number(serviceId));
if (res.success && res.data) {
setService(res.data);
} else {
setError('서비스 정보를 불러오지 못했습니다');
}
} catch {
setError('서비스 정보를 불러오는 중 오류가 발생했습니다');
} finally {
setIsLoading(false);
}
}, [serviceId]);
useEffect(() => {
fetchData();
}, [fetchData]);
const handleApiNavigate = (svcId: number, apiId: number) => {
navigate(`/api-hub/services/${svcId}/apis/${apiId}`);
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-[var(--color-text-secondary)]"> ...</div>
</div>
);
}
if (error || !service) {
return (
<div>
<button
onClick={() => navigate('/api-hub')}
className="text-sm text-[var(--color-primary)] hover:text-blue-800 mb-4 inline-block"
>
API HUB으로
</button>
<div className="text-center py-20 text-[var(--color-text-secondary)]">
{error ?? '서비스를 찾을 수 없습니다'}
</div>
</div>
);
}
const domainsMap = new Map<string, ServiceApiItem[]>();
for (const dg of service.domains) {
const key = dg.domain ? dg.domain.toUpperCase() : '기타';
domainsMap.set(key, dg.apis);
}
// If any apis fall under null domain and weren't covered by domains array, show them under 기타
const domainEntries = [...domainsMap.entries()];
return (
<div className="max-w-7xl mx-auto">
<button
onClick={() => navigate('/api-hub')}
className="text-sm text-[var(--color-primary)] hover:text-blue-800 mb-4 inline-block"
>
API HUB으로
</button>
{/* Service Header */}
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 mb-6 border border-[var(--color-border)]">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-1">
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">{service.serviceName}</h1>
<span className="text-sm text-[var(--color-text-secondary)] font-mono">{service.serviceCode}</span>
</div>
{service.description && (
<p className="text-[var(--color-text-secondary)] mt-2">{service.description}</p>
)}
</div>
<div className="flex flex-col items-end gap-2 ml-6 shrink-0">
<span
className={`flex items-center gap-1.5 px-3 py-1 rounded-full text-sm font-medium ${HEALTH_BADGE[service.healthStatus] ?? 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'}`}
>
<span className={`w-2 h-2 rounded-full ${HEALTH_DOT[service.healthStatus] ?? 'bg-gray-400'}`} />
{HEALTH_LABEL[service.healthStatus] ?? service.healthStatus}
</span>
<div className="flex items-center gap-4 text-sm text-[var(--color-text-secondary)]">
<span>API {service.apiCount}</span>
<span> {service.domains.length}</span>
</div>
</div>
</div>
</div>
{/* API 목록 by Domain */}
{domainEntries.length > 0 ? (
domainEntries.map(([domain, apis]) => (
<DomainSection
key={domain}
domainName={domain}
apis={apis}
serviceId={service.serviceId}
onNavigate={handleApiNavigate}
/>
))
) : (
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-8 text-center text-[var(--color-text-tertiary)]">
API가
</div>
)}
</div>
);
};
export default ApiHubServicePage;