472 lines
22 KiB
TypeScript
472 lines
22 KiB
TypeScript
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 <span className="text-xs text-muted-foreground">사용량 -</span>;
|
|
|
|
const cpuPercent = usagePercent(parseCpuMillicores(metric.cpu), 1000);
|
|
const memoryPercent = usagePercent(parseMemoryMi(metric.memory), 512);
|
|
|
|
return (
|
|
<div className={cn('grid gap-2', compact ? 'w-full grid-cols-2' : 'sm:grid-cols-2')}>
|
|
<div>
|
|
<div className="mb-1 flex justify-between text-xs text-muted-foreground">
|
|
<span>CPU</span>
|
|
<span>{metric.cpu}</span>
|
|
</div>
|
|
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
|
<div className="h-full rounded-full bg-primary" style={{ width: `${cpuPercent}%` }} />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="mb-1 flex justify-between text-xs text-muted-foreground">
|
|
<span>메모리</span>
|
|
<span>{metric.memory}</span>
|
|
</div>
|
|
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
|
<div className="h-full rounded-full bg-emerald-400" style={{ width: `${memoryPercent}%` }} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function CommonAppLogsPage() {
|
|
const [componentKey, setComponentKey] = useState(commonAppComponents[0]?.key ?? '');
|
|
const [data, setData] = useState<RuntimeResourceResponse | null>(null);
|
|
const [podMetrics, setPodMetrics] = useState<RuntimePodMetric[]>([]);
|
|
const [events, setEvents] = useState<RuntimeEvent[]>([]);
|
|
const [selectedResource, setSelectedResource] = useState<RuntimeResource | null>(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<HTMLPreElement | null>(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<string, RuntimePodMetric>();
|
|
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 (
|
|
<>
|
|
<PageTitle
|
|
eyebrow="공통앱"
|
|
title="로그/이벤트"
|
|
description="공통앱 워크로드 로그와 Kubernetes 이벤트를 확인합니다."
|
|
actions={(
|
|
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={refresh} disabled={loading}>
|
|
<RotateCcw className={cn('size-4', loading && 'animate-spin')} />
|
|
새로고침
|
|
</Button>
|
|
)}
|
|
/>
|
|
|
|
{error ? <div className="mb-4 rounded-md border border-rose-500/30 bg-rose-950/30 px-4 py-3 text-sm text-rose-200">{error}</div> : null}
|
|
|
|
<div className="space-y-4">
|
|
<Card className="p-5">
|
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
|
<div>
|
|
<p className="text-sm font-semibold">조회 기준</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">공통앱 구성요소와 네임스페이스 기준으로 로그와 이벤트를 조회합니다.</p>
|
|
</div>
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
|
<label className="flex items-center gap-3">
|
|
<span className="shrink-0 text-xs font-medium text-muted-foreground">구성요소</span>
|
|
<select
|
|
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring sm:w-64"
|
|
value={componentKey}
|
|
onChange={(event) => {
|
|
setComponentKey(event.target.value);
|
|
setSelectedResource(null);
|
|
setLogs('');
|
|
}}
|
|
>
|
|
{commonAppComponents.map((component) => <option key={component.key} value={component.key}>{component.name}</option>)}
|
|
</select>
|
|
</label>
|
|
<div className="rounded-md border border-border bg-background px-3 py-2 text-sm">
|
|
<span className="text-xs text-muted-foreground">네임스페이스</span>
|
|
<span className="ml-3 font-medium">{COMMON_APP_NAMESPACE}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<div className="grid gap-4 xl:grid-cols-[360px_minmax(0,1fr)]">
|
|
<Card className="min-w-0 overflow-hidden">
|
|
<div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
|
|
<div>
|
|
<h2 className="text-sm font-semibold text-foreground">워크로드</h2>
|
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">실행 중인 로그 대상을 선택합니다.</p>
|
|
</div>
|
|
<span className="text-xs text-muted-foreground">총 {loggableResources.length}개</span>
|
|
</div>
|
|
<div className="max-h-[620px] space-y-2 overflow-y-auto p-4">
|
|
{loggableResources.length === 0 ? (
|
|
<div className="flex min-h-40 items-center justify-center text-center text-sm text-muted-foreground">
|
|
실행 중인 로그 대상이 없습니다.
|
|
</div>
|
|
) : 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 (
|
|
<button
|
|
type="button"
|
|
key={`${resource.kind}-${resource.name}`}
|
|
className={cn(
|
|
'w-full rounded-md border px-3 py-3 text-left transition-colors',
|
|
selected ? 'border-border bg-primary/15 shadow-[inset_4px_0_0_var(--color-primary)]' : 'border-border bg-background/70 hover:bg-muted/20'
|
|
)}
|
|
onClick={() => loadLogs(resource)}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span className={cn('size-2.5 shrink-0 rounded-full', workloadStatusDot(resource))} />
|
|
<span className="truncate text-sm font-medium">{resource.name}</span>
|
|
</div>
|
|
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
|
<span>{resource.kind}</span>
|
|
<span>준비 <b className="text-foreground">{resource.ready}</b></span>
|
|
<span>재시작 <b className="text-foreground">{resource.restarts ?? 0}</b></span>
|
|
</div>
|
|
<div className="mt-3">
|
|
<UsageBars metric={metric} compact />
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="min-w-0 overflow-hidden">
|
|
<div className="flex flex-col gap-3 border-b border-border px-5 py-4 lg:flex-row lg:items-center lg:justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
className={cn('inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-[13px] font-medium', activeTab === 'logs' ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:text-foreground')}
|
|
onClick={() => setActiveTab('logs')}
|
|
>
|
|
<TerminalSquare className="size-4" />
|
|
로그
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={cn('inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-[13px] font-medium', activeTab === 'events' ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:text-foreground')}
|
|
onClick={() => setActiveTab('events')}
|
|
>
|
|
<FileText className="size-4" />
|
|
이벤트
|
|
</button>
|
|
</div>
|
|
{activeTab === 'logs' ? (
|
|
<div className="flex items-center gap-2">
|
|
<select
|
|
className="h-8 rounded-md border border-input bg-background px-3 text-xs text-foreground outline-none focus:ring-2 focus:ring-ring"
|
|
value={tail}
|
|
onChange={(event) => {
|
|
const nextTail = Number(event.target.value);
|
|
setTail(nextTail);
|
|
if (selectedResource) loadLogs(selectedResource, nextTail);
|
|
}}
|
|
>
|
|
<option value={100}>최근 100줄</option>
|
|
<option value={300}>최근 300줄</option>
|
|
<option value={1000}>최근 1000줄</option>
|
|
</select>
|
|
<Button variant="outline" className="h-8 px-3 text-[13px]" disabled={!selectedResource || logLoading} onClick={() => selectedResource && loadLogs(selectedResource)}>
|
|
<RotateCcw className={cn('size-4', logLoading && 'animate-spin')} />
|
|
로그 새로고침
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="flex w-full flex-col gap-2 lg:w-auto lg:flex-row lg:items-center">
|
|
<div className="flex flex-wrap gap-2">
|
|
{eventRanges.map((range) => (
|
|
<Button
|
|
key={range.value}
|
|
variant={eventRange === range.value ? 'secondary' : 'outline'}
|
|
className="h-8 px-3 text-[13px]"
|
|
onClick={() => setEventRange(range.value)}
|
|
>
|
|
{range.label}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
<div className="relative w-full lg:w-80">
|
|
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
<input
|
|
className="h-8 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
|
placeholder="이벤트 검색"
|
|
value={search}
|
|
onChange={(event) => setSearch(event.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{activeTab === 'logs' ? (
|
|
<div className="min-h-[620px] bg-background">
|
|
<div className="border-b border-border px-5 py-4">
|
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
|
<div className="text-xs text-muted-foreground">
|
|
대상 <b className="text-foreground">{selectedResource ? `${selectedResource.kind}/${selectedResource.name}` : '-'}</b>
|
|
</div>
|
|
{selectedResource ? (
|
|
<div className="grid gap-2 text-xs sm:grid-cols-[minmax(0,1fr)_120px_120px] lg:min-w-[620px]">
|
|
<div className="rounded-md border border-border bg-card px-3 py-2">
|
|
<UsageBars metric={selectedMetric} />
|
|
</div>
|
|
<div className="rounded-md border border-border bg-card px-3 py-2">
|
|
<p className="text-muted-foreground">Ready</p>
|
|
<p className="mt-1 font-semibold text-foreground">{selectedResource.ready}</p>
|
|
</div>
|
|
<div className="rounded-md border border-border bg-card px-3 py-2">
|
|
<p className="text-muted-foreground">재시작</p>
|
|
<p className="mt-1 font-semibold text-foreground">{selectedResource.restarts ?? 0}</p>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
{selectedResource ? (
|
|
<pre ref={logContainerRef} className="max-h-[640px] overflow-auto whitespace-pre-wrap p-5 font-mono text-xs leading-6 text-muted-foreground">
|
|
{logLoading ? '로그를 불러오는 중입니다...' : logs || '로그가 없습니다.'}
|
|
</pre>
|
|
) : (
|
|
<div className="flex min-h-[520px] items-center justify-center text-center text-sm text-muted-foreground">
|
|
왼쪽에서 워크로드를 선택하면 로그가 표시됩니다.
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="max-h-[680px] overflow-x-auto overflow-y-auto">
|
|
<table className="w-full min-w-[920px] text-left text-sm">
|
|
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
|
<tr>
|
|
<th className="px-5 py-3 font-medium">Type</th>
|
|
<th className="px-5 py-3 font-medium">Reason</th>
|
|
<th className="px-5 py-3 font-medium">Object</th>
|
|
<th className="px-5 py-3 font-medium">Message</th>
|
|
<th className="px-5 py-3 font-medium">Count</th>
|
|
<th className="px-5 py-3 font-medium">Last</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border">
|
|
{filteredEvents.length === 0 ? (
|
|
<tr><td colSpan={6} className="px-5 py-14 text-center text-muted-foreground">표시할 이벤트가 없습니다.</td></tr>
|
|
) : null}
|
|
{filteredEvents.map((event, index) => (
|
|
<tr key={`${event.involvedObject}-${event.reason}-${index}`} className="hover:bg-muted/20">
|
|
<td className="px-5 py-3"><Badge tone={eventTone(event.type)}>{event.type ?? '-'}</Badge></td>
|
|
<td className="px-5 py-3 font-medium">{event.reason ?? '-'}</td>
|
|
<td className="px-5 py-3 font-mono text-xs text-muted-foreground">{event.involvedObject ?? '-'}</td>
|
|
<td className="max-w-[460px] px-5 py-3 text-muted-foreground"><span className="block truncate">{event.message ?? '-'}</span></td>
|
|
<td className="px-5 py-3">{event.count ?? '-'}</td>
|
|
<td className="px-5 py-3 text-muted-foreground">{formatDateTime(event.lastTimestamp || event.firstTimestamp)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
<LoadingModal open={loadingVisible} />
|
|
</>
|
|
);
|
|
}
|