import { useEffect, useMemo, useRef, useState } from 'react'; import { FileText, RotateCcw, Search, TerminalSquare } from 'lucide-react'; import { Badge, Button, Card } from '../../components/ui'; import { PageTitle } from '../../layout/AppLayout'; import { formatDateTime } from '../../lib/date-format'; import { runtimeStatusDotClass, statusDotToneClass } from '../../lib/status-dot'; import { cn } from '../../lib/utils'; import type { RuntimeEvent, RuntimePodMetric, RuntimeResource, RuntimeResourceResponse } from '../../types/runtime'; import { LoadingModal, useMinimumLoading } from '../operations/operation-utils'; import { loadRuntimeEvents, loadRuntimePodMetrics, loadRuntimeResources, loadRuntimeWorkloadLogs } from '../runtime/runtime-api'; import { COMMON_APP_NAMESPACE, commonAppComponents, type CommonAppComponent } from './common-app-utils'; function apiKind(kind: string) { return kind.toLowerCase(); } function stripAnsi(value: string) { return value.replace(/(?:\x1B|\u241B)\[[0-?]*[ -/]*[@-~]/g, ''); } function eventTone(type?: string) { if (type === 'Normal') return 'green'; if (type === 'Warning') return 'amber'; return 'neutral'; } const eventRanges = [ { label: '1시간', value: '1h', milliseconds: 60 * 60 * 1000 }, { label: '24시간', value: '24h', milliseconds: 24 * 60 * 60 * 1000 }, { label: '전체', value: 'all', milliseconds: null } ]; function eventTime(event: RuntimeEvent) { const value = event.lastTimestamp ?? event.firstTimestamp; if (!value) return 0; const time = new Date(value).getTime(); return Number.isFinite(time) ? time : 0; } function isEventInRange(event: RuntimeEvent, range: (typeof eventRanges)[number]) { if (!range.milliseconds) return true; const time = eventTime(event); return time > 0 && time >= Date.now() - range.milliseconds; } function metricKey(namespace: string, pod: string) { return `${namespace}/${pod}`; } function isLoggable(resource: RuntimeResource) { return ['Pod', 'Deployment', 'StatefulSet', 'DaemonSet', 'Job'].includes(resource.kind); } function parseReadyCount(ready?: string) { const [current, desired] = (ready ?? '').split('/').map((value) => Number(value)); return { current: Number.isFinite(current) ? current : 0, desired: Number.isFinite(desired) ? desired : 0 }; } function hasReadyReplica(resource: RuntimeResource) { const ready = parseReadyCount(resource.ready); return ready.current > 0 && (ready.desired === 0 || ready.current <= ready.desired); } function isActiveLogTarget(resource: RuntimeResource) { if (!isLoggable(resource)) return false; if (resource.kind === 'Job') return resource.status === 'Running'; if (resource.kind === 'Pod') return ['Running', 'Pending', 'ContainerCreating'].includes(resource.status); return resource.status === 'Running' && hasReadyReplica(resource); } function relatedToComponent(resource: RuntimeResource, component: CommonAppComponent) { const names = [component.workloadName, component.serviceName].filter(Boolean) as string[]; return names.some((name) => resource.name === name || resource.name.includes(component.workloadName)); } function workloadStatusDot(resource: RuntimeResource) { if (resource.kind === 'Pod') return runtimeStatusDotClass(resource.status); if (resource.kind === 'Job') return resource.status === 'Running' ? statusDotToneClass('green') : statusDotToneClass('neutral'); if (hasReadyReplica(resource) && resource.status === 'Running') return statusDotToneClass('green'); if (resource.status === 'Pending') return statusDotToneClass('amber'); if (['Error', 'CrashLoopBackOff', 'ImagePullBackOff', 'Failed'].includes(resource.status)) return statusDotToneClass('red'); return statusDotToneClass('neutral'); } function parseCpuMillicores(value?: string) { if (!value) return 0; if (value.endsWith('m')) return Number(value.replace('m', '')) || 0; return (Number(value) || 0) * 1000; } function parseMemoryMi(value?: string) { if (!value) return 0; const numeric = Number(value.replace(/[^\d.]/g, '')) || 0; if (value.endsWith('Gi')) return numeric * 1024; if (value.endsWith('Ki')) return numeric / 1024; if (value.endsWith('Mi')) return numeric; return numeric / 1024 / 1024; } function usagePercent(value: number, max: number) { return Math.max(4, Math.min(100, Math.round((value / max) * 100))); } function UsageBars({ metric, compact = false }: { metric?: RuntimePodMetric; compact?: boolean }) { if (!metric) return 사용량 -; const cpuPercent = usagePercent(parseCpuMillicores(metric.cpu), 1000); const memoryPercent = usagePercent(parseMemoryMi(metric.memory), 512); return (
CPU {metric.cpu}
메모리 {metric.memory}
); } export function CommonAppLogsPage() { const [componentKey, setComponentKey] = useState(commonAppComponents[0]?.key ?? ''); const [data, setData] = useState(null); const [podMetrics, setPodMetrics] = useState([]); const [events, setEvents] = useState([]); const [selectedResource, setSelectedResource] = useState(null); const [logs, setLogs] = useState(''); const [tail, setTail] = useState(100); const [activeTab, setActiveTab] = useState<'logs' | 'events'>('logs'); const [search, setSearch] = useState(''); const [eventRange, setEventRange] = useState('1h'); const [loading, setLoading] = useState(true); const [logLoading, setLogLoading] = useState(false); const [error, setError] = useState(''); const logContainerRef = useRef(null); const loadingVisible = useMinimumLoading(loading || logLoading); const selectedComponent = commonAppComponents.find((component) => component.key === componentKey) ?? commonAppComponents[0]; const resources = data?.resources ?? []; const activeNamespace = data?.namespace || COMMON_APP_NAMESPACE; const metricByPod = useMemo(() => { const map = new Map(); podMetrics.forEach((metric) => map.set(metricKey(metric.namespace, metric.pod), metric)); return map; }, [podMetrics]); const componentResources = selectedComponent ? resources.filter((resource) => relatedToComponent(resource, selectedComponent)) : []; const loggableResources = componentResources.filter(isActiveLogTarget); const selectedMetric = selectedResource?.kind === 'Pod' ? metricByPod.get(metricKey(activeNamespace, selectedResource.name)) : undefined; const selectedEventRange = eventRanges.find((range) => range.value === eventRange) ?? eventRanges[0]; const filteredEvents = events.filter((event) => { if (!isEventInRange(event, selectedEventRange)) return false; const normalizedSearch = search.trim().toLowerCase(); const componentMatch = selectedComponent ? `${event.involvedObject ?? ''} ${event.message ?? ''}`.toLowerCase().includes(selectedComponent.workloadName.toLowerCase()) : true; if (!componentMatch) return false; if (!normalizedSearch) return true; return `${event.type ?? ''} ${event.reason ?? ''} ${event.message ?? ''} ${event.involvedObject ?? ''}`.toLowerCase().includes(normalizedSearch); }); useEffect(() => { if (activeTab !== 'logs') return; const frame = window.requestAnimationFrame(() => { const container = logContainerRef.current; if (container) container.scrollTop = container.scrollHeight; }); return () => window.cancelAnimationFrame(frame); }, [activeTab, logs, logLoading, selectedResource?.name]); const loadLogs = (resource: RuntimeResource, nextTail = tail) => { setSelectedResource(resource); setLogLoading(true); setError(''); loadRuntimeWorkloadLogs(COMMON_APP_NAMESPACE, apiKind(resource.kind), resource.name, nextTail) .then((result) => setLogs(result.logs ? stripAnsi(result.logs) : '로그가 없습니다.')) .catch((logError: Error) => { setLogs(''); setError(logError.message); }) .finally(() => setLogLoading(false)); }; const refresh = () => { setLoading(true); setError(''); Promise.all([ loadRuntimeResources(COMMON_APP_NAMESPACE), loadRuntimeEvents(COMMON_APP_NAMESPACE), loadRuntimePodMetrics(COMMON_APP_NAMESPACE).catch(() => []) ]) .then(([nextData, nextEvents, nextMetrics]) => { setData(nextData); setEvents(nextEvents); setPodMetrics(nextMetrics); setSelectedResource(null); setLogs(''); }) .catch((refreshError: Error) => setError(refreshError.message)) .finally(() => setLoading(false)); }; useEffect(() => { refresh(); }, []); useEffect(() => { if (activeTab !== 'events') return; let cancelled = false; const loadEvents = () => { loadRuntimeEvents(COMMON_APP_NAMESPACE) .then((nextEvents) => { if (!cancelled) setEvents(nextEvents); }) .catch(() => undefined); }; loadEvents(); const timer = window.setInterval(loadEvents, 10_000); return () => { cancelled = true; window.clearInterval(timer); }; }, [activeTab]); return ( <> 새로고침 )} /> {error ?
{error}
: null}

조회 기준

공통앱 구성요소와 네임스페이스 기준으로 로그와 이벤트를 조회합니다.

네임스페이스 {COMMON_APP_NAMESPACE}

워크로드

실행 중인 로그 대상을 선택합니다.

총 {loggableResources.length}개
{loggableResources.length === 0 ? (
실행 중인 로그 대상이 없습니다.
) : loggableResources.map((resource) => { const selected = selectedResource?.kind === resource.kind && selectedResource?.name === resource.name; const metric = resource.kind === 'Pod' ? metricByPod.get(metricKey(activeNamespace, resource.name)) : undefined; return ( ); })}
{activeTab === 'logs' ? (
) : (
{eventRanges.map((range) => ( ))}
setSearch(event.target.value)} />
)}
{activeTab === 'logs' ? (
대상 {selectedResource ? `${selectedResource.kind}/${selectedResource.name}` : '-'}
{selectedResource ? (

Ready

{selectedResource.ready}

재시작

{selectedResource.restarts ?? 0}

) : null}
{selectedResource ? (
                    {logLoading ? '로그를 불러오는 중입니다...' : logs || '로그가 없습니다.'}
                  
) : (
왼쪽에서 워크로드를 선택하면 로그가 표시됩니다.
)}
) : (
{filteredEvents.length === 0 ? ( ) : null} {filteredEvents.map((event, index) => ( ))}
Type Reason Object Message Count Last
표시할 이벤트가 없습니다.
{event.type ?? '-'} {event.reason ?? '-'} {event.involvedObject ?? '-'} {event.message ?? '-'} {event.count ?? '-'} {formatDateTime(event.lastTimestamp || event.firstTimestamp)}
)}
); }