add runtime log views and standardize status dots
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-09 10:16:25 +09:00
parent f67017623f
commit 3cab64a256
13 changed files with 990 additions and 65 deletions
+21 -4
View File
@@ -36,9 +36,11 @@ import { LauncherBuildPage } from './pages/launcher/LauncherBuildPage';
import { LauncherSourcePage } from './pages/launcher/LauncherSourcePage';
import { LauncherAppsPage as LauncherAppsApiPage } from './pages/launcher/LauncherAppsPage';
import { LauncherComponentsPage } from './pages/launcher/LauncherComponentsPage';
import { LauncherLogsPage } from './pages/launcher/LauncherLogsPage';
import { LauncherVersionsPage as LauncherVersionsApiPage } from './pages/launcher/LauncherVersionsPage';
import { CommonAppBuildPage } from './pages/common-apps/CommonAppBuildPage';
import { CommonAppComponentsPage } from './pages/common-apps/CommonAppComponentsPage';
import { CommonAppLogsPage } from './pages/common-apps/CommonAppLogsPage';
import { CommonAppSourcePage } from './pages/common-apps/CommonAppSourcePage';
import { LoadingModal, useMinimumLoading } from './pages/operations/operation-utils';
import {
@@ -60,10 +62,12 @@ type ViewKey =
| 'commonSources'
| 'commonBuilds'
| 'commonComponents'
| 'commonLogs'
| 'launcherSources'
| 'launcherBuilds'
| 'launcherVersions'
| 'launcherComponents'
| 'launcherLogs'
| 'launcherApps'
| 'notices'
| 'appManagement'
@@ -130,10 +134,12 @@ const pathByView: Record<ViewKey, string> = {
commonSources: '/common-apps/sources',
commonBuilds: '/common-apps/builds',
commonComponents: '/common-apps/components',
commonLogs: '/common-apps/logs',
launcherSources: '/launcher/sources',
launcherBuilds: '/launcher/builds',
launcherVersions: '/launcher/versions',
launcherComponents: '/launcher/components',
launcherLogs: '/launcher/logs',
launcherApps: '/launcher/apps',
notices: '/operations/notices',
appManagement: '/applications/apps',
@@ -916,7 +922,7 @@ function MetricCards({
},
{
label: '진행 중 작업',
value: loading ? '-' : `${activeWorkTotal}건`,
value: loading ? '-' : `${activeWorkTotal}`,
delta: `빌드 ${activeWork.builds} · 릴리즈 ${activeWork.releases} · 배포 ${activeWork.deployments}`,
hint: '대기/실행 중 작업 기준',
tone: activeWorkTotal > 0 ? 'amber' as const : 'green' as const
@@ -1032,10 +1038,14 @@ function LauncherPanel({ versions, loading, onOpen }: { versions: ApiLauncherVer
);
}
function CommonAppsPanel({ pipelines, loading }: { pipelines: CommonAppPipeline[]; loading: boolean }) {
function CommonAppsPanel({ pipelines, loading, onOpen }: { pipelines: CommonAppPipeline[]; loading: boolean; onOpen: () => void }) {
return (
<Card className="min-w-0 overflow-hidden">
<CardHeader title="공통앱" description="공통앱의 최근 빌드 상태를 확인합니다." />
<CardHeader
title="공통앱"
description="공통앱의 최근 빌드 상태를 확인합니다."
action={<DashboardLinkButton onClick={onOpen} label="공통앱 빌드 관리로 이동" />}
/>
<div className="max-h-[420px] overflow-y-auto">
{loading ? (
<div className="space-y-3 p-5">
@@ -1369,12 +1379,14 @@ function NoticePanel({ notices: dashboardNotices, loading, onOpen }: { notices:
function DashboardPage({
onLauncherVersionsOpen,
onCommonBuildsOpen,
onAppManagementOpen,
onRuntimeLogsOpen,
onNoticesOpen,
onUserSessionsOpen
}: {
onLauncherVersionsOpen: () => void;
onCommonBuildsOpen: () => void;
onAppManagementOpen: () => void;
onRuntimeLogsOpen: () => void;
onNoticesOpen: () => void;
@@ -1477,7 +1489,7 @@ function DashboardPage({
<div className="mt-4 grid gap-4 xl:grid-cols-2">
<LauncherPanel versions={launcherRows} loading={dashboardLoading} onOpen={onLauncherVersionsOpen} />
<CommonAppsPanel pipelines={commonAppPipelines} loading={dashboardLoading} />
<CommonAppsPanel pipelines={commonAppPipelines} loading={dashboardLoading} onOpen={onCommonBuildsOpen} />
</div>
<div className="mt-4">
@@ -1556,10 +1568,14 @@ function App() {
return <CommonAppBuildPage />;
case 'commonComponents':
return <CommonAppComponentsPage />;
case 'commonLogs':
return <CommonAppLogsPage />;
case 'launcherVersions':
return <LauncherVersionsApiPage />;
case 'launcherComponents':
return <LauncherComponentsPage />;
case 'launcherLogs':
return <LauncherLogsPage />;
case 'launcherSources':
return <LauncherSourcePage />;
case 'launcherBuilds':
@@ -1623,6 +1639,7 @@ function App() {
return (
<DashboardPage
onLauncherVersionsOpen={() => changeView('launcherVersions')}
onCommonBuildsOpen={() => changeView('commonBuilds')}
onAppManagementOpen={() => changeView('appManagement')}
onRuntimeLogsOpen={() => changeView('runtimeLogs')}
onNoticesOpen={() => changeView('notices')}
+3 -1
View File
@@ -24,7 +24,8 @@ export const navGroups = [
children: [
{ label: '소스 관리', view: 'commonSources' },
{ label: '빌드 관리', view: 'commonBuilds' },
{ label: '구성요소', view: 'commonComponents' }
{ label: '구성요소', view: 'commonComponents' },
{ label: '로그/이벤트', view: 'commonLogs' }
]
},
{
@@ -35,6 +36,7 @@ export const navGroups = [
{ label: '빌드 관리', view: 'launcherBuilds' },
{ label: '버전 관리' },
{ label: '구성요소', view: 'launcherComponents' },
{ label: '로그/이벤트', view: 'launcherLogs' },
{ label: '앱 연결' }
]
},
+1
View File
@@ -42,6 +42,7 @@ export const navGroups: NavGroup[] = [
{ label: '빌드 관리', path: '/launcher/builds' },
{ label: '버전 관리', path: '/launcher/versions' },
{ label: '구성요소', path: '/launcher/components' },
{ label: '로그/이벤트', path: '/launcher/logs' },
{ label: '앱 연결', path: '/launcher/apps' }
]
},
+21
View File
@@ -0,0 +1,21 @@
export type StatusDotTone = 'green' | 'amber' | 'red' | 'blue' | 'neutral';
export function statusDotToneClass(tone: StatusDotTone) {
const tones: Record<StatusDotTone, string> = {
green: 'bg-emerald-400 shadow-[0_0_0_3px_rgba(52,211,153,0.15)]',
amber: 'bg-amber-400 shadow-[0_0_0_3px_rgba(251,191,36,0.15)]',
red: 'bg-rose-400 shadow-[0_0_0_3px_rgba(251,113,133,0.15)]',
blue: 'bg-sky-400 shadow-[0_0_0_3px_rgba(56,189,248,0.15)]',
neutral: 'bg-muted-foreground/60 shadow-[0_0_0_3px_rgba(148,163,184,0.12)]'
};
return tones[tone];
}
export function runtimeStatusDotClass(status?: string | null) {
if (['Running', 'Bound', 'ClusterIP', 'LoadBalancer', 'Normal'].includes(status ?? '')) return statusDotToneClass('green');
if (['Pending', 'ContainerCreating', 'Warning'].includes(status ?? '')) return statusDotToneClass('amber');
if (['Error', 'CrashLoopBackOff', 'ImagePullBackOff', 'Failed'].includes(status ?? '')) return statusDotToneClass('red');
return statusDotToneClass('neutral');
}
+3 -9
View File
@@ -5,6 +5,7 @@ import { useToast } from '../../components/toast';
import { PageTitle } from '../../layout/AppLayout';
import { apiBaseUrl, apiFetch, authHeaders } from '../../lib/api';
import { filterAppsByCurrentAdmin, grantAppPermissionToCurrentAdmin } from '../../lib/admin-app-permissions';
import { runtimeStatusDotClass, statusDotToneClass } from '../../lib/status-dot';
import { cn } from '../../lib/utils';
import { getStoredSelectedAppId, setStoredSelectedAppId } from '../../lib/selected-app';
import type { ApiApp, ApiAppComponent, BuildEnvVariable, RuntimePersistentVolumeClaim, RuntimeResource } from '../../types/runtime';
@@ -237,13 +238,6 @@ function runtimeStatusTone(resource: RuntimeResource, stopped: boolean): 'neutra
return 'blue';
}
function statusDot(status?: string) {
if (['Running', 'Bound', 'ClusterIP', 'LoadBalancer'].includes(status ?? '')) return 'bg-emerald-400';
if (['Pending', 'ContainerCreating'].includes(status ?? '')) return 'bg-amber-400';
if (['Error', 'CrashLoopBackOff', 'ImagePullBackOff', 'Failed'].includes(status ?? '')) return 'bg-rose-400';
return 'bg-muted-foreground';
}
function isComponentRunning(component: ApiAppComponent, resources: RuntimeResource[]) {
const name = component.name.toLowerCase();
return resources.some((resource) => {
@@ -1012,7 +1006,7 @@ export function AppManagementPage() {
<tr key={component.name} className="hover:bg-muted/20">
<td className="px-5 py-3 font-medium">
<span className="inline-flex items-center gap-2">
<span className={cn('size-2 rounded-full', running ? 'bg-emerald-400' : component.repository ? 'bg-blue-400' : 'bg-muted-foreground')} />
<span className={cn('size-2.5 shrink-0 rounded-full', running ? statusDotToneClass('green') : component.repository ? statusDotToneClass('blue') : statusDotToneClass('neutral'))} />
{component.name}
{running ? <Badge tone="green">실행 중</Badge> : null}
</span>
@@ -1095,7 +1089,7 @@ export function AppManagementPage() {
<tr key={actionBase} className="hover:bg-muted/20">
<td className="px-5 py-3">
<span className="inline-flex items-center gap-2">
<span className={`block size-2.5 rounded-full ${stopped ? 'bg-muted-foreground' : statusDot(resource.status)}`} />
<span className={cn('block size-2.5 shrink-0 rounded-full', stopped ? statusDotToneClass('neutral') : runtimeStatusDotClass(resource.status))} />
<span className="text-sm text-foreground">{resource.name}</span>
<Badge tone={runtimeStatusTone(resource, stopped)}>{runtimeStatusLabel(resource, stopped)}</Badge>
</span>
+415
View File
@@ -0,0 +1,415 @@
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';
}
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 [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 filteredEvents = events.filter((event) => {
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();
}, []);
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="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>
{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} />
</>
);
}
+7 -5
View File
@@ -1,3 +1,5 @@
import { statusDotToneClass } from '../../lib/status-dot';
export const COMMON_APP_NAMESPACE = 'studio-system';
export type CommonAppComponent = {
@@ -84,11 +86,11 @@ export function pipelineStatusTone(status?: string | null): 'green' | 'amber' |
export function pipelineStatusDotClass(status?: string | null) {
const value = (status ?? '').toLowerCase();
if (['success', 'succeeded', 'passed'].includes(value)) return 'bg-emerald-400 shadow-[0_0_0_3px_rgba(52,211,153,0.15)]';
if (['running', 'started'].includes(value)) return 'bg-sky-400 shadow-[0_0_0_3px_rgba(56,189,248,0.15)]';
if (['pending', 'queued', 'blocked', 'created'].includes(value)) return 'bg-amber-400 shadow-[0_0_0_3px_rgba(251,191,36,0.15)]';
if (['failure', 'failed', 'error', 'killed', 'declined'].includes(value)) return 'bg-rose-400 shadow-[0_0_0_3px_rgba(251,113,133,0.15)]';
return 'bg-muted-foreground/60';
if (['success', 'succeeded', 'passed'].includes(value)) return statusDotToneClass('green');
if (['running', 'started'].includes(value)) return statusDotToneClass('blue');
if (['pending', 'queued', 'blocked', 'created'].includes(value)) return statusDotToneClass('amber');
if (['failure', 'failed', 'error', 'killed', 'declined'].includes(value)) return statusDotToneClass('red');
return statusDotToneClass('neutral');
}
export function isActivePipelineStatus(status?: string | null) {
+5 -4
View File
@@ -1,9 +1,9 @@
import { Activity, Boxes, FileKey, RefreshCw } from 'lucide-react';
import { Activity, Boxes, ChevronRight, FileKey } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Badge, Button, Card, CardHeader } from '../../components/ui';
import { PageTitle } from '../../layout/AppLayout';
import { apiRequest } from '../../lib/api';
import { cn } from '../../lib/utils';
type CommonAppPipeline = {
id: string;
@@ -43,6 +43,7 @@ async function loadCommonAppPipelines(): Promise<CommonAppPipeline[]> {
}
export function DashboardPage() {
const navigate = useNavigate();
const [commonApps, setCommonApps] = useState<CommonAppPipeline[]>([]);
const [commonAppsLoading, setCommonAppsLoading] = useState(true);
const [commonAppsFailed, setCommonAppsFailed] = useState(false);
@@ -113,8 +114,8 @@ export function DashboardPage() {
title="공통앱"
description="공통앱의 최근 빌드 상태를 확인합니다."
action={
<Button variant="outline" className="size-8 px-0" onClick={refreshCommonApps} disabled={commonAppsLoading} title="새로고침" aria-label="새로고침">
<RefreshCw className={cn('size-4', commonAppsLoading && 'animate-spin')} />
<Button variant="ghost" className="size-8 px-0 text-muted-foreground hover:text-foreground" onClick={() => navigate('/common-apps/builds')} title="공통앱 빌드 관리로 이동" aria-label="공통앱 빌드 관리로 이동">
<ChevronRight className="size-4" />
</Button>
}
/>
+2 -1
View File
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
import { useToast } from '../../components/toast';
import { PageTitle } from '../../layout/AppLayout';
import { statusDotToneClass } from '../../lib/status-dot';
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
import {
createLauncherAppLink,
@@ -226,7 +227,7 @@ export function LauncherAppsPage() {
<td className="px-5 py-3">{link.displayOrder || index + 1}</td>
<td className="px-5 py-3">
<p className="inline-flex items-center gap-2">
<span className={link.isEnabled ? 'size-2 rounded-full bg-emerald-400' : 'size-2 rounded-full bg-muted-foreground'} />
<span className={`size-2.5 shrink-0 rounded-full ${link.isEnabled ? statusDotToneClass('green') : statusDotToneClass('neutral')}`} />
{link.displayName || link.app.name}
</p>
<p className="mt-1 text-xs text-muted-foreground">
+444
View File
@@ -0,0 +1,444 @@
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';
const LAUNCHER_NAMESPACE = 'studio-system';
type LauncherLogComponent = {
key: string;
name: string;
description: string;
kind: 'Deployment' | 'StatefulSet';
workloadName: string;
serviceName: string;
};
const launcherLogComponents: LauncherLogComponent[] = [
{
key: 'launcher-be',
name: '런처 백엔드',
description: '로그인, 앱 목록, 업데이트, handoff API를 제공하는 서버입니다.',
kind: 'Deployment',
workloadName: 'studio-launcher-be',
serviceName: 'studio-launcher-be'
},
{
key: 'launcher-db',
name: '런처 DB',
description: '런처 앱, 버전, 사용자 앱 권한 데이터를 저장합니다.',
kind: 'StatefulSet',
workloadName: 'studio-launcher-db',
serviceName: 'studio-launcher-db'
}
];
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';
}
function metricKey(namespace: string, pod: string) {
return `${namespace}/${pod}`;
}
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 isLoggable(resource: RuntimeResource) {
return ['Pod', 'Deployment', 'StatefulSet', 'DaemonSet', 'Job'].includes(resource.kind);
}
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: LauncherLogComponent) {
return resource.name === component.workloadName
|| resource.name === component.serviceName
|| 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 LauncherLogsPage() {
const [componentKey, setComponentKey] = useState(launcherLogComponents[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 [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 = launcherLogComponents.find((component) => component.key === componentKey) ?? launcherLogComponents[0];
const resources = data?.resources ?? [];
const activeNamespace = data?.namespace || LAUNCHER_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 filteredEvents = events.filter((event) => {
const normalizedSearch = search.trim().toLowerCase();
const componentText = `${event.involvedObject ?? ''} ${event.message ?? ''}`.toLowerCase();
const componentMatch = selectedComponent ? componentText.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(LAUNCHER_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(LAUNCHER_NAMESPACE),
loadRuntimeEvents(LAUNCHER_NAMESPACE),
loadRuntimePodMetrics(LAUNCHER_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();
}, []);
return (
<>
<PageTitle
eyebrow="런처"
title="로그/이벤트"
description="런처 백엔드와 DB 워크로드 로그 및 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('');
}}
>
{launcherLogComponents.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">{LAUNCHER_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="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>
{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} />
</>
);
}
+3 -9
View File
@@ -5,6 +5,7 @@ import { useToast } from '../../components/toast';
import { PageTitle } from '../../layout/AppLayout';
import { formatDateTime } from '../../lib/date-format';
import { getStoredSelectedAppId, getStoredSelectedComponentId, selectedComponentChangedEventName, setStoredSelectedAppId, setStoredSelectedComponentId } from '../../lib/selected-app';
import { statusDotToneClass } from '../../lib/status-dot';
import type { ApiApp, ApiAppComponent, RuntimeEnvProfile, RuntimeEnvVariable, RuntimeIngressRule, RuntimeResource } from '../../types/runtime';
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
import {
@@ -40,13 +41,6 @@ function getWorkloadKind(kind: string) {
return '';
}
function statusDot(status?: string) {
if (['Running', 'Bound', 'ClusterIP', 'LoadBalancer'].includes(status ?? '')) return 'bg-emerald-400';
if (['Pending', 'ContainerCreating'].includes(status ?? '')) return 'bg-amber-400';
if (['Error', 'CrashLoopBackOff', 'ImagePullBackOff', 'Failed'].includes(status ?? '')) return 'bg-rose-400';
return 'bg-muted-foreground';
}
function isStopped(resource: RuntimeResource) {
return resource.ready.endsWith('/0');
}
@@ -520,7 +514,7 @@ export function RuntimeConfigPage() {
<tr key={variable.id} className="hover:bg-muted/20">
<td className="px-4 py-3">
<span className="inline-flex items-center gap-2">
<span className={variable.isSecret ? 'block size-2.5 rounded-full bg-amber-400' : 'block size-2.5 rounded-full bg-emerald-400'} />
<span className={`block size-2.5 shrink-0 rounded-full ${variable.isSecret ? statusDotToneClass('amber') : statusDotToneClass('green')}`} />
<span className="truncate">{variable.component?.name ?? '-'}</span>
</span>
</td>
@@ -626,7 +620,7 @@ export function RuntimeConfigPage() {
<tr key={rule.id} className="hover:bg-muted/20">
<td className="px-4 py-3">
<span className="inline-flex items-center gap-2">
<span className="block size-2.5 rounded-full bg-sky-400" />
<span className={`block size-2.5 shrink-0 rounded-full ${statusDotToneClass('blue')}`} />
<span className="truncate">{rule.component?.name ?? '-'}</span>
</span>
</td>
+8 -14
View File
@@ -4,18 +4,12 @@ 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 { runtimeStatusDotClass, statusDotToneClass } from '../../lib/status-dot';
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';
@@ -59,12 +53,12 @@ function isActiveLogTarget(resource: RuntimeResource) {
}
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';
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 metricKey(namespace: string, pod: string) {
@@ -311,7 +305,7 @@ export function RuntimeLogsPage() {
onClick={() => loadLogs(resource)}
>
<div className="flex items-center gap-2">
<span className={cn('size-2 rounded-full', workloadStatusDot(resource))} />
<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">