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

363 lines
15 KiB
TypeScript

import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import type { RequestLog, PageResponse } from '../../types/monitoring';
import type { ServiceInfo } from '../../types/service';
import { searchLogs } from '../../services/monitoringService';
import { getServices } from '../../services/serviceService';
import Badge from '../../components/ui/Badge';
import type { BadgeVariant } from '../../components/ui/Badge';
import Button from '../../components/ui/Button';
const METHOD_CLASS: Record<string, string> = {
GET: 'bg-green-100 text-green-800 dark:bg-green-500/15 dark:text-green-400',
POST: 'bg-blue-100 text-blue-800 dark:bg-blue-500/15 dark:text-blue-400',
PUT: 'bg-orange-100 text-orange-800 dark:bg-orange-500/15 dark:text-orange-400',
DELETE: 'bg-red-100 text-red-800 dark:bg-red-500/15 dark:text-red-400',
};
const STATUS_VARIANT: Record<string, BadgeVariant> = {
SUCCESS: 'success',
FAIL: 'danger',
DENIED: 'warning',
EXPIRED: 'warning',
INVALID_KEY: 'danger',
ERROR: 'danger',
FAILED: 'default',
};
const REQUEST_STATUSES = ['SUCCESS', 'FAIL', 'DENIED', 'EXPIRED', 'INVALID_KEY', 'ERROR', 'FAILED'];
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE'];
const DEFAULT_PAGE_SIZE = 20;
const formatDate = (d: Date): string => {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
};
const getToday = (): string => formatDate(new Date());
const getTodayString = getToday;
const formatDateTime = (dateStr: string): string => {
const d = new Date(dateStr);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const hours = String(d.getHours()).padStart(2, '0');
const minutes = String(d.getMinutes()).padStart(2, '0');
const seconds = String(d.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};
const RequestLogsPage = () => {
const navigate = useNavigate();
const [startDate, setStartDate] = useState(getTodayString());
const [endDate, setEndDate] = useState(getTodayString());
const [datePreset, setDatePreset] = useState('오늘');
const [serviceId, setServiceId] = useState('');
const [requestStatus, setRequestStatus] = useState('');
const [requestMethod, setRequestMethod] = useState('');
const [services, setServices] = useState<ServiceInfo[]>([]);
const [result, setResult] = useState<PageResponse<RequestLog> | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(0);
const fetchServices = async () => {
try {
const res = await getServices();
if (res.success && res.data) {
setServices(res.data);
}
} catch {
// 서비스 목록 로딩 실패는 무시
}
};
const handleSearch = async (page: number) => {
setLoading(true);
setError(null);
setCurrentPage(page);
try {
const params: Record<string, string | number | undefined> = {
startDate: startDate || undefined,
endDate: endDate || undefined,
serviceId: serviceId ? Number(serviceId) : undefined,
requestStatus: requestStatus || undefined,
requestMethod: requestMethod || undefined,
page,
size: DEFAULT_PAGE_SIZE,
};
const res = await searchLogs(params);
if (res.success && res.data) {
setResult(res.data);
} else {
setError(res.message || '로그 조회에 실패했습니다.');
}
} catch {
setError('로그 조회에 실패했습니다.');
} finally {
setLoading(false);
}
};
const handleReset = () => {
setStartDate(getTodayString());
setEndDate(getTodayString());
setDatePreset('오늘');
setServiceId('');
setRequestStatus('');
setRequestMethod('');
setCurrentPage(0);
};
useEffect(() => {
fetchServices();
handleSearch(0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleResetAndSearch = () => {
handleReset();
// 초기화 후 기본값으로 검색 (setState는 비동기이므로 직접 호출)
setLoading(true);
setError(null);
setCurrentPage(0);
const today = getTodayString();
const params: Record<string, string | number | undefined> = {
startDate: today,
endDate: today,
page: 0,
size: DEFAULT_PAGE_SIZE,
};
searchLogs(params)
.then((res) => {
if (res.success && res.data) {
setResult(res.data);
} else {
setError(res.message || '로그 조회에 실패했습니다.');
}
})
.catch(() => {
setError('로그 조회에 실패했습니다.');
})
.finally(() => {
setLoading(false);
});
};
const handleRowClick = (logId: number) => {
navigate(`/monitoring/request-logs/${logId}`);
};
const handlePrev = () => {
if (currentPage > 0) {
handleSearch(currentPage - 1);
}
};
const handleNext = () => {
if (result && currentPage < result.totalPages - 1) {
handleSearch(currentPage + 1);
}
};
return (
<div className="max-w-7xl mx-auto">
<h1 className="text-2xl font-bold text-[var(--color-text-primary)] mb-6">Request Logs</h1>
{/* Search Form */}
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 mb-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div className="md:col-span-3">
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1"></label>
<div className="flex items-center gap-2 flex-wrap mb-2">
{([
{ label: '오늘', fn: () => { const t = getToday(); setStartDate(t); setEndDate(t); setDatePreset('오늘'); } },
{ label: '어제', fn: () => { const d = new Date(); d.setDate(d.getDate() - 1); const y = formatDate(d); setStartDate(y); setEndDate(y); setDatePreset('어제'); } },
{ label: '최근 7일', fn: () => { const d = new Date(); d.setDate(d.getDate() - 6); setStartDate(formatDate(d)); setEndDate(getToday()); setDatePreset('최근 7일'); } },
{ label: '이번 달', fn: () => { const d = new Date(); setStartDate(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01`); setEndDate(getToday()); setDatePreset('이번 달'); } },
{ label: '지난 달', fn: () => { const d = new Date(); d.setMonth(d.getMonth() - 1); const s = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01`; const e = new Date(d.getFullYear(), d.getMonth() + 1, 0); setStartDate(s); setEndDate(formatDate(e)); setDatePreset('지난 달'); } },
{ label: '직접 선택', fn: () => { setDatePreset('직접 선택'); } },
]).map((btn) => (
<button
key={btn.label}
type="button"
onClick={btn.fn}
className={`px-3 py-1.5 text-xs font-medium rounded-lg border transition-colors ${
datePreset === btn.label
? 'bg-blue-50 border-blue-300 text-[var(--color-primary)]'
: 'border-[var(--color-border)] text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-base)]'
}`}
>
{btn.label}
</button>
))}
</div>
<div className="flex items-center gap-2">
<input
type="date"
value={startDate}
onChange={(e) => { setStartDate(e.target.value); setDatePreset('직접 선택'); }}
className="flex-1 border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
/>
<span className="text-[var(--color-text-secondary)]">~</span>
<input
type="date"
value={endDate}
onChange={(e) => { setEndDate(e.target.value); setDatePreset('직접 선택'); }}
className="flex-1 border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
/>
</div>
</div>
</div>
<div className="flex items-end gap-3 flex-wrap">
<div>
<label className="block text-xs font-medium text-[var(--color-text-secondary)] mb-1"></label>
<select
value={serviceId}
onChange={(e) => setServiceId(e.target.value)}
className="border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
>
<option value=""></option>
{services.map((s) => (
<option key={s.serviceId} value={s.serviceId}>{s.serviceName}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-[var(--color-text-secondary)] mb-1"></label>
<select
value={requestStatus}
onChange={(e) => setRequestStatus(e.target.value)}
className="border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
>
<option value=""></option>
{REQUEST_STATUSES.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-[var(--color-text-secondary)] mb-1">Method</label>
<select
value={requestMethod}
onChange={(e) => setRequestMethod(e.target.value)}
className="border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
>
<option value=""></option>
{HTTP_METHODS.map((m) => (
<option key={m} value={m}>{m}</option>
))}
</select>
</div>
<div className="flex items-end gap-2 ml-auto">
<Button onClick={() => handleSearch(0)} variant="primary">
</Button>
<Button onClick={handleResetAndSearch} variant="secondary">
</Button>
</div>
</div>
</div>
{error && (
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
)}
{/* Results Table */}
<div className="overflow-x-auto bg-[var(--color-bg-surface)] rounded-lg shadow mb-6">
{loading ? (
<div className="text-center py-10 text-[var(--color-text-secondary)]"> ...</div>
) : (
<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)]"></th>
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]"></th>
<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)]">URL</th>
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Status Code</th>
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">(ms)</th>
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]"></th>
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">IP</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--color-border)]">
{result && result.content.length > 0 ? (
result.content.map((log) => (
<tr
key={log.logId}
onClick={() => handleRowClick(log.logId)}
className="cursor-pointer hover:bg-[var(--color-bg-base)]"
>
<td className="px-4 py-3 whitespace-nowrap text-[var(--color-text-primary)]">
{formatDateTime(log.requestedAt)}
</td>
<td className="px-4 py-3 text-[var(--color-text-primary)]">{log.serviceName || '-'}</td>
<td className="px-4 py-3">
<Badge className={METHOD_CLASS[log.requestMethod]}>
{log.requestMethod}
</Badge>
</td>
<td className="px-4 py-3 text-[var(--color-text-secondary)] truncate max-w-[250px]" title={log.requestUrl}>
{log.requestUrl}
</td>
<td className="px-4 py-3 text-[var(--color-text-primary)]">
{log.responseStatus != null ? log.responseStatus : '-'}
</td>
<td className="px-4 py-3 text-[var(--color-text-primary)]">
{log.responseTime != null ? log.responseTime : '-'}
</td>
<td className="px-4 py-3">
<Badge variant={STATUS_VARIANT[log.requestStatus] ?? 'default'}>
{log.requestStatus}
</Badge>
</td>
<td className="px-4 py-3 font-mono text-xs text-[var(--color-text-tertiary)]">{log.requestIp}</td>
</tr>
))
) : (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-[var(--color-text-tertiary)]">
</td>
</tr>
)}
</tbody>
</table>
)}
</div>
{/* Pagination */}
{result && result.totalElements > 0 && (
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--color-text-secondary)]">
{result.totalElements} / {result.page + 1} / {result.totalPages}
</span>
<div className="flex gap-2">
<Button
onClick={handlePrev}
disabled={currentPage === 0}
variant="outline"
>
</Button>
<Button
onClick={handleNext}
disabled={!result || currentPage >= result.totalPages - 1}
variant="outline"
>
</Button>
</div>
</div>
)}
</div>
);
};
export default RequestLogsPage;