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 { getStoredSelectedAppId, setStoredSelectedAppId } from '../../lib/selected-app'; import { cn } from '../../lib/utils'; import type { ApiApp, RuntimeEvent, RuntimePodMetric, RuntimeResource, RuntimeResourceResponse } from '../../types/runtime'; import { LoadingModal, useMinimumLoading } from '../operations/operation-utils'; import { loadApps, loadRuntimeEvents, loadRuntimePodMetrics, loadRuntimeResources, loadRuntimeWorkloadLogs } from './runtime-api'; function statusDot(status?: string) { if (['Running', 'Bound', 'ClusterIP', 'LoadBalancer', 'Normal'].includes(status ?? '')) return 'bg-emerald-400'; if (['Pending', 'ContainerCreating', 'Warning'].includes(status ?? '')) return 'bg-amber-400'; if (['Error', 'CrashLoopBackOff', 'ImagePullBackOff', 'Failed'].includes(status ?? '')) return 'bg-rose-400'; return 'bg-muted-foreground'; } function eventTone(type?: string) { if (type === 'Normal') return 'green'; if (type === 'Warning') return 'amber'; return 'neutral'; } function apiKind(kind: string) { return kind.toLowerCase(); } function formatDate(value?: string | null) { return formatDateTime(value); } function stripAnsi(value: string) { return value.replace(/(?:\x1B|\u241B)\[[0-?]*[ -/]*[@-~]/g, ''); } 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 workloadStatusDot(resource: RuntimeResource) { if (resource.kind === 'Pod') return statusDot(resource.status); if (resource.kind === 'Job') return resource.status === 'Running' ? 'bg-emerald-400' : 'bg-muted-foreground'; if (hasReadyReplica(resource) && resource.status === 'Running') return 'bg-emerald-400'; if (resource.status === 'Pending') return 'bg-amber-400'; if (['Error', 'CrashLoopBackOff', 'ImagePullBackOff', 'Failed'].includes(resource.status)) return 'bg-rose-400'; return 'bg-muted-foreground'; } function metricKey(namespace: string, pod: string) { return `${namespace}/${pod}`; } 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 RuntimeLogsPage() { const [apps, setApps] = useState([]); const [appId, setAppId] = useState(''); const [namespace, setNamespace] = useState(''); 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 [loading, setLoading] = useState(true); const [logLoading, setLogLoading] = useState(false); const [error, setError] = useState(''); const logContainerRef = useRef(null); const loadingVisible = useMinimumLoading(loading || logLoading); const namespaceOptions = useMemo( () => [...new Set(apps.map((app) => app.namespace).filter(Boolean) as string[])], [apps] ); const resources = data?.resources ?? []; const activeNamespace = data?.namespace || namespace; const metricByPod = useMemo(() => { const map = new Map(); podMetrics.forEach((metric) => map.set(metricKey(metric.namespace, metric.pod), metric)); return map; }, [podMetrics]); const loggableResources = resources.filter(isActiveLogTarget); const selectedMetric = selectedResource?.kind === 'Pod' ? metricByPod.get(metricKey(activeNamespace, selectedResource.name)) : undefined; const filteredEvents = events.filter((event) => { const normalizedSearch = search.trim().toLowerCase(); 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, targetNamespace = namespace, nextTail = tail) => { setSelectedResource(resource); setLogLoading(true); setError(''); loadRuntimeWorkloadLogs(targetNamespace, 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 = (nextNamespace = namespace) => { if (!nextNamespace.trim()) return; setLoading(true); setError(''); Promise.all([loadRuntimeResources(nextNamespace.trim()), loadRuntimeEvents(nextNamespace.trim()), loadRuntimePodMetrics(nextNamespace.trim()).catch(() => [])]) .then(([nextData, nextEvents, nextMetrics]) => { setData(nextData); setEvents(nextEvents); setPodMetrics(nextMetrics); setSelectedResource(null); setLogs(''); }) .catch((refreshError: Error) => setError(refreshError.message)) .finally(() => setLoading(false)); }; useEffect(() => { setLoading(true); loadApps() .then((nextApps) => { const storedAppId = getStoredSelectedAppId(); const nextAppId = nextApps.some((app) => app.id === storedAppId) ? storedAppId : nextApps[0]?.id ?? ''; const selectedApp = nextApps.find((app) => app.id === nextAppId); const namespaces = [...new Set(nextApps.map((app) => app.namespace).filter(Boolean) as string[])]; const firstNamespace = selectedApp?.namespace || namespaces[0] || 'default'; setApps(nextApps); setAppId(nextAppId); setStoredSelectedAppId(nextAppId); setNamespace(firstNamespace); return Promise.all([loadRuntimeResources(firstNamespace), loadRuntimeEvents(firstNamespace), loadRuntimePodMetrics(firstNamespace).catch(() => [])] as const); }) .then(([nextData, nextEvents, nextMetrics]) => { setData(nextData); setEvents(nextEvents); setPodMetrics(nextMetrics); setSelectedResource(null); setLogs(''); }) .catch((loadError: Error) => setError(loadError.message)) .finally(() => setLoading(false)); }, []); return ( <> refresh()} disabled={loading || !namespace.trim()}> 새로고침 )} /> {error ?
{error}
: null}

조회 기준

앱 그룹과 네임스페이스를 선택해 로그와 이벤트를 조회합니다.

워크로드

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

총 {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' ? (
) : (
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 ?? '-'} {formatDate(event.lastTimestamp || event.firstTimestamp)}
)}
); }