@skyhook-io/k8s-ui 1.3.0 → 1.3.2

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.
@@ -1 +1,3 @@
1
1
  export * from './shared'
2
+ export * from './DiffViewer'
3
+ export * from './TimelineList'
@@ -1,6 +1,10 @@
1
1
  import { memo } from 'react'
2
2
  import { NodeProps, Handle, Position } from '@xyflow/react'
3
- import { ChevronDown, ChevronRight, Box, Tag } from 'lucide-react'
3
+ import { Box, LayoutGrid, Maximize2, Minus, Tag, Workflow } from 'lucide-react'
4
+ import { healthToSeverity, SEVERITY_DOT } from '../../utils/badge-colors'
5
+ import type { HealthStatus } from '../../types'
6
+ import { Tooltip } from '../ui/Tooltip'
7
+ import type { WorkloadCard, GroupDisplayLevel } from './layout'
4
8
 
5
9
  interface GroupNodeData {
6
10
  type: 'namespace' | 'app' | 'label'
@@ -8,8 +12,16 @@ interface GroupNodeData {
8
12
  label?: string
9
13
  nodeCount: number
10
14
  collapsed: boolean
11
- onToggleCollapse: (groupId: string) => void
15
+ displayLevel: GroupDisplayLevel
16
+ onSetLevel: (groupId: string, level: GroupDisplayLevel) => void
17
+ onCardClick?: (nodeId: string) => void
18
+ onMaximizeNamespace?: (namespace: string) => void
12
19
  hideHeader?: boolean
20
+ worstStatus?: HealthStatus
21
+ unhealthyCount?: number
22
+ kindCounts?: Record<string, number>
23
+ workloadCards?: WorkloadCard[]
24
+ gridColumns?: number
13
25
  }
14
26
 
15
27
  export const GroupNode = memo(function GroupNode({
@@ -18,7 +30,14 @@ export const GroupNode = memo(function GroupNode({
18
30
  width,
19
31
  height,
20
32
  }: NodeProps & { data: GroupNodeData }) {
21
- const { type, name, label, nodeCount, collapsed, onToggleCollapse, hideHeader } = data
33
+ const {
34
+ type, name, label, nodeCount, displayLevel,
35
+ onSetLevel, onCardClick, onMaximizeNamespace, hideHeader,
36
+ worstStatus, unhealthyCount, kindCounts,
37
+ workloadCards, gridColumns,
38
+ } = data
39
+ const hasProblems = (unhealthyCount ?? 0) > 0
40
+ const statusDotColor = hasProblems && worstStatus ? SEVERITY_DOT[healthToSeverity(worstStatus)] : null
22
41
 
23
42
  const getIcon = () => {
24
43
  switch (type) {
@@ -33,7 +52,6 @@ export const GroupNode = memo(function GroupNode({
33
52
  }
34
53
 
35
54
  const getBorderStyle = (): React.CSSProperties => {
36
- // Must set full 'border' property to override ReactFlow's --xy-node-border
37
55
  switch (type) {
38
56
  case 'namespace':
39
57
  return { border: '2px solid var(--group-border-namespace)' }
@@ -87,112 +105,204 @@ export const GroupNode = memo(function GroupNode({
87
105
 
88
106
  const Icon = getIcon()
89
107
 
90
- // When collapsed, render as a compact card
91
- if (collapsed) {
108
+ // Invisible handles (shared across all levels)
109
+ const handles = (
110
+ <>
111
+ <Handle type="target" position={Position.Left} className="!bg-transparent !border-0 !w-0 !h-0" />
112
+ <Handle type="source" position={Position.Right} className="!bg-transparent !border-0 !w-0 !h-0" />
113
+ </>
114
+ )
115
+
116
+ // Level control buttons — shared across all headers
117
+ const TIP_DELAY = 100
118
+ const levelControls = (
119
+ <div className="flex items-center gap-0.5 shrink-0" onClick={(e) => e.stopPropagation()}>
120
+ <Tooltip content="Collapse" delay={TIP_DELAY} position="bottom">
121
+ <button
122
+ className={`p-1 rounded transition-colors ${displayLevel === 'chip' ? 'opacity-40' : 'hover:bg-white/10'}`}
123
+ onClick={() => displayLevel !== 'chip' && onSetLevel(id, 'chip')}
124
+ disabled={displayLevel === 'chip'}
125
+ >
126
+ <Minus className="w-4 h-4" style={getIconStyle()} />
127
+ </button>
128
+ </Tooltip>
129
+ <Tooltip content="Workload cards" delay={TIP_DELAY} position="bottom">
130
+ <button
131
+ className={`p-1 rounded transition-colors ${displayLevel === 'cardGrid' ? 'opacity-40' : 'hover:bg-white/10'}`}
132
+ onClick={() => displayLevel !== 'cardGrid' && onSetLevel(id, 'cardGrid')}
133
+ disabled={displayLevel === 'cardGrid'}
134
+ >
135
+ <LayoutGrid className="w-4 h-4" style={getIconStyle()} />
136
+ </button>
137
+ </Tooltip>
138
+ <Tooltip content="Full topology" delay={TIP_DELAY} position="bottom">
139
+ <button
140
+ className={`p-1 rounded transition-colors ${displayLevel === 'topology' ? 'opacity-40' : 'hover:bg-white/10'}`}
141
+ onClick={() => displayLevel !== 'topology' && onSetLevel(id, 'topology')}
142
+ disabled={displayLevel === 'topology'}
143
+ >
144
+ <Workflow className="w-4 h-4" style={getIconStyle()} />
145
+ </button>
146
+ </Tooltip>
147
+ {onMaximizeNamespace && type === 'namespace' && (
148
+ <Tooltip content="Focus namespace" delay={TIP_DELAY} position="bottom">
149
+ <button
150
+ className="p-1 rounded hover:bg-white/10 transition-colors"
151
+ onClick={() => onMaximizeNamespace(name)}
152
+ >
153
+ <Maximize2 className="w-4 h-4" style={getIconStyle()} />
154
+ </button>
155
+ </Tooltip>
156
+ )}
157
+ </div>
158
+ )
159
+
160
+ // ── Level 1: Chip (compact collapsed card) ──
161
+ if (displayLevel === 'chip') {
162
+ // Size tier: 0-9 → 0, 10-99 → 1, 100-999 → 2, 1000+ → 3
163
+ const tier = nodeCount === 0 ? 0 : Math.min(3, Math.floor(Math.log10(nodeCount)))
164
+ const nameSize = ['text-sm', 'text-xl', 'text-4xl', 'text-6xl'][tier]
165
+ const iconSize = ['w-3.5 h-3.5', 'w-5 h-5', 'w-9 h-9', 'w-12 h-12'][tier]
166
+ const maxPills = [2, 5, 8, 12][tier]
167
+ const chipPadding = ['8px 10px', '10px 12px', '14px 16px', '16px 20px'][tier]
168
+
169
+ const kindPills = kindCounts
170
+ ? Object.entries(kindCounts)
171
+ .sort(([, a], [, b]) => b - a)
172
+ .slice(0, maxPills)
173
+ : []
174
+
92
175
  return (
93
176
  <>
94
- <Handle
95
- type="target"
96
- position={Position.Left}
97
- className="!bg-transparent !border-0 !w-0 !h-0"
98
- />
99
-
177
+ {handles}
100
178
  <div
101
- className="rounded-xl p-4 cursor-pointer group-header-scaled"
102
- onClick={() => onToggleCollapse(id)}
103
- style={{ ...getBorderStyle(), ...getHeaderBgStyle() }}
179
+ className="rounded-xl group-header-scaled overflow-hidden"
180
+ style={{ ...getBorderStyle(), ...getHeaderBgStyle(), padding: chipPadding, width: width || '100%', height: height || '100%' }}
104
181
  >
105
- <div className="flex items-center gap-4">
106
- <ChevronRight className="w-8 h-8" style={getIconStyle()} />
107
- <Icon className="w-9 h-9" style={getIconStyle()} />
108
- <span className="text-4xl font-bold" style={getLabelStyle()}>{name}</span>
109
- {label && (
110
- <span className="text-sm text-theme-text-secondary">({label})</span>
182
+ {/* Header row: icon + name + count + status + controls */}
183
+ <div className="flex items-center gap-2 min-w-0">
184
+ <Icon className={`${iconSize} shrink-0`} style={getIconStyle()} />
185
+ <span className={`${nameSize} font-bold truncate`} style={getLabelStyle()}>{name}</span>
186
+ <span className="text-[10px] text-theme-text-secondary shrink-0 bg-theme-surface/50 rounded px-1.5 py-0.5">{nodeCount}</span>
187
+ {statusDotColor && (
188
+ <span className={`w-2 h-2 rounded-full shrink-0 ${statusDotColor}`} />
111
189
  )}
190
+ <div className="ml-auto shrink-0">{levelControls}</div>
112
191
  </div>
113
- <div className="mt-3 text-xl text-theme-text-secondary">
114
- {nodeCount} {nodeCount === 1 ? 'resource' : 'resources'}
115
- </div>
192
+ {/* Kind pills row: colored icons with counts */}
193
+ {kindPills.length > 0 && (
194
+ <div className="flex flex-wrap gap-1 mt-1.5">
195
+ {kindPills.map(([kind, count]) => (
196
+ <div key={kind} className="flex items-center gap-1 bg-theme-surface/50 rounded px-1.5 py-0.5">
197
+ <span className={`topology-icon topology-icon-${kind.toLowerCase()}`} style={{ width: 10, height: 10, fontSize: 6, borderRadius: 2 }} />
198
+ <span className="text-[10px] text-theme-text-secondary">{count} {kind}{count > 1 ? 's' : ''}</span>
199
+ </div>
200
+ ))}
201
+ {kindCounts && Object.keys(kindCounts).length > maxPills && (
202
+ <span className="text-[10px] text-theme-text-tertiary self-center">+{Object.keys(kindCounts).length - maxPills}</span>
203
+ )}
204
+ </div>
205
+ )}
116
206
  </div>
207
+ </>
208
+ )
209
+ }
210
+
211
+ // ── Level 2: Card Grid (workload cards inside namespace) ──
212
+ if (displayLevel === 'cardGrid' && workloadCards) {
213
+ const cols = gridColumns || Math.min(4, workloadCards.length || 1)
214
+ return (
215
+ <>
216
+ {handles}
217
+ <div
218
+ className="rounded-xl overflow-hidden group-header-scaled"
219
+ style={{ ...getBorderStyle(), width: width || '100%', height: height || '100%' }}
220
+ >
221
+ {/* Header bar with level controls */}
222
+ <div
223
+ className="w-full relative flex items-center"
224
+ style={{ ...getHeaderBgStyle(), padding: '8px 12px' }}
225
+ >
226
+ <div className="flex items-center gap-2 min-w-0 flex-1">
227
+ <Icon className="shrink-0 w-5 h-5" style={getIconStyle()} />
228
+ <span className="font-bold truncate text-base" style={getLabelStyle()}>{name}</span>
229
+ <span className="shrink-0 text-xs text-theme-text-secondary bg-theme-surface/50 rounded px-1.5 py-0.5">
230
+ {workloadCards.length}
231
+ </span>
232
+ </div>
233
+ <div className="shrink-0 ml-2">{levelControls}</div>
234
+ </div>
117
235
 
118
- <Handle
119
- type="source"
120
- position={Position.Right}
121
- className="!bg-transparent !border-0 !w-0 !h-0"
122
- />
236
+ {/* Workload card grid */}
237
+ <div
238
+ style={{
239
+ display: 'grid',
240
+ gridTemplateColumns: `repeat(${cols}, 200px)`,
241
+ gap: '6px',
242
+ padding: '8px 14px 14px',
243
+ }}
244
+ >
245
+ {workloadCards.map(card => {
246
+ const statusClass = card.status === 'unhealthy' ? 'grid-card-unhealthy'
247
+ : card.status === 'degraded' ? 'grid-card-degraded'
248
+ : card.status === 'unknown' ? 'grid-card-unknown' : ''
249
+ return (
250
+ <div
251
+ key={card.id}
252
+ className={`grid-card ${statusClass}`}
253
+ onClick={(e) => { e.stopPropagation(); onCardClick?.(card.id) }}
254
+ >
255
+ <div className="grid-card-header">
256
+ <span className={`topology-icon topology-icon-${card.kind.toLowerCase()}`} />
257
+ <span className="grid-card-kind">{card.kind}</span>
258
+ <span className="grid-card-count">{card.resourceCount}</span>
259
+ </div>
260
+ <div className="grid-card-name">{card.name}</div>
261
+ {card.subtitle && (
262
+ <div className="grid-card-subtitle">{card.subtitle}</div>
263
+ )}
264
+ </div>
265
+ )
266
+ })}
267
+ </div>
268
+ </div>
123
269
  </>
124
270
  )
125
271
  }
126
272
 
127
- // When expanded, render as a container with header
128
- // Children are rendered automatically by ReactFlow via parentId
273
+ // ── Level 3: Topology (full expanded container) ──
129
274
  return (
130
275
  <>
131
- <Handle
132
- type="target"
133
- position={Position.Left}
134
- className="!bg-transparent !border-0 !w-0 !h-0"
135
- />
136
-
137
- {/* Container with border - use explicit dimensions from props */}
138
- {/* Top position adjusts based on CSS variable set by ViewportController */}
276
+ {handles}
139
277
  <div
140
- className="absolute left-0 rounded-xl box-border isolate overflow-hidden bg-theme-surface/40 group-container-adjusted"
278
+ className={`absolute left-0 box-border isolate overflow-hidden group-container-adjusted ${hideHeader ? '' : 'rounded-xl bg-theme-surface/40'}`}
141
279
  style={{
142
280
  width: width || '100%',
143
281
  height: height || '100%',
144
- ...getBorderStyle()
282
+ ...(hideHeader ? {} : getBorderStyle())
145
283
  }}
146
284
  >
147
- {/* Header bar - background extends full width, content scales, count fixed right */}
148
- {/* Hidden when hideHeader is true (single namespace view) */}
149
285
  {!hideHeader && (
150
286
  <div
151
- className="w-full cursor-pointer relative flex items-center"
152
- onClick={() => onToggleCollapse(id)}
153
- style={getHeaderBgStyle()}
287
+ className="w-full relative flex items-center"
288
+ style={{ ...getHeaderBgStyle(), padding: '12px 16px' }}
154
289
  >
155
- {/* Scaled content */}
156
290
  <div
157
- className="flex items-center group-header-scaled"
158
- style={{
159
- padding: '20px 24px',
160
- gap: '16px',
161
- }}
291
+ className="flex items-center gap-3 min-w-0 flex-1 group-header-scaled"
162
292
  >
163
- <ChevronDown
164
- className="shrink-0 w-8 h-8"
165
- style={getIconStyle()}
166
- />
167
- <Icon
168
- className="shrink-0 w-9 h-9"
169
- style={getIconStyle()}
170
- />
171
- <span
172
- className="font-bold truncate text-4xl"
173
- style={getLabelStyle()}
174
- >
175
- {name}
176
- </span>
293
+ <Icon className="shrink-0 w-7 h-7" style={getIconStyle()} />
294
+ <span className="font-bold truncate text-2xl" style={getLabelStyle()}>{name}</span>
177
295
  {label && (
178
- <span className="text-sm text-theme-text-secondary truncate">
179
- ({label})
180
- </span>
296
+ <span className="text-sm text-theme-text-secondary truncate">({label})</span>
181
297
  )}
298
+ <span className="shrink-0 text-xs text-theme-text-secondary bg-theme-surface/50 rounded px-1.5 py-0.5">
299
+ {nodeCount}
300
+ </span>
182
301
  </div>
183
- {/* Count badge - fixed size, anchored top-right */}
184
- <span className="absolute right-4 top-4 shrink-0 font-medium text-sm text-theme-text-secondary bg-theme-surface/70 rounded-lg px-3 py-1">
185
- {nodeCount}
186
- </span>
302
+ <div className="shrink-0 ml-2">{levelControls}</div>
187
303
  </div>
188
304
  )}
189
305
  </div>
190
-
191
- <Handle
192
- type="source"
193
- position={Position.Right}
194
- className="!bg-transparent !border-0 !w-0 !h-0"
195
- />
196
306
  </>
197
307
  )
198
308
  })