merge develop into release
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-09 13:21:39 +09:00
+95 -22
View File
@@ -14,6 +14,7 @@ import {
} from 'recharts'; } from 'recharts';
import { Badge, Button, Card, CardHeader } from '../../components/ui'; import { Badge, Button, Card, CardHeader } from '../../components/ui';
import { PageTitle } from '../../layout/AppLayout'; import { PageTitle } from '../../layout/AppLayout';
import { getStoredSelectedAppId, setStoredSelectedAppId } from '../../lib/selected-app';
import { runtimeStatusDotClass } from '../../lib/status-dot'; import { runtimeStatusDotClass } from '../../lib/status-dot';
import { cn } from '../../lib/utils'; import { cn } from '../../lib/utils';
import type { ApiApp, RuntimeEvent, RuntimePodMetric, RuntimeResource } from '../../types/runtime'; import type { ApiApp, RuntimeEvent, RuntimePodMetric, RuntimeResource } from '../../types/runtime';
@@ -25,6 +26,20 @@ type ResourceRow = RuntimeResource & {
}; };
const chartColors = ['#34d399', '#60a5fa', '#fbbf24', '#fb7185', '#a78bfa']; const chartColors = ['#34d399', '#60a5fa', '#fbbf24', '#fb7185', '#a78bfa'];
const commonAppMonitoringAppId = '__common_apps__';
const commonAppMonitoringApp: ApiApp = {
id: commonAppMonitoringAppId,
key: 'studio',
name: '공통앱',
namespace: 'studio-system',
status: 'ACTIVE'
};
const eventRanges = [
{ label: '최근 10분', value: '10m', milliseconds: 10 * 60 * 1000 },
{ label: '1시간', value: '1h', milliseconds: 60 * 60 * 1000 },
{ label: '6시간', value: '6h', milliseconds: 6 * 60 * 60 * 1000 },
{ label: '24시간', value: '24h', milliseconds: 24 * 60 * 60 * 1000 }
];
function parseCpuMillicores(value?: string) { function parseCpuMillicores(value?: string) {
if (!value) return 0; if (!value) return 0;
@@ -60,22 +75,46 @@ function statusTone(status: string): 'neutral' | 'green' | 'amber' | 'red' | 'bl
return 'neutral'; return 'neutral';
} }
function isCompletedMigration(resource: RuntimeResource) {
const normalizedName = resource.name.toLowerCase();
const normalizedStatus = resource.status.toLowerCase();
return normalizedName.includes('migration') && ['completed', 'succeeded', 'success'].includes(normalizedStatus);
}
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;
}
export function MonitoringPage() { export function MonitoringPage() {
const [apps, setApps] = useState<ApiApp[]>([]); const [apps, setApps] = useState<ApiApp[]>([]);
const [selectedAppId, setSelectedAppId] = useState(''); const [selectedAppId, setSelectedAppId] = useState(() => getStoredSelectedAppId());
const [eventRange, setEventRange] = useState('1h');
const [resources, setResources] = useState<ResourceRow[]>([]); const [resources, setResources] = useState<ResourceRow[]>([]);
const [events, setEvents] = useState<RuntimeEvent[]>([]); const [events, setEvents] = useState<RuntimeEvent[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const loadingVisible = useMinimumLoading(loading); const loadingVisible = useMinimumLoading(loading);
const selectedApp = apps.find((app) => app.id === selectedAppId) ?? apps[0]; const selectedApp = apps.find((app) => app.id === selectedAppId) ?? apps[0];
const selectedRange = eventRanges.find((range) => range.value === eventRange) ?? eventRanges[1];
const visibleResources = useMemo(() => resources.filter((resource) => !isCompletedMigration(resource)), [resources]);
const filteredEvents = useMemo(() => {
const threshold = Date.now() - selectedRange.milliseconds;
return events.filter((event) => eventTime(event) >= threshold);
}, [events, selectedRange.milliseconds]);
const refresh = () => { const refresh = () => {
setLoading(true); setLoading(true);
loadApps() loadApps()
.then(async (nextApps) => { .then(async (nextApps) => {
const appItems = nextApps.filter((app) => app.namespace); const appItems = [commonAppMonitoringApp, ...nextApps.filter((app) => app.namespace)];
const app = appItems.find((item) => item.id === selectedAppId) ?? appItems[0]; const storedAppId = getStoredSelectedAppId();
const preferredAppId = selectedAppId || storedAppId;
const app = appItems.find((item) => item.id === preferredAppId)
?? appItems.find((item) => item.id === storedAppId)
?? appItems[0];
setApps(appItems); setApps(appItems);
setSelectedAppId(app?.id ?? ''); setSelectedAppId(app?.id ?? '');
@@ -147,26 +186,26 @@ export function MonitoringPage() {
}, [selectedApp?.namespace]); }, [selectedApp?.namespace]);
const summary = useMemo(() => { const summary = useMemo(() => {
const runtimeResources = resources.filter((resource) => ['Pod', 'Deployment', 'StatefulSet', 'DaemonSet'].includes(resource.kind)); const runtimeResources = visibleResources.filter((resource) => ['Pod', 'Deployment', 'StatefulSet', 'DaemonSet'].includes(resource.kind));
const healthy = runtimeResources.filter(isHealthy).length; const healthy = runtimeResources.filter(isHealthy).length;
const warnings = events.filter((event) => event.type === 'Warning').length; const warnings = filteredEvents.filter((event) => event.type === 'Warning').length;
const restarts = resources.reduce((sum, resource) => sum + (resource.restarts ?? 0), 0); const restarts = visibleResources.reduce((sum, resource) => sum + (resource.restarts ?? 0), 0);
return { total: runtimeResources.length, healthy, warnings, restarts }; return { total: runtimeResources.length, healthy, warnings, restarts };
}, [events, resources]); }, [filteredEvents, visibleResources]);
const podMetrics = useMemo(() => resources const podMetrics = useMemo(() => visibleResources
.filter((resource) => resource.kind === 'Pod' && resource.metric) .filter((resource) => resource.kind === 'Pod' && resource.metric)
.map((resource) => ({ .map((resource) => ({
name: resource.name.replace(`${selectedApp?.key?.toLowerCase() ?? ''}-`, ''), name: resource.name.replace(`${selectedApp?.key?.toLowerCase() ?? ''}-`, ''),
cpu: parseCpuMillicores(resource.metric?.cpu), cpu: parseCpuMillicores(resource.metric?.cpu),
memory: Math.round(parseMemoryMi(resource.metric?.memory)) memory: Math.round(parseMemoryMi(resource.metric?.memory))
})), [resources, selectedApp?.key]); })), [visibleResources, selectedApp?.key]);
const statusData = useMemo(() => { const statusData = useMemo(() => {
const byStatus = new Map<string, number>(); const byStatus = new Map<string, number>();
resources.forEach((resource) => byStatus.set(resource.status || '-', (byStatus.get(resource.status || '-') ?? 0) + 1)); visibleResources.forEach((resource) => byStatus.set(resource.status || '-', (byStatus.get(resource.status || '-') ?? 0) + 1));
return [...byStatus.entries()].map(([name, value]) => ({ name, value })); return [...byStatus.entries()].map(([name, value]) => ({ name, value }));
}, [resources]); }, [visibleResources]);
return ( return (
<> <>
@@ -183,25 +222,46 @@ export function MonitoringPage() {
/> />
<Card className="mb-4 p-4"> <Card className="mb-4 p-4">
<label className="block max-w-md"> <div className="grid gap-4 lg:grid-cols-[minmax(260px,420px)_1fr] lg:items-end">
<label className="block">
<span className="text-xs font-medium text-muted-foreground">앱</span> <span className="text-xs font-medium text-muted-foreground">앱</span>
<select <select
className="mt-2 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" className="mt-2 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"
value={selectedAppId} value={selectedAppId}
onChange={(event) => setSelectedAppId(event.target.value)} onChange={(event) => {
const nextAppId = event.target.value;
setSelectedAppId(nextAppId);
if (nextAppId !== commonAppMonitoringAppId) setStoredSelectedAppId(nextAppId);
}}
> >
{apps.map((app) => ( {apps.map((app) => (
<option key={app.id} value={app.id}>{app.name} · {app.namespace}</option> <option key={app.id} value={app.id}>{app.name} · {app.namespace}</option>
))} ))}
</select> </select>
</label> </label>
<div>
<p className="text-xs font-medium text-muted-foreground">검색 기간</p>
<div className="mt-2 flex flex-wrap gap-2">
{eventRanges.map((range) => (
<Button
key={range.value}
variant={eventRange === range.value ? 'secondary' : 'outline'}
className="h-9 px-3 text-[13px]"
onClick={() => setEventRange(range.value)}
>
{range.label}
</Button>
))}
</div>
</div>
</div>
</Card> </Card>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4"> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<MetricCard icon={Server} label="워크로드" value={`${summary.healthy}/${summary.total}`} hint="Pod/Deployment/StatefulSet 기준" tone={summary.healthy === summary.total ? 'green' : 'amber'} /> <MetricCard icon={Server} label="워크로드" value={`${summary.healthy}/${summary.total}`} hint="Pod/Deployment/StatefulSet 기준" tone={summary.healthy === summary.total ? 'green' : 'amber'} />
<MetricCard icon={Activity} label="Pod" value={String(resources.filter((resource) => resource.kind === 'Pod').length)} hint="실행 Pod 수" tone="blue" /> <MetricCard icon={Activity} label="Pod" value={String(visibleResources.filter((resource) => resource.kind === 'Pod').length)} hint="현재 기준" tone="blue" />
<MetricCard icon={AlertTriangle} label="Warning 이벤트" value={String(summary.warnings)} hint="현재 네임스페이스 기준" tone={summary.warnings > 0 ? 'amber' : 'green'} /> <MetricCard icon={AlertTriangle} label="Warning 이벤트" value={String(summary.warnings)} hint={`${selectedRange.label} 기준`} tone={summary.warnings > 0 ? 'amber' : 'green'} />
<MetricCard icon={RotateCcw} label="재시작" value={String(summary.restarts)} hint="Pod 재시작 합계" tone={summary.restarts > 0 ? 'amber' : 'green'} /> <MetricCard icon={RotateCcw} label="재시작" value={String(summary.restarts)} hint="현재 Pod 누적" tone={summary.restarts > 0 ? 'amber' : 'green'} />
</div> </div>
<div className="mt-4 grid gap-4 xl:grid-cols-[minmax(0,1.25fr)_minmax(360px,0.75fr)]"> <div className="mt-4 grid gap-4 xl:grid-cols-[minmax(0,1.25fr)_minmax(360px,0.75fr)]">
@@ -223,16 +283,29 @@ export function MonitoringPage() {
<Card className="min-w-0 overflow-hidden"> <Card className="min-w-0 overflow-hidden">
<CardHeader title="상태 분포" description="런타임 리소스 상태별 개수입니다." /> <CardHeader title="상태 분포" description="런타임 리소스 상태별 개수입니다." />
<div className="h-80 p-5"> <div className="p-5">
<div className="h-60">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<PieChart> <PieChart>
<Pie data={statusData} dataKey="value" nameKey="name" innerRadius={62} outerRadius={100} paddingAngle={3}> <Pie data={statusData} dataKey="value" nameKey="name" innerRadius={58} outerRadius={92} paddingAngle={3}>
{statusData.map((entry, index) => <Cell key={entry.name} fill={chartColors[index % chartColors.length]} />)} {statusData.map((entry, index) => <Cell key={entry.name} fill={chartColors[index % chartColors.length]} />)}
</Pie> </Pie>
<Tooltip contentStyle={{ backgroundColor: 'hsl(224 20% 10%)', border: '1px solid hsl(222 15% 20%)', borderRadius: 8 }} /> <Tooltip contentStyle={{ backgroundColor: 'hsl(224 20% 10%)', border: '1px solid hsl(222 15% 20%)', borderRadius: 8 }} />
</PieChart> </PieChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
<div className="mt-4 grid gap-2 sm:grid-cols-2">
{statusData.map((entry, index) => (
<div key={entry.name} className="flex items-center justify-between gap-3 rounded-md bg-muted/30 px-3 py-2 text-xs">
<span className="inline-flex min-w-0 items-center gap-2">
<span className="size-2.5 shrink-0 rounded-full" style={{ backgroundColor: chartColors[index % chartColors.length] }} />
<span className="truncate text-muted-foreground">{entry.name}</span>
</span>
<span className="font-semibold text-foreground">{entry.value}</span>
</div>
))}
</div>
</div>
</Card> </Card>
</div> </div>
@@ -240,7 +313,7 @@ export function MonitoringPage() {
<Card className="overflow-hidden"> <Card className="overflow-hidden">
<CardHeader title="리소스 상태" description="선택한 앱 네임스페이스의 주요 리소스입니다." /> <CardHeader title="리소스 상태" description="선택한 앱 네임스페이스의 주요 리소스입니다." />
<div className="max-h-[420px] divide-y divide-border overflow-y-auto"> <div className="max-h-[420px] divide-y divide-border overflow-y-auto">
{resources.map((resource) => ( {visibleResources.map((resource) => (
<div key={`${resource.kind}-${resource.name}`} className="flex items-center justify-between gap-4 px-5 py-3 text-sm"> <div key={`${resource.kind}-${resource.name}`} className="flex items-center justify-between gap-4 px-5 py-3 text-sm">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -256,9 +329,9 @@ export function MonitoringPage() {
</Card> </Card>
<Card className="overflow-hidden"> <Card className="overflow-hidden">
<CardHeader title="최근 이벤트" description="Warning 이벤트를 우선 확인합니다." /> <CardHeader title="이벤트 목록" description="선택한 기간의 Warning 이벤트를 우선 확인합니다." />
<div className="max-h-[420px] divide-y divide-border overflow-y-auto"> <div className="max-h-[420px] divide-y divide-border overflow-y-auto">
{events.slice(0, 12).map((event, index) => ( {filteredEvents.slice(0, 12).map((event, index) => (
<div key={`${event.involvedObject}-${event.reason}-${index}`} className="px-5 py-3 text-sm"> <div key={`${event.involvedObject}-${event.reason}-${index}`} className="px-5 py-3 text-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge tone={event.type === 'Warning' ? 'amber' : 'neutral'}>{event.type ?? '-'}</Badge> <Badge tone={event.type === 'Warning' ? 'amber' : 'neutral'}>{event.type ?? '-'}</Badge>
@@ -267,7 +340,7 @@ export function MonitoringPage() {
<p className="mt-2 line-clamp-2 text-xs leading-5 text-muted-foreground">{event.message ?? '-'}</p> <p className="mt-2 line-clamp-2 text-xs leading-5 text-muted-foreground">{event.message ?? '-'}</p>
</div> </div>
))} ))}
{!events.length ? <div className="px-5 py-12 text-center text-sm text-muted-foreground">표시할 이벤트가 없습니다.</div> : null} {!filteredEvents.length ? <div className="px-5 py-12 text-center text-sm text-muted-foreground">선택 기간에 표시할 이벤트가 없습니다.</div> : null}
</div> </div>
</Card> </Card>
</div> </div>