refine dashboard infrastructure and resource status
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -0,0 +1,328 @@
|
||||
import { RotateCcw } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { DataTableCard, type DataTableColumn, type DataTableFilter } from '../../components/data-table';
|
||||
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 { RuntimePodMetric, RuntimeResource, RuntimeResourceResponse } from '../../types/runtime';
|
||||
import { loadApps, loadRuntimeEvents, loadRuntimePodMetrics, loadRuntimeResources } from '../runtime/runtime-api';
|
||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||
|
||||
const defaultNamespaces = ['studio-system', 'stdo-lms', 'stdo-ads-ide'];
|
||||
|
||||
type NamespaceRuntime = RuntimeResourceResponse & {
|
||||
failed?: boolean;
|
||||
};
|
||||
|
||||
type InfrastructureData = {
|
||||
namespaces: string[];
|
||||
runtimes: NamespaceRuntime[];
|
||||
metrics: RuntimePodMetric[];
|
||||
};
|
||||
|
||||
type ResourceRow = RuntimeResource & {
|
||||
id: string;
|
||||
namespace: string;
|
||||
metric?: RuntimePodMetric;
|
||||
};
|
||||
|
||||
function uniqueNamespaces(values: Array<string | null | undefined>) {
|
||||
return [...new Set(values.filter(Boolean) as string[])].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
function isWorkload(resource: RuntimeResource) {
|
||||
return ['Deployment', 'StatefulSet', 'DaemonSet', 'Pod', 'Job', 'CronJob', 'ReplicaSet'].includes(resource.kind);
|
||||
}
|
||||
|
||||
function isNetwork(resource: RuntimeResource) {
|
||||
return ['Service', 'Ingress', 'Endpoint', 'Endpoints'].includes(resource.kind);
|
||||
}
|
||||
|
||||
function isStorage(resource: RuntimeResource) {
|
||||
return ['PersistentVolumeClaim', 'PersistentVolume', 'StorageClass'].includes(resource.kind);
|
||||
}
|
||||
|
||||
function resourceStatusTone(status: string): 'neutral' | 'green' | 'amber' | 'red' | 'blue' {
|
||||
const normalized = status.toLowerCase();
|
||||
if (['running', 'ready', 'active', 'bound'].some((word) => normalized.includes(word))) return 'green';
|
||||
if (['pending', 'progressing', 'containercreating'].some((word) => normalized.includes(word))) return 'amber';
|
||||
if (['failed', 'error', 'crash', 'unhealthy', 'notready'].some((word) => normalized.includes(word))) return 'red';
|
||||
if (['completed', 'succeeded'].some((word) => normalized.includes(word))) return 'neutral';
|
||||
return 'blue';
|
||||
}
|
||||
|
||||
function metricFor(resource: ResourceRow, metrics: RuntimePodMetric[]) {
|
||||
if (resource.kind !== 'Pod') return undefined;
|
||||
return metrics.find((metric) => metric.namespace === resource.namespace && metric.pod === resource.name);
|
||||
}
|
||||
|
||||
function useInfrastructureData() {
|
||||
const [data, setData] = useState<InfrastructureData>({ namespaces: defaultNamespaces, runtimes: [], metrics: [] });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const loadingVisible = useMinimumLoading(loading);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
loadApps().catch(() => []),
|
||||
loadRuntimePodMetrics().catch(() => [] as RuntimePodMetric[])
|
||||
])
|
||||
.then(async ([apps, metrics]) => {
|
||||
const namespaces = uniqueNamespaces([...defaultNamespaces, ...apps.map((app) => app.namespace)]);
|
||||
const runtimes = await Promise.all(
|
||||
namespaces.map((namespace) => (
|
||||
loadRuntimeResources(namespace)
|
||||
.then((runtime) => runtime)
|
||||
.catch(() => ({ namespace, exists: false, failed: true, summary: { pods: 0, deployments: 0, statefulSets: 0, services: 0, persistentVolumeClaims: 0 }, resources: [] }))
|
||||
))
|
||||
);
|
||||
|
||||
setData({ namespaces, runtimes, metrics });
|
||||
setFailed(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setFailed(true);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return { data, loading, loadingVisible, failed, refresh };
|
||||
}
|
||||
|
||||
function rowsFromData(data: InfrastructureData, predicate: (resource: RuntimeResource) => boolean) {
|
||||
return data.runtimes.flatMap((runtime) => (
|
||||
runtime.resources
|
||||
.filter(predicate)
|
||||
.map((resource) => {
|
||||
const row = {
|
||||
...resource,
|
||||
namespace: runtime.namespace,
|
||||
id: `${runtime.namespace}:${resource.kind}:${resource.name}`
|
||||
};
|
||||
return { ...row, metric: metricFor(row, data.metrics) };
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
function ResourceStatus({ status }: { status: string }) {
|
||||
return (
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<span className={cn('size-2.5 shrink-0 rounded-full', runtimeStatusDotClass(status))} />
|
||||
<Badge tone={resourceStatusTone(status)}>{status || '-'}</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NamespaceFilter(rows: ResourceRow[]): DataTableFilter<ResourceRow> {
|
||||
return {
|
||||
key: 'namespace',
|
||||
label: '네임스페이스',
|
||||
options: uniqueNamespaces(rows.map((row) => row.namespace)).map((namespace) => ({ label: namespace, value: namespace })),
|
||||
getValue: (row) => row.namespace
|
||||
};
|
||||
}
|
||||
|
||||
function KindFilter(rows: ResourceRow[]): DataTableFilter<ResourceRow> {
|
||||
return {
|
||||
key: 'kind',
|
||||
label: '종류',
|
||||
options: uniqueNamespaces(rows.map((row) => row.kind)).map((kind) => ({ label: kind, value: kind })),
|
||||
getValue: (row) => row.kind
|
||||
};
|
||||
}
|
||||
|
||||
function InfraPageShell({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
loading,
|
||||
loadingVisible,
|
||||
failed,
|
||||
onRefresh
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
children: ReactNode;
|
||||
loading: boolean;
|
||||
loadingVisible: boolean;
|
||||
failed: boolean;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
eyebrow="인프라"
|
||||
title={title}
|
||||
description={description}
|
||||
actions={
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={onRefresh} disabled={loading}>
|
||||
<RotateCcw className={cn('size-4', loading && 'animate-spin')} />
|
||||
새로고침
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{failed ? (
|
||||
<div className="mb-4 rounded-md border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
|
||||
인프라 데이터를 불러오지 못했습니다.
|
||||
</div>
|
||||
) : null}
|
||||
{children}
|
||||
<LoadingModal open={loadingVisible} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ResourceTable({
|
||||
title,
|
||||
description,
|
||||
rows,
|
||||
loading,
|
||||
emptyMessage
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
rows: ResourceRow[];
|
||||
loading: boolean;
|
||||
emptyMessage: string;
|
||||
}) {
|
||||
const columns = useMemo<Array<DataTableColumn<ResourceRow>>>(
|
||||
() => [
|
||||
{ key: 'namespace', header: '네임스페이스', className: 'w-44 px-5 py-3', cell: (row) => <span className="font-medium">{row.namespace}</span> },
|
||||
{ key: 'kind', header: '종류', className: 'w-40 px-5 py-3', cell: (row) => <Badge tone="neutral">{row.kind}</Badge> },
|
||||
{ key: 'name', header: '이름', className: 'min-w-[240px] px-5 py-3', cell: (row) => <span className="font-medium">{row.name}</span> },
|
||||
{ key: 'status', header: '상태', className: 'w-44 px-5 py-3', cell: (row) => <ResourceStatus status={row.status} /> },
|
||||
{ key: 'ready', header: 'Ready', className: 'w-28 px-5 py-3', cell: (row) => <span className="text-muted-foreground">{row.ready || '-'}</span> },
|
||||
{ key: 'restarts', header: '재시작', className: 'w-24 px-5 py-3', cell: (row) => <span className="text-muted-foreground">{row.restarts ?? '-'}</span> },
|
||||
{ key: 'metric', header: '사용량', className: 'w-40 px-5 py-3', cell: (row) => <span className="text-xs text-muted-foreground">{row.metric ? `${row.metric.cpu} / ${row.metric.memory}` : '-'}</span> },
|
||||
{ key: 'age', header: 'Age', className: 'w-28 px-5 py-3', cell: (row) => <span className="text-muted-foreground">{row.age || '-'}</span> },
|
||||
{ key: 'detail', header: '상세', className: 'min-w-[260px] px-5 py-3', cell: (row) => <span className="text-xs text-muted-foreground">{row.detail || '-'}</span> }
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const filters = useMemo(() => [NamespaceFilter(rows), KindFilter(rows)], [rows]);
|
||||
|
||||
return (
|
||||
<DataTableCard
|
||||
title={title}
|
||||
description={description}
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
filters={filters}
|
||||
searchPlaceholder="네임스페이스, 이름, 상태 검색"
|
||||
getSearchText={(row) => `${row.namespace} ${row.kind} ${row.name} ${row.status} ${row.detail}`}
|
||||
getRowKey={(row) => row.id}
|
||||
pageSize={10}
|
||||
minWidth={1280}
|
||||
loading={loading}
|
||||
emptyMessage={emptyMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function InfrastructureClusterPage() {
|
||||
const { data, loading, loadingVisible, failed, refresh } = useInfrastructureData();
|
||||
|
||||
const totals = useMemo(() => data.runtimes.reduce(
|
||||
(acc, runtime) => ({
|
||||
namespaces: acc.namespaces + 1,
|
||||
resources: acc.resources + runtime.resources.length,
|
||||
pods: acc.pods + runtime.summary.pods,
|
||||
deployments: acc.deployments + runtime.summary.deployments,
|
||||
services: acc.services + runtime.summary.services,
|
||||
pvc: acc.pvc + runtime.summary.persistentVolumeClaims,
|
||||
failed: acc.failed + (runtime.failed || !runtime.exists ? 1 : 0)
|
||||
}),
|
||||
{ namespaces: 0, resources: 0, pods: 0, deployments: 0, services: 0, pvc: 0, failed: 0 }
|
||||
), [data.runtimes]);
|
||||
|
||||
return (
|
||||
<InfraPageShell title="클러스터" description="네임스페이스별 런타임 리소스 요약을 조회합니다." loading={loading} loadingVisible={loadingVisible} failed={failed} onRefresh={refresh}>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-6">
|
||||
{[
|
||||
['네임스페이스', totals.namespaces],
|
||||
['전체 리소스', totals.resources],
|
||||
['Pod', totals.pods],
|
||||
['Deployment', totals.deployments],
|
||||
['Service', totals.services],
|
||||
['PVC', totals.pvc]
|
||||
].map(([label, value]) => (
|
||||
<Card key={label} className="px-5 py-4">
|
||||
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-foreground">{value}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card className="mt-4 overflow-hidden">
|
||||
<CardHeader title="네임스페이스" description="조회 가능한 네임스페이스의 리소스 구성입니다." />
|
||||
<div className="divide-y divide-border">
|
||||
{data.runtimes.map((runtime) => (
|
||||
<div key={runtime.namespace} className="grid gap-3 px-5 py-4 text-sm md:grid-cols-[minmax(180px,1fr)_repeat(5,minmax(80px,auto))] md:items-center">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn('size-2.5 rounded-full', runtime.failed || !runtime.exists ? 'bg-rose-400 shadow-[0_0_0_3px_rgba(251,113,133,0.15)]' : 'bg-emerald-400 shadow-[0_0_0_3px_rgba(52,211,153,0.15)]')} />
|
||||
<p className="truncate font-semibold">{runtime.namespace}</p>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{runtime.failed ? '조회 실패' : runtime.exists ? '조회 가능' : '네임스페이스 없음'}</p>
|
||||
</div>
|
||||
<SummaryValue label="Pod" value={runtime.summary.pods} />
|
||||
<SummaryValue label="Deployment" value={runtime.summary.deployments} />
|
||||
<SummaryValue label="StatefulSet" value={runtime.summary.statefulSets} />
|
||||
<SummaryValue label="Service" value={runtime.summary.services} />
|
||||
<SummaryValue label="PVC" value={runtime.summary.persistentVolumeClaims} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</InfraPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryValue({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="mt-1 font-semibold text-foreground">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InfrastructureWorkloadsPage() {
|
||||
const { data, loading, loadingVisible, failed, refresh } = useInfrastructureData();
|
||||
const rows = useMemo(() => rowsFromData(data, isWorkload), [data]);
|
||||
|
||||
return (
|
||||
<InfraPageShell title="워크로드" description="Deployment, StatefulSet, Pod 등 실행 리소스를 조회합니다." loading={loading} loadingVisible={loadingVisible} failed={failed} onRefresh={refresh}>
|
||||
<ResourceTable title="워크로드 목록" description="실행 상태, Ready, 재시작 횟수와 Pod 메트릭을 확인합니다." rows={rows} loading={loading} emptyMessage="표시할 워크로드가 없습니다." />
|
||||
</InfraPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function InfrastructureNetworkPage() {
|
||||
const { data, loading, loadingVisible, failed, refresh } = useInfrastructureData();
|
||||
const rows = useMemo(() => rowsFromData(data, isNetwork), [data]);
|
||||
|
||||
return (
|
||||
<InfraPageShell title="네트워크" description="Service, Ingress 등 네트워크 리소스를 조회합니다." loading={loading} loadingVisible={loadingVisible} failed={failed} onRefresh={refresh}>
|
||||
<ResourceTable title="네트워크 리소스" description="서비스와 인그레스 연결 정보를 확인합니다." rows={rows} loading={loading} emptyMessage="표시할 네트워크 리소스가 없습니다." />
|
||||
</InfraPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function InfrastructureStoragePage() {
|
||||
const { data, loading, loadingVisible, failed, refresh } = useInfrastructureData();
|
||||
const rows = useMemo(() => rowsFromData(data, isStorage), [data]);
|
||||
|
||||
return (
|
||||
<InfraPageShell title="스토리지" description="PVC, PV, StorageClass 등 저장소 리소스를 조회합니다." loading={loading} loadingVisible={loadingVisible} failed={failed} onRefresh={refresh}>
|
||||
<ResourceTable title="스토리지 리소스" description="영구 볼륨과 스토리지 연결 상태를 확인합니다." rows={rows} loading={loading} emptyMessage="표시할 스토리지 리소스가 없습니다." />
|
||||
</InfraPageShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user