/** * Mandu Kitchen DevTools - Network Panel * @version 1.0.3 */ import React, { useMemo, useState } from 'react'; import type { NetworkRequest } from '../../../types'; import { colors, typography, spacing, borderRadius, animation } from '../../../design-tokens'; // ============================================================================ // Styles // ============================================================================ const styles = { container: { padding: spacing.md, height: '100%', display: 'flex', flexDirection: 'column' as const, gap: spacing.md, }, header: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: spacing.md, }, filterGroup: { display: 'flex', gap: spacing.xs, }, filterButton: { padding: `${spacing.xs} ${spacing.sm}`, borderRadius: borderRadius.sm, border: 'none', backgroundColor: colors.background.light, color: colors.text.secondary, fontSize: typography.fontSize.xs, cursor: 'pointer', transition: `all ${animation.duration.fast}`, }, filterButtonActive: { backgroundColor: colors.brand.accent, color: colors.background.dark, }, stats: { display: 'flex', gap: spacing.md, fontSize: typography.fontSize.xs, color: colors.text.muted, }, list: { flex: 1, overflow: 'auto', display: 'flex', flexDirection: 'column' as const, gap: spacing.xs, }, emptyState: { flex: 1, display: 'flex', flexDirection: 'column' as const, alignItems: 'center', justifyContent: 'center', gap: spacing.md, padding: spacing.xl, color: colors.text.muted, fontSize: typography.fontSize.sm, textAlign: 'center' as const, }, requestItem: { display: 'flex', alignItems: 'center', gap: spacing.sm, padding: spacing.sm, borderRadius: borderRadius.md, backgroundColor: colors.background.medium, transition: `all ${animation.duration.fast}`, cursor: 'pointer', }, method: { padding: `2px ${spacing.xs}`, borderRadius: borderRadius.sm, fontSize: typography.fontSize.xs, fontWeight: typography.fontWeight.semibold, fontFamily: typography.fontFamily.mono, minWidth: '50px', textAlign: 'center' as const, }, url: { flex: 1, fontSize: typography.fontSize.sm, color: colors.text.primary, fontFamily: typography.fontFamily.mono, whiteSpace: 'nowrap' as const, overflow: 'hidden', textOverflow: 'ellipsis', }, status: { padding: `2px ${spacing.xs}`, borderRadius: borderRadius.sm, fontSize: typography.fontSize.xs, fontWeight: typography.fontWeight.medium, minWidth: '36px', textAlign: 'center' as const, }, timing: { fontSize: typography.fontSize.xs, color: colors.text.muted, fontFamily: typography.fontFamily.mono, minWidth: '50px', textAlign: 'right' as const, }, streamingBadge: { padding: `2px ${spacing.xs}`, borderRadius: borderRadius.sm, backgroundColor: `${colors.semantic.info}20`, color: colors.semantic.info, fontSize: typography.fontSize.xs, fontWeight: typography.fontWeight.medium, }, }; const methodColors: Record = { GET: { bg: `${colors.semantic.success}20`, color: colors.semantic.success }, POST: { bg: `${colors.semantic.info}20`, color: colors.semantic.info }, PUT: { bg: `${colors.semantic.warning}20`, color: colors.semantic.warning }, PATCH: { bg: `${colors.semantic.warning}20`, color: colors.semantic.warning }, DELETE: { bg: `${colors.semantic.error}20`, color: colors.semantic.error }, }; type FilterType = 'all' | 'fetch' | 'sse' | 'error'; // ============================================================================ // Props // ============================================================================ export interface NetworkPanelProps { requests: NetworkRequest[]; } // ============================================================================ // Component // ============================================================================ export function NetworkPanel({ requests }: NetworkPanelProps): React.ReactElement { const [filter, setFilter] = useState('all'); // Filter requests const filteredRequests = useMemo(() => { return requests.filter((req) => { switch (filter) { case 'fetch': return !req.isStreaming; case 'sse': return req.isStreaming; case 'error': return req.status && req.status >= 400; default: return true; } }); }, [requests, filter]); // Stats const stats = useMemo(() => { const total = requests.length; const streaming = requests.filter((r) => r.isStreaming).length; const errors = requests.filter((r) => r.status && r.status >= 400).length; const totalTime = requests.reduce((sum, r) => { if (r.startTime && r.endTime) { return sum + (r.endTime - r.startTime); } return sum; }, 0); return { total, streaming, errors, totalTime }; }, [requests]); const getStatusStyle = (status?: number): React.CSSProperties => { if (!status) { return { backgroundColor: colors.background.light, color: colors.text.muted }; } if (status >= 500) { return { backgroundColor: `${colors.semantic.error}20`, color: colors.semantic.error }; } if (status >= 400) { return { backgroundColor: `${colors.semantic.warning}20`, color: colors.semantic.warning }; } if (status >= 300) { return { backgroundColor: `${colors.semantic.info}20`, color: colors.semantic.info }; } return { backgroundColor: `${colors.semantic.success}20`, color: colors.semantic.success }; }; const formatUrl = (url: string): string => { try { const parsed = new URL(url); return parsed.pathname + parsed.search; } catch { return url; } }; const formatDuration = (start: number, end?: number): string => { if (!end) return '...'; const duration = end - start; if (duration < 1000) return `${duration.toFixed(0)}ms`; return `${(duration / 1000).toFixed(1)}s`; }; if (requests.length === 0) { return (

아직 네트워크 요청이 없습니다.
요청이 발생하면 여기에 기록됩니다.

); } return (
{/* Header */}
{(['all', 'fetch', 'sse', 'error'] as FilterType[]).map((f) => ( ))}
총 시간: {(stats.totalTime / 1000).toFixed(1)}s
{/* Request List */}
{filteredRequests.map((request) => { const methodStyle = methodColors[request.method] ?? methodColors.GET; return (
{request.method} {formatUrl(request.url)} {request.isStreaming && ( SSE {request.chunkCount && `(${request.chunkCount})`} )} {request.status ?? '...'} {formatDuration(request.startTime, request.endTime)}
); })}
); }