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

431 lines
18 KiB
TypeScript
Raw Blame 히스토리

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import type { ServiceInfo, ServiceApi, CreateServiceApiRequest } from '../../types/service';
import { getServices, getServiceApis, createServiceApi } from '../../services/serviceService';
const METHOD_COLOR: 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',
DELETE: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
};
interface FlatApi extends ServiceApi {
serviceName: string;
}
const ApisPage = () => {
const navigate = useNavigate();
const [services, setServices] = useState<ServiceInfo[]>([]);
const [allApis, setAllApis] = useState<FlatApi[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Filter state
const [filterServiceId, setFilterServiceId] = useState<number | 'all'>('all');
const [searchText, setSearchText] = useState('');
const [filterActive, setFilterActive] = useState<'all' | 'active' | 'inactive'>('all');
// Create API modal state
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalError, setModalError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [modalServiceId, setModalServiceId] = useState<number | ''>('');
const [modalMethod, setModalMethod] = useState('GET');
const [modalPath, setModalPath] = useState('');
const [modalName, setModalName] = useState('');
const [modalDomain, setModalDomain] = useState('');
const [modalSection, setModalSection] = useState('');
const [modalDescription, setModalDescription] = useState('');
const fetchAll = async () => {
try {
setLoading(true);
setError(null);
const svcRes = await getServices();
if (!svcRes.success || !svcRes.data) {
setError(svcRes.message || '서비스 목록을 불러오는데 실패했습니다.');
return;
}
const loadedServices = svcRes.data;
setServices(loadedServices);
if (loadedServices.length === 0) {
setAllApis([]);
return;
}
const results = await Promise.allSettled(
loadedServices.map((svc) => getServiceApis(svc.serviceId)),
);
const flat: FlatApi[] = [];
results.forEach((result, idx) => {
if (result.status === 'fulfilled' && result.value.success && result.value.data) {
result.value.data.forEach((api) => {
flat.push({ ...api, serviceName: loadedServices[idx].serviceName });
});
}
});
setAllApis(flat);
} catch {
setError('API 목록을 불러오는데 실패했습니다.');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchAll();
}, []);
const filteredApis = useMemo(() => {
return allApis.filter((api) => {
if (filterServiceId !== 'all' && api.serviceId !== filterServiceId) return false;
if (filterActive === 'active' && !api.isActive) return false;
if (filterActive === 'inactive' && api.isActive) return false;
if (searchText.trim()) {
const q = searchText.trim().toLowerCase();
if (!api.apiName.toLowerCase().includes(q) && !api.apiPath.toLowerCase().includes(q)) {
return false;
}
}
return true;
});
}, [allApis, filterServiceId, filterActive, searchText]);
const handleOpenModal = () => {
setModalServiceId(services.length > 0 ? services[0].serviceId : '');
setModalMethod('GET');
setModalPath('');
setModalName('');
setModalDomain('');
setModalSection('');
setModalDescription('');
setModalError(null);
setIsModalOpen(true);
};
const handleCloseModal = () => {
setIsModalOpen(false);
setModalError(null);
};
const handleModalSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (modalServiceId === '') return;
setModalError(null);
setSubmitting(true);
try {
const req: CreateServiceApiRequest = {
apiMethod: modalMethod,
apiPath: modalPath,
apiName: modalName,
apiDomain: modalDomain || undefined,
apiSection: modalSection || undefined,
description: modalDescription || undefined,
};
const res = await createServiceApi(modalServiceId as number, req);
if (!res.success) {
setModalError(res.message || 'API 생성에 실패했습니다.');
return;
}
handleCloseModal();
await fetchAll();
if (res.data) {
navigate(`/admin/apis/${res.data.serviceId}/${res.data.apiId}`);
}
} catch {
setModalError('API 생성에 실패했습니다.');
} finally {
setSubmitting(false);
}
};
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">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">API </h1>
<button
onClick={handleOpenModal}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
>
API
</button>
</div>
{/* Global error */}
{error && (
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-400 rounded-lg text-sm">
{error}
</div>
)}
{/* Filters */}
<div className="flex flex-wrap gap-3 mb-4">
<select
value={filterServiceId}
onChange={(e) =>
setFilterServiceId(e.target.value === 'all' ? 'all' : Number(e.target.value))
}
className="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all"> </option>
{services.map((svc) => (
<option key={svc.serviceId} value={svc.serviceId}>
{svc.serviceName}
</option>
))}
</select>
<input
type="text"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
placeholder="API명, Path 검색"
className="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 min-w-[200px]"
/>
<div className="flex rounded-lg border border-gray-300 dark:border-gray-600 overflow-hidden text-sm">
{(['all', 'active', 'inactive'] as const).map((v) => (
<button
key={v}
onClick={() => setFilterActive(v)}
className={`px-3 py-2 font-medium ${
filterActive === v
? 'bg-blue-600 text-white'
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
{v === 'all' ? '전체' : v === 'active' ? 'Active' : 'Inactive'}
</button>
))}
</div>
</div>
{/* Table */}
<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 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">Path</th>
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">API명</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">Active</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 dark:divide-gray-700">
{filteredApis.map((api) => (
<tr
key={`${api.serviceId}-${api.apiId}`}
onClick={() => navigate(`/admin/apis/${api.serviceId}/${api.apiId}`)}
className="cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700"
>
<td className="px-4 py-3 text-gray-700 dark:text-gray-300">{api.serviceName}</td>
<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-700 dark:bg-gray-700 dark:text-gray-300'
}`}
>
{api.apiMethod}
</span>
</td>
<td className="px-4 py-3 font-mono text-gray-800 dark:text-gray-200 truncate max-w-[240px]">
{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">
<span
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
api.isActive
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300'
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300'
}`}
>
{api.isActive ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-4 py-3">
<span className="text-gray-400 dark:text-gray-500">-</span>
</td>
</tr>
))}
{filteredApis.length === 0 && (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-gray-400 dark:text-gray-500">
API가
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Create API Modal */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-lg mx-4">
<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">API </h2>
<button
onClick={handleCloseModal}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-xl font-bold leading-none"
>
×
</button>
</div>
<form onSubmit={handleModalSubmit} className="px-6 py-4 space-y-4">
{modalError && (
<div className="p-3 bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-400 rounded-lg text-sm">
{modalError}
</div>
)}
<div>
<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={modalServiceId}
onChange={(e) => setModalServiceId(Number(e.target.value))}
required
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{services.map((svc) => (
<option key={svc.serviceId} value={svc.serviceId}>
{svc.serviceName}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Method <span className="text-red-500">*</span>
</label>
<select
value={modalMethod}
onChange={(e) => setModalMethod(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{['GET', 'POST', 'PUT', 'DELETE'].map((m) => (
<option key={m} value={m}>{m}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
API Path <span className="text-red-500">*</span>
</label>
<input
type="text"
value={modalPath}
onChange={(e) => setModalPath(e.target.value)}
required
placeholder="/api/v1/example"
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
API명 <span className="text-red-500">*</span>
</label>
<input
type="text"
value={modalName}
onChange={(e) => setModalName(e.target.value)}
required
placeholder="API 이름"
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Domain
</label>
<input
type="text"
value={modalDomain}
onChange={(e) => setModalDomain(e.target.value)}
placeholder="도메인 (선택)"
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Section
</label>
<input
type="text"
value={modalSection}
onChange={(e) => setModalSection(e.target.value)}
placeholder="섹션 (선택)"
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
</label>
<textarea
value={modalDescription}
onChange={(e) => setModalDescription(e.target.value)}
rows={3}
placeholder="API 설명 (선택)"
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
/>
</div>
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={handleCloseModal}
className="px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 text-sm font-medium hover:bg-gray-50 dark:hover:bg-gray-700"
>
</button>
<button
type="submit"
disabled={submitting}
className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm font-medium"
>
{submitting ? '생성 중...' : '생성'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default ApisPage;