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:
+157
-27
@@ -5,7 +5,6 @@ import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircleHelp,
|
||||
ExternalLink,
|
||||
LogOut,
|
||||
Search,
|
||||
SlidersHorizontal
|
||||
@@ -42,6 +41,12 @@ 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 {
|
||||
InfrastructureClusterPage,
|
||||
InfrastructureNetworkPage,
|
||||
InfrastructureStoragePage,
|
||||
InfrastructureWorkloadsPage
|
||||
} from './pages/infrastructure/InfrastructurePages';
|
||||
import { LoadingModal, useMinimumLoading } from './pages/operations/operation-utils';
|
||||
import {
|
||||
AgentCommandsPage as AgentCommandsRestoredPage,
|
||||
@@ -83,6 +88,10 @@ type ViewKey =
|
||||
| 'tailscaleStatus'
|
||||
| 'tailscaleRoutes'
|
||||
| 'tailscaleAcl'
|
||||
| 'infraCluster'
|
||||
| 'infraWorkloads'
|
||||
| 'infraNetwork'
|
||||
| 'infraStorage'
|
||||
| 'agentRegistration'
|
||||
| 'agentInstallStatus'
|
||||
| 'agentSshKeys'
|
||||
@@ -116,6 +125,10 @@ const viewByLabel: Record<string, ViewKey> = {
|
||||
'노드 현황': 'tailscaleStatus',
|
||||
라우트: 'tailscaleRoutes',
|
||||
ACL: 'tailscaleAcl',
|
||||
클러스터: 'infraCluster',
|
||||
워크로드: 'infraWorkloads',
|
||||
네트워크: 'infraNetwork',
|
||||
스토리지: 'infraStorage',
|
||||
'장비 등록': 'agentRegistration',
|
||||
'설치 상태': 'agentInstallStatus',
|
||||
'SSH 키 배포': 'agentSshKeys',
|
||||
@@ -155,6 +168,10 @@ const pathByView: Record<ViewKey, string> = {
|
||||
tailscaleStatus: '/tailscale/status',
|
||||
tailscaleRoutes: '/tailscale/routes',
|
||||
tailscaleAcl: '/tailscale/acl',
|
||||
infraCluster: '/infrastructure/cluster',
|
||||
infraWorkloads: '/infrastructure/workloads',
|
||||
infraNetwork: '/infrastructure/network',
|
||||
infraStorage: '/infrastructure/storage',
|
||||
agentRegistration: '/agents/registration',
|
||||
agentInstallStatus: '/agents/install-status',
|
||||
agentSshKeys: '/agents/ssh-keys',
|
||||
@@ -300,6 +317,29 @@ type RuntimePodMetric = {
|
||||
memory: string;
|
||||
};
|
||||
|
||||
type ClusterMetricValue = {
|
||||
usage: string | null;
|
||||
capacity: string | null;
|
||||
percent: number | null;
|
||||
};
|
||||
|
||||
type ClusterNodeMetric = {
|
||||
name: string;
|
||||
status: string;
|
||||
cpu: ClusterMetricValue;
|
||||
memory: ClusterMetricValue;
|
||||
storage: ClusterMetricValue;
|
||||
};
|
||||
|
||||
type ClusterMetrics = {
|
||||
nodes: ClusterNodeMetric[];
|
||||
summary: {
|
||||
cpuPercent: number | null;
|
||||
memoryPercent: number | null;
|
||||
storagePercent: number | null;
|
||||
};
|
||||
};
|
||||
|
||||
type DashboardJob = {
|
||||
id: string;
|
||||
status: string;
|
||||
@@ -393,6 +433,10 @@ function loadRuntimePodMetrics(namespace: string) {
|
||||
return apiRequest<RuntimePodMetric[]>(`/runtime/metrics/pods?namespace=${encodeURIComponent(namespace)}`);
|
||||
}
|
||||
|
||||
function loadClusterMetrics() {
|
||||
return apiRequest<ClusterMetrics>('/runtime/metrics/cluster');
|
||||
}
|
||||
|
||||
const statusTone = {
|
||||
success: 'green',
|
||||
succeeded: 'green',
|
||||
@@ -426,13 +470,35 @@ const statusTone = {
|
||||
잠김: 'red',
|
||||
구성완료: 'green',
|
||||
검토필요: 'amber',
|
||||
완료: 'green'
|
||||
완료: 'green',
|
||||
성공: 'green',
|
||||
실패: 'red',
|
||||
취소: 'neutral'
|
||||
} as const;
|
||||
|
||||
function getTone(status: string) {
|
||||
return statusTone[status as keyof typeof statusTone] ?? 'neutral';
|
||||
}
|
||||
|
||||
function formatPipelineStatus(status?: string | null) {
|
||||
const labels: Record<string, string> = {
|
||||
success: '성공',
|
||||
succeeded: '성공',
|
||||
done: '성공',
|
||||
running: '실행 중',
|
||||
started: '실행 중',
|
||||
pending: '대기',
|
||||
blocked: '대기',
|
||||
created: '생성됨',
|
||||
failure: '실패',
|
||||
failed: '실패',
|
||||
error: '오류',
|
||||
killed: '중단'
|
||||
};
|
||||
const normalized = (status ?? '').toLowerCase();
|
||||
return labels[normalized] ?? status ?? '-';
|
||||
}
|
||||
|
||||
function formatRelativeTime(timestamp: number) {
|
||||
if (!timestamp) return '-';
|
||||
const seconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000));
|
||||
@@ -983,7 +1049,7 @@ function LauncherPanel({ versions, loading, onOpen }: { versions: ApiLauncherVer
|
||||
const activeVersion = versions.find((version) => version.isActive) ?? versions[0];
|
||||
|
||||
return (
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<Card className="flex min-h-[520px] min-w-0 flex-col self-stretch overflow-hidden">
|
||||
<CardHeader
|
||||
title="런처 버전 관리"
|
||||
description="스튜디오 메인 런처의 최신 버전, 최소 지원 버전, 배포 파일 상태를 관리합니다."
|
||||
@@ -1054,7 +1120,7 @@ function LauncherPanel({ versions, loading, onOpen }: { versions: ApiLauncherVer
|
||||
|
||||
function CommonAppsPanel({ pipelines, loading, onOpen }: { pipelines: CommonAppPipeline[]; loading: boolean; onOpen: () => void }) {
|
||||
return (
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<Card className="flex min-h-[520px] min-w-0 flex-col overflow-hidden">
|
||||
<CardHeader
|
||||
title="공통앱"
|
||||
description="공통앱의 최근 빌드 상태를 확인합니다."
|
||||
@@ -1085,7 +1151,7 @@ function CommonAppsPanel({ pipelines, loading, onOpen }: { pipelines: CommonAppP
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge tone={getTone(pipeline.status)}>{pipeline.status}</Badge>
|
||||
<Badge tone={getTone(pipeline.status)}>{formatPipelineStatus(pipeline.status)}</Badge>
|
||||
<span>{pipeline.branch}</span>
|
||||
<span>{formatRelativeTime(pipeline.createdAt)}</span>
|
||||
</div>
|
||||
@@ -1195,13 +1261,11 @@ function AppRegistryTable({
|
||||
action={<DashboardLinkButton onClick={onOpen} label="앱 관리로 이동" />}
|
||||
/>
|
||||
<div className="max-h-[444px] overflow-auto">
|
||||
<table className="w-full min-w-[980px] text-left text-sm">
|
||||
<table className="w-full min-w-[620px] text-left text-sm">
|
||||
<thead className="border-b border-border text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-5 py-3 font-medium">앱</th>
|
||||
<th className="px-5 py-3 font-medium">상태</th>
|
||||
<th className="px-5 py-3 font-medium">릴리즈</th>
|
||||
<th className="px-5 py-3 font-medium">저장소/경로</th>
|
||||
<th className="px-5 py-3 font-medium">워크로드</th>
|
||||
<th className="px-5 py-3 font-medium">구성요소</th>
|
||||
</tr>
|
||||
@@ -1214,15 +1278,9 @@ function AppRegistryTable({
|
||||
<SkeletonBlock className="h-4 w-32" />
|
||||
<SkeletonBlock className="mt-2 h-3 w-44" />
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<SkeletonBlock className="h-5 w-14 rounded-md" />
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<SkeletonBlock className="h-4 w-24" />
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<SkeletonBlock className="h-4 w-52" />
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<SkeletonBlock className="h-4 w-8" />
|
||||
</td>
|
||||
@@ -1233,12 +1291,11 @@ function AppRegistryTable({
|
||||
))
|
||||
) : dashboardApps.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-5 py-12 text-center text-muted-foreground">등록된 앱이 없습니다.</td>
|
||||
<td colSpan={4} className="px-5 py-12 text-center text-muted-foreground">등록된 앱이 없습니다.</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{!loading && dashboardApps.map((app) => {
|
||||
const latestRelease = latestReleaseByApp[app.id];
|
||||
const firstRepository = app.components?.find((component) => component.repository)?.repository;
|
||||
const workloadCount = app.components?.filter((component) => ['CONTAINER', 'DATABASE', 'MIGRATION'].includes(component.type)).length ?? 0;
|
||||
|
||||
return (
|
||||
@@ -1248,14 +1305,14 @@ function AppRegistryTable({
|
||||
<p className="mt-1 text-xs text-muted-foreground">{app.key} · {app.namespace ?? '네임스페이스 없음'}</p>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<Badge tone={getTone(app.status)}>{app.status}</Badge>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{latestRelease ? `${latestRelease.version} · ${formatJobStatus(latestRelease.status)}` : '-'}</td>
|
||||
<td className="px-5 py-3">
|
||||
<span className="inline-flex max-w-64 items-center gap-1 truncate text-muted-foreground">
|
||||
{firstRepository ?? '-'}
|
||||
{firstRepository ? <ExternalLink className="size-3 shrink-0" /> : null}
|
||||
</span>
|
||||
{latestRelease ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge tone={getTone(formatJobStatus(latestRelease.status))}>{formatJobStatus(latestRelease.status)}</Badge>
|
||||
<span className="font-medium text-foreground">{latestRelease.version}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3">{workloadCount}</td>
|
||||
<td className="px-5 py-3">{app.components?.length ?? 0}</td>
|
||||
@@ -1269,6 +1326,66 @@ function AppRegistryTable({
|
||||
);
|
||||
}
|
||||
|
||||
function ServerResourcePanel({ metrics, loading }: { metrics: ClusterMetrics | null; loading: boolean }) {
|
||||
const resources = [
|
||||
{ label: 'CPU', value: metrics?.summary.cpuPercent ?? null },
|
||||
{ label: 'Memory', value: metrics?.summary.memoryPercent ?? null },
|
||||
{ label: 'Storage', value: metrics?.summary.storagePercent ?? null }
|
||||
];
|
||||
const nodeCount = metrics?.nodes.length ?? 0;
|
||||
const nodeNames = metrics?.nodes.map((node) => node.name).join(', ') || '-';
|
||||
const primaryNode = metrics?.nodes[0] ?? null;
|
||||
|
||||
return (
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<CardHeader
|
||||
title="서버 리소스"
|
||||
description={nodeCount ? `k3s 노드 ${nodeCount}개 기준 · ${nodeNames}` : 'k3s 서버 자원 사용률'}
|
||||
/>
|
||||
<div className="flex min-h-0 flex-1 flex-col justify-between gap-8 p-5">
|
||||
<div className="space-y-5">
|
||||
{resources.map((resource) => (
|
||||
<div key={resource.label}>
|
||||
<div className="mb-2 flex items-center justify-between text-sm">
|
||||
<span className="font-medium text-foreground">{resource.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{loading ? '-' : resource.value === null ? '-' : `${resource.value}%`}</span>
|
||||
</div>
|
||||
{loading ? (
|
||||
<SkeletonBlock className="h-2 w-full rounded-full" />
|
||||
) : (
|
||||
<Progress value={resource.value ?? 0} tone={resource.value !== null && resource.value >= 85 ? 'red' : resource.value !== null && resource.value >= 70 ? 'amber' : 'green'} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-muted/20 px-3 py-3">
|
||||
<div className="grid gap-2 text-xs">
|
||||
<ServerResourceDetail label="노드 상태" value={loading ? '-' : primaryNode?.status ?? '-'} />
|
||||
<ServerResourceDetail label="CPU" value={formatResourceDetail(primaryNode?.cpu)} />
|
||||
<ServerResourceDetail label="Memory" value={formatResourceDetail(primaryNode?.memory)} />
|
||||
<ServerResourceDetail label="Storage" value={formatResourceDetail(primaryNode?.storage)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ServerResourceDetail({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="min-w-0 truncate text-right font-medium text-foreground">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatResourceDetail(metric?: ClusterMetricValue | null) {
|
||||
if (!metric) return '-';
|
||||
if (!metric.usage && !metric.capacity) return '-';
|
||||
return `${metric.usage ?? '-'} / ${metric.capacity ?? '-'}`;
|
||||
}
|
||||
|
||||
function ContainerPanel({ resources, loading, onOpen }: { resources: DashboardRuntimeResource[]; loading: boolean; onOpen: () => void }) {
|
||||
const visibleResources = resources.filter((resource) => !isIgnoredRuntimeResource(resource));
|
||||
|
||||
@@ -1416,6 +1533,7 @@ function DashboardPage({
|
||||
const [commonAppPipelines, setCommonAppPipelines] = useState<CommonAppPipeline[]>([]);
|
||||
const [dashboardNotices, setDashboardNotices] = useState<ApiNotice[]>([]);
|
||||
const [runtimeResources, setRuntimeResources] = useState<DashboardRuntimeResource[]>([]);
|
||||
const [clusterMetrics, setClusterMetrics] = useState<ClusterMetrics | null>(null);
|
||||
const [dashboardLoading, setDashboardLoading] = useState(true);
|
||||
const loadingVisible = useMinimumLoading(dashboardLoading);
|
||||
|
||||
@@ -1424,13 +1542,14 @@ function DashboardPage({
|
||||
|
||||
async function loadDashboard() {
|
||||
setDashboardLoading(true);
|
||||
const [versionsResult, appsResult, releasesResult, sessionsResult, noticesResult, commonAppsResult] = await Promise.allSettled([
|
||||
const [versionsResult, appsResult, releasesResult, sessionsResult, noticesResult, commonAppsResult, clusterMetricsResult] = await Promise.allSettled([
|
||||
loadLauncherVersions(),
|
||||
loadApps(),
|
||||
loadAppReleases(),
|
||||
loadUserSessions(),
|
||||
loadNotices(),
|
||||
loadCommonAppPipelines()
|
||||
loadCommonAppPipelines(),
|
||||
loadClusterMetrics()
|
||||
]);
|
||||
|
||||
const nextVersions = versionsResult.status === 'fulfilled' ? versionsResult.value : [];
|
||||
@@ -1439,6 +1558,7 @@ function DashboardPage({
|
||||
const nextSessions = sessionsResult.status === 'fulfilled' ? sessionsResult.value : [];
|
||||
const nextNotices = noticesResult.status === 'fulfilled' ? noticesResult.value : [];
|
||||
const nextCommonApps = commonAppsResult.status === 'fulfilled' ? commonAppsResult.value : [];
|
||||
const nextClusterMetrics = clusterMetricsResult.status === 'fulfilled' ? clusterMetricsResult.value : null;
|
||||
const nextActiveWork = await loadActiveWorkSummary(nextReleases);
|
||||
const namespaceApps = nextApps.filter((app) => app.namespace);
|
||||
const [runtimeResults, metricResults] = await Promise.all([
|
||||
@@ -1480,6 +1600,7 @@ function DashboardPage({
|
||||
setCommonAppPipelines(nextCommonApps);
|
||||
setDashboardNotices(nextNotices);
|
||||
setRuntimeResources(nextRuntimeResources);
|
||||
setClusterMetrics(nextClusterMetrics);
|
||||
setDashboardLoading(false);
|
||||
}
|
||||
|
||||
@@ -1508,8 +1629,9 @@ function DashboardPage({
|
||||
<CommonAppsPanel pipelines={commonAppPipelines} loading={dashboardLoading} onOpen={onCommonBuildsOpen} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="mt-4 grid items-stretch gap-4 xl:grid-cols-[minmax(0,1.45fr)_minmax(320px,0.55fr)]">
|
||||
<AppRegistryTable apps={dashboardApps} releases={dashboardReleases} loading={dashboardLoading} onOpen={onAppManagementOpen} />
|
||||
<ServerResourcePanel metrics={clusterMetrics} loading={dashboardLoading} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 xl:grid-cols-2">
|
||||
@@ -1635,6 +1757,14 @@ function App() {
|
||||
return <TailscaleRoutesRestoredPage />;
|
||||
case 'tailscaleAcl':
|
||||
return <TailscaleAclRestoredPage />;
|
||||
case 'infraCluster':
|
||||
return <InfrastructureClusterPage />;
|
||||
case 'infraWorkloads':
|
||||
return <InfrastructureWorkloadsPage />;
|
||||
case 'infraNetwork':
|
||||
return <InfrastructureNetworkPage />;
|
||||
case 'infraStorage':
|
||||
return <InfrastructureStoragePage />;
|
||||
case 'agentRegistration':
|
||||
return <AgentRegistrationRestoredPage />;
|
||||
case 'agentInstallStatus':
|
||||
|
||||
Reference in New Issue
Block a user