@skyhook-io/k8s-ui 1.4.3 → 1.5.1

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.
@@ -106,37 +106,51 @@ export function NodeRenderer({ data, relationships, onViewPods, metrics, metrics
106
106
 
107
107
  {/* Capacity */}
108
108
  <Section title="Capacity" icon={HardDrive}>
109
- <div className="space-y-1">
110
- <div className="grid grid-cols-4 gap-2 text-xs text-theme-text-tertiary font-medium mb-2">
111
- <span>Resource</span>
112
- <span>Capacity</span>
113
- <span>Allocatable</span>
114
- <span>In Use</span>
115
- </div>
116
- <div className="grid grid-cols-4 gap-2 text-sm">
117
- <span className="text-theme-text-secondary">CPU</span>
118
- <span className="text-theme-text-primary">{capacity.cpu || '-'}</span>
119
- <span className="text-theme-text-primary">{allocatable.cpu || '-'}</span>
120
- <span className="text-theme-text-primary font-medium">{metrics?.usage?.cpu || '-'}</span>
121
- </div>
122
- <div className="grid grid-cols-4 gap-2 text-sm">
123
- <span className="text-theme-text-secondary">Memory</span>
124
- <span className="text-theme-text-primary">{formatMemory(capacity.memory)}</span>
125
- <span className="text-theme-text-primary">{formatMemory(allocatable.memory)}</span>
126
- <span className="text-theme-text-primary font-medium">{metrics?.usage?.memory ? formatMemory(metrics.usage.memory) : '-'}</span>
127
- </div>
128
- <div className="grid grid-cols-4 gap-2 text-sm">
129
- <span className="text-theme-text-secondary">Pods</span>
130
- <span className="text-theme-text-primary">{capacity.pods || '-'}</span>
131
- <span className="text-theme-text-primary">{allocatable.pods || '-'}</span>
132
- <span className="text-theme-text-primary font-medium">{relationships?.pods?.length ?? '-'}</span>
133
- </div>
134
- <div className="grid grid-cols-4 gap-2 text-sm">
135
- <span className="text-theme-text-secondary">Ephemeral Storage</span>
136
- <span className="text-theme-text-primary">{formatStorage(capacity['ephemeral-storage'])}</span>
137
- <span className="text-theme-text-primary">{formatStorage(allocatable['ephemeral-storage'])}</span>
138
- <span className="text-theme-text-primary">-</span>
139
- </div>
109
+ <div className="space-y-2">
110
+ {[
111
+ {
112
+ label: 'CPU',
113
+ capacity: capacity.cpu,
114
+ allocatable: allocatable.cpu,
115
+ inUse: metrics?.usage?.cpu,
116
+ },
117
+ {
118
+ label: 'Memory',
119
+ capacity: formatMemory(capacity.memory),
120
+ allocatable: formatMemory(allocatable.memory),
121
+ inUse: metrics?.usage?.memory ? formatMemory(metrics.usage.memory) : undefined,
122
+ },
123
+ {
124
+ label: 'Pods',
125
+ capacity: capacity.pods,
126
+ allocatable: allocatable.pods,
127
+ inUse: relationships?.pods?.length,
128
+ },
129
+ {
130
+ label: 'Ephemeral Storage',
131
+ capacity: formatStorage(capacity['ephemeral-storage']),
132
+ allocatable: formatStorage(allocatable['ephemeral-storage']),
133
+ inUse: undefined,
134
+ },
135
+ ].map((row) => (
136
+ <div key={row.label} className="card-inner">
137
+ <div className="text-xs font-medium text-theme-text-secondary mb-1">{row.label}</div>
138
+ <div className="space-y-0.5 text-xs">
139
+ <div className="flex items-baseline justify-between gap-2">
140
+ <span className="text-theme-text-tertiary">Capacity</span>
141
+ <span className="text-theme-text-primary tabular-nums">{row.capacity ?? '-'}</span>
142
+ </div>
143
+ <div className="flex items-baseline justify-between gap-2">
144
+ <span className="text-theme-text-tertiary">Allocatable</span>
145
+ <span className="text-theme-text-primary tabular-nums">{row.allocatable ?? '-'}</span>
146
+ </div>
147
+ <div className="flex items-baseline justify-between gap-2">
148
+ <span className="text-theme-text-tertiary">In Use</span>
149
+ <span className="text-theme-text-primary font-medium tabular-nums">{row.inUse ?? '-'}</span>
150
+ </div>
151
+ </div>
152
+ </div>
153
+ ))}
140
154
  </div>
141
155
  {onViewPods && (
142
156
  <div className="mt-3 pt-3 border-t border-theme-border">
@@ -35,7 +35,7 @@ export function HealthRing({ segments, size = 48, strokeWidth = 5, label }: Heal
35
35
  className="text-theme-border"
36
36
  />
37
37
  {label && (
38
- <text x="50" y="48" textAnchor="middle" dominantBaseline="central" className="fill-theme-text-tertiary text-[22px] font-semibold font-mono">
38
+ <text x="50" y="48" textAnchor="middle" dominantBaseline="central" fill="currentColor" className="text-theme-text-tertiary text-[22px] font-semibold font-mono">
39
39
  0
40
40
  </text>
41
41
  )}
@@ -98,7 +98,7 @@ export function HealthRing({ segments, size = 48, strokeWidth = 5, label }: Heal
98
98
  </g>
99
99
  {/* Center label — nudged up slightly for visual centering in 270° arc */}
100
100
  {label && (
101
- <text x="50" y="48" textAnchor="middle" dominantBaseline="central" className="fill-theme-text-primary text-[22px] font-semibold font-mono">
101
+ <text x="50" y="48" textAnchor="middle" dominantBaseline="central" fill="currentColor" className="text-theme-text-primary text-[22px] font-semibold font-mono">
102
102
  {label}
103
103
  </text>
104
104
  )}
@@ -1,5 +1,5 @@
1
1
  import { useState } from 'react'
2
- import { ChevronRight, Copy, Check, Tag, AlertTriangle, CheckCircle, ExternalLink, Layers } from 'lucide-react'
2
+ import { ChevronRight, Copy, Check, Tag, AlertTriangle, CheckCircle, ExternalLink, Layers, X, Minus } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import { formatAge, formatDuration } from '../resources/resource-utils'
5
5
  import { Tooltip } from './Tooltip'
@@ -182,6 +182,106 @@ export function Property({ label, value, copyable, onCopy, copied }: PropertyPro
182
182
  // COMMON SECTIONS
183
183
  // ============================================================================
184
184
 
185
+ // Condition types where status=True means a problem and status=False means healthy.
186
+ //
187
+ // K8s has no canonical API metadata for polarity — SIG-API Machinery explicitly
188
+ // declined to add it (https://gateway-api.sigs.k8s.io/geps/gep-1364/), so every
189
+ // consumer has to maintain a list. This one is assembled from:
190
+ // - Core K8s (kubelet node conditions, Pod, workload controllers)
191
+ // - Node Problem Detector default configs (kubernetes/node-problem-detector)
192
+ // - GKE's NPD plugin set (observed in-cluster)
193
+ // - ArgoCD Application conditions (argoproj/argo-cd)
194
+ // - FluxCD meta conditions (fluxcd/pkg/apis/meta)
195
+ // - Conventional Operator-pattern conditions (Degraded, OutOfSync)
196
+ // Patterns only cover two NPD naming families whose convention is
197
+ // strict ("Deprecated*" for deprecation checks, "Frequent*" for restart
198
+ // counters) — everything else is explicit to avoid guessing wrong.
199
+ const NEGATIVE_POLARITY_TYPES = new Set([
200
+ // Core K8s — Node (kubelet)
201
+ 'MemoryPressure',
202
+ 'DiskPressure',
203
+ 'PIDPressure',
204
+ 'NetworkUnavailable',
205
+ // Core K8s — Pod
206
+ 'DisruptionTarget',
207
+ // Core K8s — workloads
208
+ 'ReplicaFailure',
209
+ // NPD — upstream default configs
210
+ 'KernelDeadlock',
211
+ 'CperHardwareErrorFatal',
212
+ 'XfsShutdown',
213
+ 'CorruptDockerOverlay2',
214
+ 'ReadonlyFilesystem',
215
+ 'ContainerRuntimeUnhealthy',
216
+ 'KubeletUnhealthy',
217
+ // NPD — GKE extensions (observed)
218
+ 'ReadOnlyRootFileSystem',
219
+ 'ResourceExhausted',
220
+ 'Swap',
221
+ // NPD — AKS extensions (learn.microsoft.com/azure/aks/node-problem-detector)
222
+ 'FilesystemCorruptionProblem',
223
+ 'KubeletProblem',
224
+ 'ContainerRuntimeProblem',
225
+ 'VMEventScheduled',
226
+ 'GPUMissing',
227
+ 'NVLinkStatusInactive',
228
+ 'XIDErrors',
229
+ 'IBLinkFlapping',
230
+ 'GPUClockThrottling',
231
+ 'UnhealthyNvidiaDevicePlugin',
232
+ // ArgoCD Application
233
+ 'DeletionError',
234
+ 'InvalidSpecError',
235
+ 'ComparisonError',
236
+ 'SyncError',
237
+ 'UnknownError',
238
+ 'SharedResourceWarning',
239
+ 'RepeatedResourceWarning',
240
+ 'OrphanedResourceWarning',
241
+ 'ExcludedResourceWarning',
242
+ 'OutOfSync',
243
+ // ArgoCD ApplicationSet
244
+ 'ErrorOccurred',
245
+ // FluxCD (meta)
246
+ 'Stalled',
247
+ // Gateway API (sigs.k8s.io/gateway-api v1)
248
+ 'Conflicted',
249
+ 'OverlappingTLSConfig',
250
+ 'PartiallyInvalid',
251
+ 'InsecureFrontendValidationMode',
252
+ // cert-manager (CertificateRequest)
253
+ 'Denied',
254
+ 'InvalidRequest',
255
+ // Karpenter NodeClaim
256
+ 'Drifted',
257
+ // VPA
258
+ 'ConfigUnsupported',
259
+ 'LowConfidence',
260
+ // KEDA ScaledObject
261
+ 'Fallback',
262
+ // Operator-pattern CRDs (widespread)
263
+ 'Degraded',
264
+ 'Failed',
265
+ ])
266
+
267
+ const NEGATIVE_POLARITY_PATTERNS: RegExp[] = [
268
+ // NPD deprecation-check family (e.g. DeprecatedAuthsFieldInContainerdConfiguration)
269
+ /^Deprecated/,
270
+ // NPD restart-counter family (e.g. FrequentKubeletRestart, FrequentUnregisterNetDevice)
271
+ /^Frequent/,
272
+ ]
273
+
274
+ function isInvertedPolarityCondition(type: string | undefined): boolean {
275
+ if (!type) return false
276
+ if (NEGATIVE_POLARITY_TYPES.has(type)) return true
277
+ return NEGATIVE_POLARITY_PATTERNS.some((re) => re.test(type))
278
+ }
279
+
280
+ function isConditionHealthy(cond: { type?: string; status?: string }): boolean {
281
+ const inverted = isInvertedPolarityCondition(cond.type)
282
+ return inverted ? cond.status === 'False' : cond.status === 'True'
283
+ }
284
+
185
285
  export function ConditionsSection({ conditions }: { conditions?: any[] }) {
186
286
  if (!conditions || conditions.length === 0) return null
187
287
 
@@ -193,7 +293,7 @@ export function ConditionsSection({ conditions }: { conditions?: any[] }) {
193
293
  return (a.type || '').localeCompare(b.type || '')
194
294
  })
195
295
 
196
- const failCount = sorted.filter((c: any) => c.status === 'False').length
296
+ const failCount = sorted.filter((c: any) => (c.status === 'True' || c.status === 'False') && !isConditionHealthy(c)).length
197
297
 
198
298
  return (
199
299
  <Section
@@ -206,8 +306,8 @@ export function ConditionsSection({ conditions }: { conditions?: any[] }) {
206
306
 
207
307
  <div className="space-y-0.5">
208
308
  {sorted.map((cond: any) => {
209
- const isOk = cond.status === 'True'
210
- const isUnknown = cond.status === 'Unknown'
309
+ const isUnknown = cond.status !== 'True' && cond.status !== 'False'
310
+ const isOk = !isUnknown && isConditionHealthy(cond)
211
311
  const isFail = !isOk && !isUnknown
212
312
  return (
213
313
  <div key={cond.type} className={clsx(
@@ -220,12 +320,14 @@ export function ConditionsSection({ conditions }: { conditions?: any[] }) {
220
320
  </div>
221
321
  {/* Timeline dot */}
222
322
  <span className={clsx(
223
- 'w-[13px] h-[13px] rounded-full flex items-center justify-center text-[8px] shrink-0 mt-[3px] z-10 ring-2 ring-theme-surface',
323
+ 'w-3 h-3 rounded-full flex items-center justify-center shrink-0 mt-1 z-10 ring-2 ring-theme-surface',
224
324
  isOk ? 'bg-emerald-500/20 text-emerald-500 dark:bg-emerald-500/30'
225
325
  : isUnknown ? 'bg-gray-400/20 text-gray-400 dark:bg-gray-400/30'
226
326
  : 'bg-red-500/25 text-red-500 dark:bg-red-500/35'
227
327
  )}>
228
- {isOk ? '✓' : isUnknown ? '?' : '✗'}
328
+ {isOk ? <Check className="w-2 h-2" strokeWidth={4} />
329
+ : isUnknown ? <Minus className="w-2 h-2" strokeWidth={4} />
330
+ : <X className="w-2 h-2" strokeWidth={4} />}
229
331
  </span>
230
332
  {/* Content */}
231
333
  <div className="min-w-0 flex-1 pl-2">
@@ -30,6 +30,8 @@ export interface KeyboardShortcut {
30
30
  handler: (e: KeyboardEvent) => void
31
31
  /** Whether this shortcut is currently active */
32
32
  enabled?: boolean
33
+ /** Fire even when focus is inside an input/textarea. For global modifier combos (⌘K, Ctrl+Shift+D) that can't collide with typing. */
34
+ allowInInputs?: boolean
33
35
  }
34
36
 
35
37
  // Internal registration with stable ID
@@ -46,28 +48,22 @@ const KeyboardShortcutContext = createContext<KeyboardShortcutContextType | null
46
48
 
47
49
  let nextRegistrationId = 0
48
50
 
49
- // Check if focus is in an element that should suppress shortcuts
50
- function shouldSuppressShortcut(e: KeyboardEvent): boolean {
51
- const target = e.target as HTMLElement
52
- if (!target) return false
53
-
54
- // Always allow Escape
55
- if (e.key === 'Escape') return false
51
+ // Classify suppression level for the currently-focused element.
52
+ // none no suppression, all shortcuts fire
53
+ // soft plain text inputs; bypassed by shortcuts with allowInInputs
54
+ // hard — rich editors (Monaco, xterm) that own their own keyboard UX;
55
+ // never bypassed, since Cmd+K / Ctrl+Shift+D have meaning there
56
+ type SuppressionLevel = 'none' | 'soft' | 'hard'
56
57
 
57
- // Suppress in text inputs
58
+ function getSuppressionLevel(e: KeyboardEvent): SuppressionLevel {
59
+ const target = e.target as HTMLElement
60
+ if (!target) return 'none'
61
+ if (e.key === 'Escape') return 'none'
62
+ if (target.closest('.monaco-editor') || target.closest('.xterm')) return 'hard'
58
63
  const tagName = target.tagName
59
- if (tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT') return true
60
-
61
- // Suppress in contenteditable
62
- if (target.isContentEditable) return true
63
-
64
- // Suppress in Monaco editor
65
- if (target.closest('.monaco-editor')) return true
66
-
67
- // Suppress in xterm terminal
68
- if (target.closest('.xterm')) return true
69
-
70
- return false
64
+ if (tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT') return 'soft'
65
+ if (target.isContentEditable) return 'soft'
66
+ return 'none'
71
67
  }
72
68
 
73
69
  // Parse a key string like "Shift+N", "Cmd+K", "g g" into match criteria
@@ -158,7 +154,8 @@ export function KeyboardShortcutProvider({ children }: { children: ReactNode })
158
154
  // Global keydown listener
159
155
  useEffect(() => {
160
156
  const handleKeyDown = (e: KeyboardEvent) => {
161
- const suppressed = shouldSuppressShortcut(e)
157
+ const suppression = getSuppressionLevel(e)
158
+ const suppressed = suppression !== 'none'
162
159
 
163
160
  // Get all enabled shortcuts, sorted by scope priority (highest first)
164
161
  const shortcuts = Array.from(shortcutsRef.current.values())
@@ -215,7 +212,7 @@ export function KeyboardShortcutProvider({ children }: { children: ReactNode })
215
212
  return
216
213
  }
217
214
 
218
- if (suppressed) continue
215
+ if (suppressed && !(shortcut.allowInInputs && suppression === 'soft')) continue
219
216
 
220
217
  if (matchesKey(e, matcher, '')) {
221
218
  e.preventDefault()
@@ -0,0 +1,6 @@
1
+ /* Top-level re-export of topology.css so both npm-exports resolution and
2
+ * source-alias resolution (in the radar monorepo's Vite config) land on
3
+ * the same stylesheet. Keeps external consumers' @import paths simple
4
+ * (@skyhook-io/k8s-ui/topology.css) without forcing them to know the
5
+ * components/ subdirectory layout. */
6
+ @import "./components/topology/topology.css";