- RescueView: CenterMap을 MapView(useBaseMapStyle) 기반 OSM 지도로 교체 - RescueScenarioView: BASE_STYLE → useBaseMapStyle로 전환하여 OSM 통일 - 긴급구난 시나리오 시드 데이터 10건으로 확장 (모델 이론 기반) - 관리자 비식별화조치 R&D 패널 5종 추가 (HNS대기, KOSPS, POSEIDON, Rescue, 시스템아키텍처) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
639 lines
22 KiB
TypeScript
639 lines
22 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
|
|
|
// ─── 타입 ──────────────────────────────────────────────────────────────────────
|
|
|
|
type PipelineStatus = '정상' | '지연' | '중단';
|
|
type ReceiveStatus = '수신완료' | '수신대기' | '수신실패' | '시간초과';
|
|
type ProcessStatus = '처리완료' | '처리중' | '대기' | '오류';
|
|
type DataSource = 'HYCOM' | '기상청' | 'KOSPS DLL';
|
|
type AlertLevel = '경고' | '주의' | '정보';
|
|
|
|
interface PipelineNode {
|
|
id: string;
|
|
name: string;
|
|
status: PipelineStatus;
|
|
lastReceived: string;
|
|
cycle: string;
|
|
}
|
|
|
|
interface DataLogRow {
|
|
id: string;
|
|
timestamp: string;
|
|
source: DataSource;
|
|
dataType: string;
|
|
size: string;
|
|
receiveStatus: ReceiveStatus;
|
|
processStatus: ProcessStatus;
|
|
}
|
|
|
|
interface AlertItem {
|
|
id: string;
|
|
level: AlertLevel;
|
|
message: string;
|
|
timestamp: string;
|
|
}
|
|
|
|
// ─── Mock 데이터 ────────────────────────────────────────────────────────────────
|
|
|
|
const MOCK_PIPELINE: PipelineNode[] = [
|
|
{
|
|
id: 'hycom',
|
|
name: 'HYCOM 해양순환모델',
|
|
status: '정상',
|
|
lastReceived: '2026-04-11 06:00',
|
|
cycle: '6시간 주기',
|
|
},
|
|
{
|
|
id: 'kma',
|
|
name: '기상청 수치모델',
|
|
status: '정상',
|
|
lastReceived: '2026-04-11 06:00',
|
|
cycle: '3시간 주기',
|
|
},
|
|
{
|
|
id: 'kosps-server',
|
|
name: '광주 KOSPS 서버',
|
|
status: '정상',
|
|
lastReceived: '2026-04-11 06:05',
|
|
cycle: '수신 즉시',
|
|
},
|
|
{
|
|
id: 'fortran-dll',
|
|
name: 'KOSPS Fortran DLL 연산',
|
|
status: '지연',
|
|
lastReceived: '2026-04-11 05:45',
|
|
cycle: '예측 시작 즉시',
|
|
},
|
|
{
|
|
id: 'result-api',
|
|
name: '결과 수신 API',
|
|
status: '정상',
|
|
lastReceived: '2026-04-11 06:10',
|
|
cycle: '예측 완료 즉시',
|
|
},
|
|
];
|
|
|
|
const MOCK_LOGS: DataLogRow[] = [
|
|
{
|
|
id: 'log-01',
|
|
timestamp: '2026-04-11 06:10',
|
|
source: 'KOSPS DLL',
|
|
dataType: 'DLL 응답 결과',
|
|
size: '28 MB',
|
|
receiveStatus: '수신완료',
|
|
processStatus: '처리완료',
|
|
},
|
|
{
|
|
id: 'log-02',
|
|
timestamp: '2026-04-11 06:05',
|
|
source: '기상청',
|
|
dataType: '풍향/풍속',
|
|
size: '38 MB',
|
|
receiveStatus: '수신완료',
|
|
processStatus: '처리완료',
|
|
},
|
|
{
|
|
id: 'log-03',
|
|
timestamp: '2026-04-11 06:05',
|
|
source: '기상청',
|
|
dataType: '기압',
|
|
size: '22 MB',
|
|
receiveStatus: '수신완료',
|
|
processStatus: '처리완료',
|
|
},
|
|
{
|
|
id: 'log-04',
|
|
timestamp: '2026-04-11 06:00',
|
|
source: 'HYCOM',
|
|
dataType: '해수면온도(SST)',
|
|
size: '98 MB',
|
|
receiveStatus: '수신완료',
|
|
processStatus: '처리완료',
|
|
},
|
|
{
|
|
id: 'log-05',
|
|
timestamp: '2026-04-11 06:00',
|
|
source: 'HYCOM',
|
|
dataType: '해류(U/V)',
|
|
size: '142 MB',
|
|
receiveStatus: '수신완료',
|
|
processStatus: '처리완료',
|
|
},
|
|
{
|
|
id: 'log-06',
|
|
timestamp: '2026-04-11 06:00',
|
|
source: '기상청',
|
|
dataType: '기온',
|
|
size: '19 MB',
|
|
receiveStatus: '수신완료',
|
|
processStatus: '처리완료',
|
|
},
|
|
{
|
|
id: 'log-07',
|
|
timestamp: '2026-04-11 05:55',
|
|
source: 'KOSPS DLL',
|
|
dataType: 'DLL 호출 요청',
|
|
size: '3 MB',
|
|
receiveStatus: '수신완료',
|
|
processStatus: '처리중',
|
|
},
|
|
{
|
|
id: 'log-08',
|
|
timestamp: '2026-04-11 05:45',
|
|
source: 'KOSPS DLL',
|
|
dataType: 'DLL 응답 결과',
|
|
size: '-',
|
|
receiveStatus: '수신대기',
|
|
processStatus: '대기',
|
|
},
|
|
{
|
|
id: 'log-09',
|
|
timestamp: '2026-04-11 03:10',
|
|
source: 'HYCOM',
|
|
dataType: '해류(U/V)',
|
|
size: '140 MB',
|
|
receiveStatus: '수신완료',
|
|
processStatus: '처리완료',
|
|
},
|
|
{
|
|
id: 'log-10',
|
|
timestamp: '2026-04-11 03:05',
|
|
source: '기상청',
|
|
dataType: '풍향/풍속',
|
|
size: '37 MB',
|
|
receiveStatus: '수신완료',
|
|
processStatus: '처리완료',
|
|
},
|
|
{
|
|
id: 'log-11',
|
|
timestamp: '2026-04-11 03:00',
|
|
source: 'HYCOM',
|
|
dataType: '해수면높이(SSH)',
|
|
size: '54 MB',
|
|
receiveStatus: '수신완료',
|
|
processStatus: '처리완료',
|
|
},
|
|
{
|
|
id: 'log-12',
|
|
timestamp: '2026-04-11 03:00',
|
|
source: 'KOSPS DLL',
|
|
dataType: 'DLL 응답 결과',
|
|
size: '27 MB',
|
|
receiveStatus: '수신완료',
|
|
processStatus: '처리완료',
|
|
},
|
|
{
|
|
id: 'log-13',
|
|
timestamp: '2026-04-11 00:05',
|
|
source: '기상청',
|
|
dataType: '기압',
|
|
size: '23 MB',
|
|
receiveStatus: '수신완료',
|
|
processStatus: '처리완료',
|
|
},
|
|
{
|
|
id: 'log-14',
|
|
timestamp: '2026-04-11 00:00',
|
|
source: 'HYCOM',
|
|
dataType: '해수면높이(SSH)',
|
|
size: '53 MB',
|
|
receiveStatus: '시간초과',
|
|
processStatus: '오류',
|
|
},
|
|
{
|
|
id: 'log-15',
|
|
timestamp: '2026-04-11 00:00',
|
|
source: 'KOSPS DLL',
|
|
dataType: 'DLL 호출 요청',
|
|
size: '-',
|
|
receiveStatus: '수신실패',
|
|
processStatus: '오류',
|
|
},
|
|
];
|
|
|
|
const MOCK_ALERTS: AlertItem[] = [
|
|
{
|
|
id: 'alert-01',
|
|
level: '경고',
|
|
message: 'KOSPS Fortran DLL 응답 지연 — 평균 응답시간 초과',
|
|
timestamp: '2026-04-11 05:45',
|
|
},
|
|
{
|
|
id: 'alert-02',
|
|
level: '주의',
|
|
message: 'HYCOM SSH 데이터 다음 수신 예정: 09:00',
|
|
timestamp: '2026-04-11 06:00',
|
|
},
|
|
{
|
|
id: 'alert-03',
|
|
level: '정보',
|
|
message: '금일 KOSPS 예측 완료: 3회 / 6회',
|
|
timestamp: '2026-04-11 06:12',
|
|
},
|
|
];
|
|
|
|
// ─── Mock fetch ─────────────────────────────────────────────────────────────────
|
|
|
|
interface KospsData {
|
|
pipeline: PipelineNode[];
|
|
logs: DataLogRow[];
|
|
alerts: AlertItem[];
|
|
}
|
|
|
|
function fetchKospsData(): Promise<KospsData> {
|
|
return new Promise((resolve) => {
|
|
setTimeout(
|
|
() =>
|
|
resolve({
|
|
pipeline: MOCK_PIPELINE,
|
|
logs: MOCK_LOGS,
|
|
alerts: MOCK_ALERTS,
|
|
}),
|
|
300,
|
|
);
|
|
});
|
|
}
|
|
|
|
// ─── 유틸 ───────────────────────────────────────────────────────────────────────
|
|
|
|
function getPipelineStatusStyle(status: PipelineStatus): string {
|
|
if (status === '정상') return 'text-emerald-400 bg-emerald-500/10';
|
|
if (status === '지연') return 'text-yellow-400 bg-yellow-500/10';
|
|
return 'text-red-400 bg-red-500/10';
|
|
}
|
|
|
|
function getPipelineBorderStyle(status: PipelineStatus): string {
|
|
if (status === '정상') return 'border-l-emerald-500';
|
|
if (status === '지연') return 'border-l-yellow-500';
|
|
return 'border-l-red-500';
|
|
}
|
|
|
|
function getReceiveStatusStyle(status: ReceiveStatus): string {
|
|
if (status === '수신완료') return 'text-emerald-400 bg-emerald-500/10';
|
|
if (status === '수신대기') return 'text-yellow-400 bg-yellow-500/10';
|
|
return 'text-red-400 bg-red-500/10';
|
|
}
|
|
|
|
function getProcessStatusStyle(status: ProcessStatus): string {
|
|
if (status === '처리완료') return 'text-emerald-400 bg-emerald-500/10';
|
|
if (status === '처리중') return 'text-cyan-400 bg-cyan-500/10';
|
|
if (status === '대기') return 'text-yellow-400 bg-yellow-500/10';
|
|
return 'text-red-400 bg-red-500/10';
|
|
}
|
|
|
|
function getAlertStyle(level: AlertLevel): string {
|
|
if (level === '경고') return 'text-red-400 bg-red-500/10 border-red-500/30';
|
|
if (level === '주의') return 'text-yellow-400 bg-yellow-500/10 border-yellow-500/30';
|
|
return 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30';
|
|
}
|
|
|
|
// ─── 파이프라인 카드 ─────────────────────────────────────────────────────────────
|
|
|
|
function PipelineCard({ node }: { node: PipelineNode }) {
|
|
const badgeStyle = getPipelineStatusStyle(node.status);
|
|
const borderStyle = getPipelineBorderStyle(node.status);
|
|
|
|
return (
|
|
<div
|
|
className={`flex-1 min-w-0 bg-bg-card rounded border border-stroke-1 border-l-2 ${borderStyle} p-3 flex flex-col gap-1.5`}
|
|
>
|
|
<div className="text-xs font-medium text-t1 leading-snug">{node.name}</div>
|
|
<span
|
|
className={`self-start inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${badgeStyle}`}
|
|
>
|
|
{node.status}
|
|
</span>
|
|
<div className="text-label-2 text-t3 mt-0.5">최근 수신: {node.lastReceived}</div>
|
|
<div className="text-label-2 text-t3">{node.cycle}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: boolean }) {
|
|
if (loading && nodes.length === 0) {
|
|
return (
|
|
<div className="flex items-center gap-1 animate-pulse">
|
|
{Array.from({ length: 5 }).map((_, i) => (
|
|
<div key={i} className="flex items-center gap-1">
|
|
<div className="flex-1 h-16 bg-bg-elevated rounded w-28" />
|
|
{i < 4 && <span className="text-t3 text-sm px-0.5">→</span>}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex items-stretch gap-1">
|
|
{nodes.map((node, idx) => (
|
|
<div key={node.id} className="flex items-center gap-1 flex-1 min-w-0">
|
|
<PipelineCard node={node} />
|
|
{idx < nodes.length - 1 && (
|
|
<span className="text-t3 text-sm shrink-0 px-0.5">→</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 수신 이력 테이블 ────────────────────────────────────────────────────────────
|
|
|
|
type FilterSource = 'all' | DataSource;
|
|
type FilterReceive = 'all' | ReceiveStatus;
|
|
type FilterPeriod = '6h' | '12h' | '24h';
|
|
|
|
const PERIOD_HOURS: Record<FilterPeriod, number> = { '6h': 6, '12h': 12, '24h': 24 };
|
|
|
|
function filterLogs(
|
|
rows: DataLogRow[],
|
|
source: FilterSource,
|
|
receive: FilterReceive,
|
|
period: FilterPeriod,
|
|
): DataLogRow[] {
|
|
const cutoff = new Date('2026-04-11T06:30:00');
|
|
const hours = PERIOD_HOURS[period];
|
|
const from = new Date(cutoff.getTime() - hours * 60 * 60 * 1000);
|
|
|
|
return rows.filter((r) => {
|
|
if (source !== 'all' && r.source !== source) return false;
|
|
if (receive !== 'all' && r.receiveStatus !== receive) return false;
|
|
const ts = new Date(r.timestamp.replace(' ', 'T'));
|
|
if (ts < from) return false;
|
|
return true;
|
|
});
|
|
}
|
|
|
|
const LOG_HEADERS = ['시간', '데이터소스', '데이터종류', '크기', '수신상태', '처리상태'];
|
|
|
|
function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean }) {
|
|
return (
|
|
<div className="overflow-auto">
|
|
<table className="w-full text-xs border-collapse">
|
|
<thead>
|
|
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
|
{LOG_HEADERS.map((h) => (
|
|
<th
|
|
key={h}
|
|
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
|
|
>
|
|
{h}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{loading && rows.length === 0
|
|
? Array.from({ length: 8 }).map((_, i) => (
|
|
<tr key={i} className="border-b border-stroke-1 animate-pulse">
|
|
{LOG_HEADERS.map((_, j) => (
|
|
<td key={j} className="px-3 py-2">
|
|
<div className="h-3 bg-bg-elevated rounded w-16" />
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))
|
|
: rows.map((row) => (
|
|
<tr key={row.id} className="border-b border-stroke-1 hover:bg-bg-surface/50">
|
|
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">
|
|
{row.timestamp}
|
|
</td>
|
|
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.source}</td>
|
|
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.dataType}</td>
|
|
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.size}</td>
|
|
<td className="px-3 py-2">
|
|
<span
|
|
className={`inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${getReceiveStatusStyle(row.receiveStatus)}`}
|
|
>
|
|
{row.receiveStatus}
|
|
</span>
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
<span
|
|
className={`inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${getProcessStatusStyle(row.processStatus)}`}
|
|
>
|
|
{row.processStatus}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{!loading && rows.length === 0 && (
|
|
<tr>
|
|
<td colSpan={6} className="px-3 py-8 text-center text-t3">
|
|
조회된 데이터가 없습니다.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 알림 목록 ───────────────────────────────────────────────────────────────────
|
|
|
|
function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean }) {
|
|
if (loading && alerts.length === 0) {
|
|
return (
|
|
<div className="flex flex-col gap-2 animate-pulse">
|
|
{Array.from({ length: 3 }).map((_, i) => (
|
|
<div key={i} className="h-8 bg-bg-elevated rounded" />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (alerts.length === 0) {
|
|
return <p className="text-xs text-t3 py-2">활성 알림이 없습니다.</p>;
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-1.5">
|
|
{alerts.map((alert) => (
|
|
<div
|
|
key={alert.id}
|
|
className={`flex items-start gap-2 px-3 py-2 rounded border text-xs ${getAlertStyle(alert.level)}`}
|
|
>
|
|
<span className="font-semibold shrink-0">[{alert.level}]</span>
|
|
<span className="flex-1">{alert.message}</span>
|
|
<span className="shrink-0 opacity-70 font-mono">{alert.timestamp}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 메인 패널 ───────────────────────────────────────────────────────────────────
|
|
|
|
export default function RndKospsPanel() {
|
|
const [pipeline, setPipeline] = useState<PipelineNode[]>([]);
|
|
const [logs, setLogs] = useState<DataLogRow[]>([]);
|
|
const [alerts, setAlerts] = useState<AlertItem[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
|
|
|
|
// 필터
|
|
const [filterSource, setFilterSource] = useState<FilterSource>('all');
|
|
const [filterReceive, setFilterReceive] = useState<FilterReceive>('all');
|
|
const [filterPeriod, setFilterPeriod] = useState<FilterPeriod>('24h');
|
|
|
|
const fetchData = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const data = await fetchKospsData();
|
|
setPipeline(data.pipeline);
|
|
setLogs(data.logs);
|
|
setAlerts(data.alerts);
|
|
setLastUpdate(new Date());
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
let isMounted = true;
|
|
void Promise.resolve().then(() => {
|
|
if (isMounted) void fetchData();
|
|
});
|
|
return () => {
|
|
isMounted = false;
|
|
};
|
|
}, [fetchData]);
|
|
|
|
const filteredLogs = filterLogs(logs, filterSource, filterReceive, filterPeriod);
|
|
|
|
const totalReceived = logs.filter((r) => r.receiveStatus === '수신완료').length;
|
|
const totalDelayed = logs.filter((r) => r.receiveStatus === '수신대기').length;
|
|
const totalFailed = logs.filter(
|
|
(r) => r.receiveStatus === '수신실패' || r.receiveStatus === '시간초과',
|
|
).length;
|
|
|
|
return (
|
|
<div className="flex flex-col h-full overflow-hidden">
|
|
{/* ── 헤더 ── */}
|
|
<div className="shrink-0 border-b border-stroke-1">
|
|
<div className="flex items-center justify-between px-5 py-3">
|
|
<h2 className="text-sm font-semibold text-t1">유출유확산예측 (KOSPS) 연계 모니터링</h2>
|
|
<div className="flex items-center gap-3">
|
|
{lastUpdate && (
|
|
<span className="text-xs text-t3">
|
|
갱신:{' '}
|
|
{lastUpdate.toLocaleTimeString('ko-KR', {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
})}
|
|
</span>
|
|
)}
|
|
<button
|
|
onClick={() => void fetchData()}
|
|
disabled={loading}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
<svg
|
|
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
|
/>
|
|
</svg>
|
|
새로고침
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{/* 요약 통계 바 */}
|
|
<div className="flex items-center gap-4 px-5 py-2 bg-bg-base text-xs text-t3 border-t border-stroke-1">
|
|
<span>
|
|
정상 수신:{' '}
|
|
<span className="text-emerald-400 font-medium">{totalReceived}건</span>
|
|
</span>
|
|
<span className="text-stroke-1">|</span>
|
|
<span>
|
|
지연: <span className="text-yellow-400 font-medium">{totalDelayed}건</span>
|
|
</span>
|
|
<span className="text-stroke-1">|</span>
|
|
<span>
|
|
실패: <span className="text-red-400 font-medium">{totalFailed}건</span>
|
|
</span>
|
|
<span className="text-stroke-1">|</span>
|
|
<span>
|
|
금일 예측 완료:{' '}
|
|
<span className="text-cyan-400 font-medium">3 / 6회</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── 스크롤 영역 ── */}
|
|
<div className="flex-1 overflow-auto">
|
|
{/* 파이프라인 현황 */}
|
|
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
|
|
<h3 className="text-xs font-semibold text-t2 mb-3 uppercase tracking-wide">
|
|
데이터 파이프라인 현황
|
|
</h3>
|
|
<PipelineFlow nodes={pipeline} loading={loading} />
|
|
</section>
|
|
|
|
{/* 필터 바 + 수신 이력 테이블 */}
|
|
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
|
|
<div className="flex items-center justify-between mb-3 gap-3 flex-wrap">
|
|
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide shrink-0">
|
|
데이터 수신 이력
|
|
</h3>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
{/* 데이터소스 필터 */}
|
|
<select
|
|
value={filterSource}
|
|
onChange={(e) => setFilterSource(e.target.value as FilterSource)}
|
|
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
|
|
>
|
|
<option value="all">모두 (소스)</option>
|
|
<option value="HYCOM">HYCOM</option>
|
|
<option value="기상청">기상청</option>
|
|
<option value="KOSPS DLL">KOSPS DLL</option>
|
|
</select>
|
|
{/* 수신상태 필터 */}
|
|
<select
|
|
value={filterReceive}
|
|
onChange={(e) => setFilterReceive(e.target.value as FilterReceive)}
|
|
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
|
|
>
|
|
<option value="all">모두 (상태)</option>
|
|
<option value="수신완료">수신완료</option>
|
|
<option value="수신대기">수신대기</option>
|
|
<option value="수신실패">수신실패</option>
|
|
<option value="시간초과">시간초과</option>
|
|
</select>
|
|
{/* 기간 필터 */}
|
|
<select
|
|
value={filterPeriod}
|
|
onChange={(e) => setFilterPeriod(e.target.value as FilterPeriod)}
|
|
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
|
|
>
|
|
<option value="6h">최근 6시간</option>
|
|
<option value="12h">최근 12시간</option>
|
|
<option value="24h">최근 24시간</option>
|
|
</select>
|
|
<span className="text-xs text-t3">{filteredLogs.length}건</span>
|
|
</div>
|
|
</div>
|
|
<DataLogTable rows={filteredLogs} loading={loading} />
|
|
</section>
|
|
|
|
{/* 알림 현황 */}
|
|
<section className="px-5 pt-4 pb-5">
|
|
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide mb-3">
|
|
알림 현황
|
|
</h3>
|
|
<AlertList alerts={alerts} loading={loading} />
|
|
</section>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|