This commit is contained in:
@@ -21,6 +21,7 @@ type NoticeForm = {
|
||||
title: string;
|
||||
body: string;
|
||||
linkUrl: string;
|
||||
exposureEnabled: boolean;
|
||||
startsAt: string;
|
||||
endsAt: string;
|
||||
priority: string;
|
||||
@@ -32,6 +33,7 @@ const emptyForm: NoticeForm = {
|
||||
title: '',
|
||||
body: '',
|
||||
linkUrl: '',
|
||||
exposureEnabled: false,
|
||||
startsAt: '',
|
||||
endsAt: '',
|
||||
priority: '0',
|
||||
@@ -77,6 +79,7 @@ function toForm(notice: LauncherNotice): NoticeForm {
|
||||
title: notice.title,
|
||||
body: notice.body,
|
||||
linkUrl: notice.linkUrl ?? '',
|
||||
exposureEnabled: Boolean(notice.startsAt || notice.endsAt),
|
||||
startsAt: toDateTimeInputValue(notice.startsAt),
|
||||
endsAt: toDateTimeInputValue(notice.endsAt),
|
||||
priority: String(notice.priority ?? 0),
|
||||
@@ -181,8 +184,8 @@ export function NoticesPage() {
|
||||
title: form.title.trim(),
|
||||
body: form.body.trim(),
|
||||
linkUrl: form.linkUrl.trim(),
|
||||
startsAt: toApiDateTime(form.startsAt),
|
||||
endsAt: toApiDateTime(form.endsAt),
|
||||
startsAt: form.exposureEnabled ? toApiDateTime(form.startsAt) : undefined,
|
||||
endsAt: form.exposureEnabled ? toApiDateTime(form.endsAt) : undefined,
|
||||
priority: Number(form.priority) || 0,
|
||||
isActive: form.isActive
|
||||
});
|
||||
@@ -382,15 +385,32 @@ export function NoticesPage() {
|
||||
<span className="text-xs font-medium text-muted-foreground">링크주소</span>
|
||||
<input 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={form.linkUrl} onChange={(event) => setForm((prev) => ({ ...prev, linkUrl: event.target.value }))} />
|
||||
</label>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">노출 시작 일시</span>
|
||||
<input type="datetime-local" 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={form.startsAt} onChange={(event) => setForm((prev) => ({ ...prev, startsAt: event.target.value }))} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">노출 종료 일시</span>
|
||||
<input type="datetime-local" 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={form.endsAt} onChange={(event) => setForm((prev) => ({ ...prev, endsAt: event.target.value }))} />
|
||||
<div className="rounded-md border border-border bg-muted/20 p-3">
|
||||
<label className="inline-flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.exposureEnabled}
|
||||
onChange={(event) => setForm((prev) => ({
|
||||
...prev,
|
||||
exposureEnabled: event.target.checked,
|
||||
startsAt: event.target.checked ? prev.startsAt : '',
|
||||
endsAt: event.target.checked ? prev.endsAt : ''
|
||||
}))}
|
||||
/>
|
||||
노출기간 설정
|
||||
</label>
|
||||
{form.exposureEnabled ? (
|
||||
<div className="mt-3 grid gap-4 md:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">노출 시작 일시</span>
|
||||
<input type="datetime-local" 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={form.startsAt} onChange={(event) => setForm((prev) => ({ ...prev, startsAt: event.target.value }))} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">노출 종료 일시</span>
|
||||
<input type="datetime-local" 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={form.endsAt} onChange={(event) => setForm((prev) => ({ ...prev, endsAt: event.target.value }))} />
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import { Activity, AlertTriangle, RotateCcw, Server } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis
|
||||
} from 'recharts';
|
||||
import { Badge, Button, Card, CardHeader } from '../../components/ui';
|
||||
import { PageTitle } from '../../layout/AppLayout';
|
||||
import { runtimeStatusDotClass } from '../../lib/status-dot';
|
||||
import { cn } from '../../lib/utils';
|
||||
import type { ApiApp, RuntimeEvent, RuntimePodMetric, RuntimeResource } from '../../types/runtime';
|
||||
import { loadApps, loadRuntimeEvents, loadRuntimePodMetrics, loadRuntimeResources } from '../runtime/runtime-api';
|
||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||
|
||||
type ResourceRow = RuntimeResource & {
|
||||
metric?: RuntimePodMetric;
|
||||
};
|
||||
|
||||
const chartColors = ['#34d399', '#60a5fa', '#fbbf24', '#fb7185', '#a78bfa'];
|
||||
|
||||
function parseCpuMillicores(value?: string) {
|
||||
if (!value) return 0;
|
||||
if (value.endsWith('m')) return Number(value.slice(0, -1)) || 0;
|
||||
return (Number(value) || 0) * 1000;
|
||||
}
|
||||
|
||||
function parseMemoryMi(value?: string) {
|
||||
if (!value) return 0;
|
||||
const matched = value.match(/^([0-9.]+)([A-Za-z]+)?$/);
|
||||
if (!matched) return 0;
|
||||
const amount = Number(matched[1]);
|
||||
const unit = matched[2] ?? '';
|
||||
if (!Number.isFinite(amount)) return 0;
|
||||
if (unit === 'Ki') return amount / 1024;
|
||||
if (unit === 'Gi') return amount * 1024;
|
||||
if (unit === 'Ti') return amount * 1024 * 1024;
|
||||
if (unit === 'Mi' || unit === 'M') return amount;
|
||||
return amount / 1024 / 1024;
|
||||
}
|
||||
|
||||
function isHealthy(resource: RuntimeResource) {
|
||||
if (['Error', 'CrashLoopBackOff', 'Failed'].includes(resource.status)) return false;
|
||||
if (!resource.ready.includes('/')) return ['Running', 'Bound', 'ClusterIP', 'LoadBalancer'].includes(resource.status);
|
||||
const [ready, total] = resource.ready.split('/').map((value) => Number(value));
|
||||
return Number.isFinite(ready) && Number.isFinite(total) && total > 0 && ready === total;
|
||||
}
|
||||
|
||||
function statusTone(status: string): 'neutral' | 'green' | 'amber' | 'red' | 'blue' {
|
||||
if (isHealthy({ kind: '', name: '', status, ready: status === 'Running' ? '1/1' : '-', restarts: null, age: '', detail: '' })) return 'green';
|
||||
if (['Pending', 'ContainerCreating'].includes(status)) return 'amber';
|
||||
if (['Error', 'CrashLoopBackOff', 'Failed'].includes(status)) return 'red';
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
export function MonitoringPage() {
|
||||
const [apps, setApps] = useState<ApiApp[]>([]);
|
||||
const [selectedAppId, setSelectedAppId] = useState('');
|
||||
const [resources, setResources] = useState<ResourceRow[]>([]);
|
||||
const [events, setEvents] = useState<RuntimeEvent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const loadingVisible = useMinimumLoading(loading);
|
||||
|
||||
const selectedApp = apps.find((app) => app.id === selectedAppId) ?? apps[0];
|
||||
|
||||
const refresh = () => {
|
||||
setLoading(true);
|
||||
loadApps()
|
||||
.then(async (nextApps) => {
|
||||
const appItems = nextApps.filter((app) => app.namespace);
|
||||
const app = appItems.find((item) => item.id === selectedAppId) ?? appItems[0];
|
||||
setApps(appItems);
|
||||
setSelectedAppId(app?.id ?? '');
|
||||
|
||||
if (!app?.namespace) {
|
||||
setResources([]);
|
||||
setEvents([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const [runtime, metrics, eventItems] = await Promise.all([
|
||||
loadRuntimeResources(app.namespace),
|
||||
loadRuntimePodMetrics(app.namespace).catch(() => []),
|
||||
loadRuntimeEvents(app.namespace).catch(() => [])
|
||||
]);
|
||||
const metricByPod = new Map(metrics.map((metric) => [metric.pod, metric]));
|
||||
setResources((runtime.resources ?? []).map((resource) => ({
|
||||
...resource,
|
||||
metric: resource.kind === 'Pod' ? metricByPod.get(resource.name) : undefined
|
||||
})));
|
||||
setEvents(eventItems);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedApp?.namespace) return;
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
loadRuntimeResources(selectedApp.namespace),
|
||||
loadRuntimePodMetrics(selectedApp.namespace).catch(() => []),
|
||||
loadRuntimeEvents(selectedApp.namespace).catch(() => [])
|
||||
])
|
||||
.then(([runtime, metrics, eventItems]) => {
|
||||
const metricByPod = new Map(metrics.map((metric) => [metric.pod, metric]));
|
||||
setResources((runtime.resources ?? []).map((resource) => ({
|
||||
...resource,
|
||||
metric: resource.kind === 'Pod' ? metricByPod.get(resource.name) : undefined
|
||||
})));
|
||||
setEvents(eventItems);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [selectedAppId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedApp?.namespace) return;
|
||||
const namespace = selectedApp.namespace;
|
||||
const timer = window.setInterval(() => {
|
||||
Promise.all([
|
||||
loadRuntimeResources(namespace),
|
||||
loadRuntimePodMetrics(namespace).catch(() => []),
|
||||
loadRuntimeEvents(namespace).catch(() => [])
|
||||
])
|
||||
.then(([runtime, metrics, eventItems]) => {
|
||||
const metricByPod = new Map(metrics.map((metric) => [metric.pod, metric]));
|
||||
setResources((runtime.resources ?? []).map((resource) => ({
|
||||
...resource,
|
||||
metric: resource.kind === 'Pod' ? metricByPod.get(resource.name) : undefined
|
||||
})));
|
||||
setEvents(eventItems);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, 10000);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [selectedApp?.namespace]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const runtimeResources = resources.filter((resource) => ['Pod', 'Deployment', 'StatefulSet', 'DaemonSet'].includes(resource.kind));
|
||||
const healthy = runtimeResources.filter(isHealthy).length;
|
||||
const warnings = events.filter((event) => event.type === 'Warning').length;
|
||||
const restarts = resources.reduce((sum, resource) => sum + (resource.restarts ?? 0), 0);
|
||||
return { total: runtimeResources.length, healthy, warnings, restarts };
|
||||
}, [events, resources]);
|
||||
|
||||
const podMetrics = useMemo(() => resources
|
||||
.filter((resource) => resource.kind === 'Pod' && resource.metric)
|
||||
.map((resource) => ({
|
||||
name: resource.name.replace(`${selectedApp?.key?.toLowerCase() ?? ''}-`, ''),
|
||||
cpu: parseCpuMillicores(resource.metric?.cpu),
|
||||
memory: Math.round(parseMemoryMi(resource.metric?.memory))
|
||||
})), [resources, selectedApp?.key]);
|
||||
|
||||
const statusData = useMemo(() => {
|
||||
const byStatus = new Map<string, number>();
|
||||
resources.forEach((resource) => byStatus.set(resource.status || '-', (byStatus.get(resource.status || '-') ?? 0) + 1));
|
||||
return [...byStatus.entries()].map(([name, value]) => ({ name, value }));
|
||||
}, [resources]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
eyebrow="시스템"
|
||||
title="모니터링"
|
||||
description="선택한 앱 서비스의 런타임 상태, 리소스 사용량, 최근 이벤트를 확인합니다."
|
||||
actions={
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={refresh} disabled={loading}>
|
||||
<RotateCcw className={cn('size-4', loading && 'animate-spin')} />
|
||||
새로고침
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card className="mb-4 p-4">
|
||||
<label className="block max-w-md">
|
||||
<span className="text-xs font-medium text-muted-foreground">앱</span>
|
||||
<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"
|
||||
value={selectedAppId}
|
||||
onChange={(event) => setSelectedAppId(event.target.value)}
|
||||
>
|
||||
{apps.map((app) => (
|
||||
<option key={app.id} value={app.id}>{app.name} · {app.namespace}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</Card>
|
||||
|
||||
<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={Activity} label="Pod" value={String(resources.filter((resource) => resource.kind === 'Pod').length)} hint="실행 Pod 수" tone="blue" />
|
||||
<MetricCard icon={AlertTriangle} label="Warning 이벤트" value={String(summary.warnings)} hint="현재 네임스페이스 기준" tone={summary.warnings > 0 ? 'amber' : 'green'} />
|
||||
<MetricCard icon={RotateCcw} label="재시작" value={String(summary.restarts)} hint="Pod 재시작 합계" tone={summary.restarts > 0 ? 'amber' : 'green'} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 xl:grid-cols-[minmax(0,1.25fr)_minmax(360px,0.75fr)]">
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<CardHeader title="Pod 리소스" description="선택한 앱 네임스페이스의 Pod CPU/Memory 사용량입니다." />
|
||||
<div className="h-80 p-5">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={podMetrics}>
|
||||
<CartesianGrid stroke="rgba(148,163,184,0.16)" vertical={false} />
|
||||
<XAxis dataKey="name" tick={{ fill: '#94a3b8', fontSize: 11 }} tickLine={false} axisLine={false} />
|
||||
<YAxis tick={{ fill: '#94a3b8', fontSize: 11 }} tickLine={false} axisLine={false} />
|
||||
<Tooltip contentStyle={{ backgroundColor: 'hsl(224 20% 10%)', border: '1px solid hsl(222 15% 20%)', borderRadius: 8 }} />
|
||||
<Bar dataKey="cpu" name="CPU(m)" fill="#60a5fa" radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="memory" name="Memory(Mi)" fill="#34d399" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<CardHeader title="상태 분포" description="런타임 리소스 상태별 개수입니다." />
|
||||
<div className="h-80 p-5">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie data={statusData} dataKey="value" nameKey="name" innerRadius={62} outerRadius={100} paddingAngle={3}>
|
||||
{statusData.map((entry, index) => <Cell key={entry.name} fill={chartColors[index % chartColors.length]} />)}
|
||||
</Pie>
|
||||
<Tooltip contentStyle={{ backgroundColor: 'hsl(224 20% 10%)', border: '1px solid hsl(222 15% 20%)', borderRadius: 8 }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 xl:grid-cols-2">
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader title="리소스 상태" description="선택한 앱 네임스페이스의 주요 리소스입니다." />
|
||||
<div className="max-h-[420px] divide-y divide-border overflow-y-auto">
|
||||
{resources.map((resource) => (
|
||||
<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="flex items-center gap-2">
|
||||
<span className={cn('size-2.5 shrink-0 rounded-full', runtimeStatusDotClass(resource.status))} />
|
||||
<p className="truncate font-medium">{resource.kind}/{resource.name}</p>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{resource.ready || '-'} · 재시작 {resource.restarts ?? 0}</p>
|
||||
</div>
|
||||
<Badge tone={statusTone(resource.status)}>{resource.status || '-'}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader title="최근 이벤트" description="Warning 이벤트를 우선 확인합니다." />
|
||||
<div className="max-h-[420px] divide-y divide-border overflow-y-auto">
|
||||
{events.slice(0, 12).map((event, index) => (
|
||||
<div key={`${event.involvedObject}-${event.reason}-${index}`} className="px-5 py-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge tone={event.type === 'Warning' ? 'amber' : 'neutral'}>{event.type ?? '-'}</Badge>
|
||||
<p className="truncate font-medium">{event.reason ?? '-'}</p>
|
||||
</div>
|
||||
<p className="mt-2 line-clamp-2 text-xs leading-5 text-muted-foreground">{event.message ?? '-'}</p>
|
||||
</div>
|
||||
))}
|
||||
{!events.length ? <div className="px-5 py-12 text-center text-sm text-muted-foreground">표시할 이벤트가 없습니다.</div> : null}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<LoadingModal open={loadingVisible} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ icon: Icon, label, value, hint, tone }: { icon: typeof Server; label: string; value: string; hint: string; tone: 'green' | 'amber' | 'red' | 'blue' | 'neutral' }) {
|
||||
return (
|
||||
<Card className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
<Icon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="mt-4 flex items-end justify-between gap-3">
|
||||
<p className="text-3xl font-semibold tracking-tight">{value}</p>
|
||||
<Badge tone={tone}>{tone === 'green' ? '정상' : tone === 'amber' ? '주의' : tone === 'red' ? '장애' : '정보'}</Badge>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">{hint}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user