@t3q-ui/design-system 0.0.0-stage → 1.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,344 @@
1
+ /**
2
+ * TDS 대시보드 예제 — "헤더(새로고침·갱신 시각) → 요약 타일 → 추이 차트 2개(기간 선택) → 표 → 최근 이벤트" 흐름.
3
+ * 이 파일은 TDS 저장소의 타입 검사(`pnpm typecheck`)와 렌더 테스트를 통과한 코드다. 새 대시보드는 이 구조에서 시작한다.
4
+ *
5
+ * 이 예제가 보여 주는 것 (자주 틀리는 곳):
6
+ * - DashboardLayout 이 헤더·요약 타일·섹션 간격·로딩·오류·없음 상태를 그린다. 타일·섹션 제목을 직접 만들지 않는다
7
+ * - 사용률 타일은 `ratio`(0~1 → 진행 막대) + `tone`(80% 초과면 'warning') → 숫자 색·경고 아이콘·막대 색이 함께 바뀐다
8
+ * - 새로고침은 `titleRight` 에 `PageHeaderRefreshButton`(아이콘 버튼 + "방금 업데이트" 라벨) — 목록·상세 화면과 같은 모양
9
+ * - 전체를 못 불러왔으면 `error` + `onRetry`, 데이터가 전혀 없으면 `empty` → 패턴이 아이콘·제목·설명·다시 시도 상자로 그린다.
10
+ * Alert 줄이나 `<div>` 로 직접 만들지 않는다. 패널 하나만 비었으면 `DashboardPanel empty`
11
+ * - 차트 패널은 `DashboardSection columns={2}` 안에 `DashboardPanel`(제목·현재값·단위·최고값) → 차트는 소비 앱 몫(여기서는 SVG 면 채우기 + "경고 기준 80%" 라벨)
12
+ * - 기간 선택은 섹션 `action` 에 `ToggleGroup type='single'`
13
+ * - 표는 `AgGridTable`, 80% 초과 셀은 `Badge variant='warning'`(아이콘 + 값)으로 경고 — 글자색을 직접 칠하지 않는다.
14
+ * `warning30` 같은 30단계는 채움·배경용이라 흰 배경에 글자로 쓰면 대비가 1.75:1 밖에 안 된다(axe color-contrast)
15
+ * - 최근 이벤트는 `EventList`(레벨 색 띠 + Badge + 상대 시각)
16
+ */
17
+ import { useCallback, useEffect, useState } from 'react'
18
+ import {
19
+ AgGridTable,
20
+ Badge,
21
+ EventList,
22
+ LucideIcon,
23
+ PageHeaderRefreshButton,
24
+ ToggleGroup,
25
+ ToggleGroupItem,
26
+ } from '@t3q-ui/design-system'
27
+ import {
28
+ DashboardLayout,
29
+ DashboardPanel,
30
+ DashboardSection,
31
+ type DashboardStat,
32
+ } from '@t3q-ui/design-system/patterns'
33
+ import type { ColDef, ICellRendererParams } from 'ag-grid-community'
34
+
35
+ export type Period = '1h' | '6h' | '24h'
36
+ type UsagePoint = { time: string; cpuPercent: number; memoryPercent: number }
37
+ type Node = {
38
+ name: string
39
+ status: 'Ready' | 'NotReady'
40
+ cpuPercent: number | null
41
+ memoryPercent: number | null
42
+ gpu: { used: number; total: number } | null
43
+ }
44
+ type ClusterEvent = {
45
+ id: string
46
+ time: string
47
+ level: 'info' | 'warning' | 'error'
48
+ message: string
49
+ }
50
+ /** 목 API 응답. null 이면 "데이터 없음" */
51
+ export type ResourceData = {
52
+ cpu: { used: number; total: number }
53
+ memory: { used: number; total: number }
54
+ gpu: { used: number; total: number }
55
+ runningJobs: number
56
+ usage: UsagePoint[]
57
+ nodes: Node[]
58
+ events: ClusterEvent[]
59
+ } | null
60
+
61
+ const WARN = 0.8
62
+ const PERIODS: { value: Period; label: string }[] = [
63
+ { value: '1h', label: '1시간' },
64
+ { value: '6h', label: '6시간' },
65
+ { value: '24h', label: '24시간' },
66
+ ]
67
+ const pct = (ratio: number) => `${(ratio * 100).toFixed(1)}%`
68
+ const tone = (ratio: number): DashboardStat['tone'] => (ratio > WARN ? 'warning' : 'default')
69
+
70
+ /** "3분 전" 식 상대 시각 — 이벤트 목록의 `time` 은 소비 앱이 포맷해 넘긴다 */
71
+ function relative(iso: string, now: number) {
72
+ const min = Math.max(0, Math.round((now - new Date(iso).getTime()) / 60_000))
73
+ if (min < 1) return '방금'
74
+ if (min < 60) return `${min}분 전`
75
+ return `${Math.floor(min / 60)}시간 ${min % 60}분 전`
76
+ }
77
+
78
+ /** 면 채우기 추이 차트 (소비 앱 몫 — 차트 라이브러리 없이 SVG). 경고 기준선에 라벨을 붙인다 */
79
+ function UsageChart({
80
+ points,
81
+ pick,
82
+ label,
83
+ }: {
84
+ points: UsagePoint[]
85
+ pick: (p: UsagePoint) => number
86
+ label: string
87
+ }) {
88
+ const W = 600
89
+ const H = 160
90
+ const PAD = { left: 40, right: 12, top: 12, bottom: 24 }
91
+ const x = (i: number) =>
92
+ PAD.left + (i / Math.max(1, points.length - 1)) * (W - PAD.left - PAD.right)
93
+ const y = (v: number) => PAD.top + (1 - v / 100) * (H - PAD.top - PAD.bottom)
94
+ const line = points.map((p, i) => `${i ? 'L' : 'M'}${x(i)},${y(pick(p))}`).join(' ')
95
+ const area = `${line} L${x(points.length - 1)},${y(0)} L${x(0)},${y(0)} Z`
96
+ const last = points.length ? pick(points[points.length - 1]) : 0
97
+ return (
98
+ <svg
99
+ viewBox={`0 0 ${W} ${H}`}
100
+ className='h-[160px] w-full'
101
+ role='img'
102
+ aria-label={`${label} 추이. 최근 ${last}%`}
103
+ >
104
+ {[0, 25, 50, 75, 100].map((t) => (
105
+ <g key={t}>
106
+ <line
107
+ x1={PAD.left}
108
+ x2={W - PAD.right}
109
+ y1={y(t)}
110
+ y2={y(t)}
111
+ className='stroke-sys-divider-subtler'
112
+ />
113
+ <text
114
+ x={PAD.left - 6}
115
+ y={y(t)}
116
+ textAnchor='end'
117
+ dominantBaseline='middle'
118
+ className='fill-sys-text-subtler text-[10px]'
119
+ >
120
+ {t}%
121
+ </text>
122
+ </g>
123
+ ))}
124
+ <line
125
+ x1={PAD.left}
126
+ x2={W - PAD.right}
127
+ y1={y(WARN * 100)}
128
+ y2={y(WARN * 100)}
129
+ className='stroke-warning30'
130
+ strokeDasharray='4 4'
131
+ />
132
+ <text x={PAD.left + 4} y={y(WARN * 100) - 4} className='fill-warning40 text-[10px]'>
133
+ 경고 기준 {WARN * 100}%
134
+ </text>
135
+ <path d={area} className='fill-primary30' fillOpacity={0.12} />
136
+ <path d={line} className='stroke-primary30' strokeWidth={2} fill='none' />
137
+ {points.length ? (
138
+ <text
139
+ x={x(points.length - 1)}
140
+ y={y(last) - 6}
141
+ textAnchor='end'
142
+ className='fill-sys-text-main text-[11px]'
143
+ >
144
+ {last}%
145
+ </text>
146
+ ) : null}
147
+ </svg>
148
+ )
149
+ }
150
+
151
+ function WarnCell({ value, text }: { value: number | null; text: string }) {
152
+ if (value == null) return <span>-</span>
153
+ return value > WARN * 100 ? (
154
+ <Badge variant='warning'>
155
+ <LucideIcon iconComponentName='TriangleAlert' aria-hidden size={12} />
156
+ {text}
157
+ </Badge>
158
+ ) : (
159
+ <span>{text}</span>
160
+ )
161
+ }
162
+
163
+ const NODE_COLUMNS: ColDef<Node>[] = [
164
+ { field: 'name', headerName: '이름', flex: 1, minWidth: 160 },
165
+ {
166
+ field: 'status',
167
+ headerName: '상태',
168
+ width: 120,
169
+ cellRenderer: (p: ICellRendererParams<Node>) =>
170
+ p.data ? (
171
+ <Badge variant={p.data.status === 'Ready' ? 'success' : 'danger'}>
172
+ {p.data.status === 'Ready' ? (
173
+ <LucideIcon iconComponentName='circle-check' />
174
+ ) : (
175
+ <LucideIcon iconComponentName='circle-x' />
176
+ )}
177
+ {p.data.status}
178
+ </Badge>
179
+ ) : null,
180
+ },
181
+ // 숫자 열은 type: 'number' — 셀·제목 오른쪽 정렬(셀 렌더러가 있어도 적용된다)
182
+ {
183
+ field: 'cpuPercent',
184
+ headerName: 'CPU(%)',
185
+ width: 120,
186
+ type: 'number',
187
+ cellRenderer: (p: ICellRendererParams<Node>) =>
188
+ p.data ? <WarnCell value={p.data.cpuPercent} text={`${p.data.cpuPercent ?? ''}%`} /> : null,
189
+ },
190
+ {
191
+ field: 'memoryPercent',
192
+ headerName: '메모리(%)',
193
+ width: 120,
194
+ type: 'number',
195
+ cellRenderer: (p: ICellRendererParams<Node>) =>
196
+ p.data ? (
197
+ <WarnCell value={p.data.memoryPercent} text={`${p.data.memoryPercent ?? ''}%`} />
198
+ ) : null,
199
+ },
200
+ {
201
+ field: 'gpu',
202
+ headerName: 'GPU(사용/전체)',
203
+ width: 140,
204
+ type: 'number',
205
+ valueFormatter: (p) => (p.data?.gpu ? `${p.data.gpu.used} / ${p.data.gpu.total}` : '-'),
206
+ },
207
+ ]
208
+
209
+ export function ResourceDashboardPage({
210
+ fetchResources,
211
+ }: {
212
+ fetchResources: (period: Period) => Promise<ResourceData>
213
+ }) {
214
+ const [period, setPeriod] = useState<Period>('1h')
215
+ const [data, setData] = useState<ResourceData | undefined>() // undefined = 로딩 중, null = 데이터 없음
216
+ const [error, setError] = useState<string | null>(null)
217
+ const [updatedAt, setUpdatedAt] = useState<number | undefined>()
218
+ const [refreshing, setRefreshing] = useState(false)
219
+
220
+ const load = useCallback(() => {
221
+ setError(null)
222
+ setRefreshing(true)
223
+ fetchResources(period)
224
+ .then((d) => {
225
+ setData(d)
226
+ setUpdatedAt(Date.now())
227
+ })
228
+ .catch(() => setError('리소스 서버에 연결할 수 없습니다. (503 Service Unavailable)'))
229
+ .finally(() => setRefreshing(false))
230
+ }, [fetchResources, period])
231
+ useEffect(load, [load])
232
+
233
+ const now = updatedAt ?? Date.now()
234
+ const stats: DashboardStat[] | undefined = data
235
+ ? [
236
+ {
237
+ label: 'CPU 사용률',
238
+ value: pct(data.cpu.used / data.cpu.total),
239
+ caption: `${data.cpu.used} / ${data.cpu.total} 코어`,
240
+ ratio: data.cpu.used / data.cpu.total,
241
+ tone: tone(data.cpu.used / data.cpu.total),
242
+ },
243
+ {
244
+ label: '메모리 사용률',
245
+ value: pct(data.memory.used / data.memory.total),
246
+ caption: `${data.memory.used.toLocaleString()} / ${data.memory.total.toLocaleString()} GB`,
247
+ ratio: data.memory.used / data.memory.total,
248
+ tone: tone(data.memory.used / data.memory.total),
249
+ },
250
+ {
251
+ label: 'GPU 사용률',
252
+ value: pct(data.gpu.used / data.gpu.total),
253
+ caption: `${data.gpu.used} / ${data.gpu.total} 개 사용 중`,
254
+ ratio: data.gpu.used / data.gpu.total,
255
+ tone: tone(data.gpu.used / data.gpu.total),
256
+ },
257
+ { label: '실행 중 작업', value: `${data.runningJobs}개`, caption: '학습·추론·노트북 포함' },
258
+ ]
259
+ : undefined
260
+ const latest = data?.usage[data.usage.length - 1]
261
+ const peak = (pick: (p: UsagePoint) => number) => Math.max(...(data?.usage ?? []).map(pick))
262
+
263
+ return (
264
+ <DashboardLayout
265
+ title='리소스'
266
+ description='클러스터의 CPU·메모리·GPU 사용 현황과 노드 상태, 최근 이벤트를 확인합니다.'
267
+ titleRight={
268
+ <PageHeaderRefreshButton onClick={load} lastUpdatedAt={updatedAt} refreshing={refreshing} />
269
+ }
270
+ stats={stats}
271
+ loading={data === undefined && !error}
272
+ error={error}
273
+ onRetry={load}
274
+ empty={
275
+ data === null ? '클러스터에 노드가 등록되면 사용 현황이 여기에 나타납니다.' : undefined
276
+ }
277
+ >
278
+ <DashboardSection
279
+ title='사용률 추이'
280
+ columns={2}
281
+ action={
282
+ <ToggleGroup
283
+ type='single'
284
+ variant='outline'
285
+ aria-label='조회 기간'
286
+ value={period}
287
+ onValueChange={(v) => v && setPeriod(v as Period)}
288
+ >
289
+ {PERIODS.map((p) => (
290
+ <ToggleGroupItem key={p.value} value={p.value}>
291
+ {p.label}
292
+ </ToggleGroupItem>
293
+ ))}
294
+ </ToggleGroup>
295
+ }
296
+ >
297
+ <DashboardPanel
298
+ title='CPU 사용률'
299
+ value={latest ? latest.cpuPercent : undefined}
300
+ unit='%'
301
+ stats={data ? [{ label: '최고', value: `${peak((p) => p.cpuPercent)}%` }] : undefined}
302
+ loading={data === undefined}
303
+ empty={data && data.usage.length === 0 ? true : undefined}
304
+ >
305
+ {data?.usage.length ? (
306
+ <UsageChart points={data.usage} pick={(p) => p.cpuPercent} label='CPU 사용률' />
307
+ ) : null}
308
+ </DashboardPanel>
309
+ <DashboardPanel
310
+ title='메모리 사용률'
311
+ value={latest ? latest.memoryPercent : undefined}
312
+ unit='%'
313
+ stats={data ? [{ label: '최고', value: `${peak((p) => p.memoryPercent)}%` }] : undefined}
314
+ loading={data === undefined}
315
+ empty={data && data.usage.length === 0 ? true : undefined}
316
+ >
317
+ {data?.usage.length ? (
318
+ <UsageChart points={data.usage} pick={(p) => p.memoryPercent} label='메모리 사용률' />
319
+ ) : null}
320
+ </DashboardPanel>
321
+ </DashboardSection>
322
+
323
+ <DashboardSection title='노드'>
324
+ <AgGridTable
325
+ rowData={data?.nodes ?? []}
326
+ columnDefs={NODE_COLUMNS}
327
+ loading={data === undefined}
328
+ domLayout='autoHeight'
329
+ />
330
+ </DashboardSection>
331
+
332
+ <DashboardSection title='최근 이벤트 (최신 10개)'>
333
+ <EventList
334
+ items={(data?.events ?? []).slice(0, 10).map((e) => ({
335
+ id: e.id,
336
+ time: relative(e.time, now),
337
+ level: e.level,
338
+ message: e.message,
339
+ }))}
340
+ />
341
+ </DashboardSection>
342
+ </DashboardLayout>
343
+ )
344
+ }