@skyhook-io/k8s-ui 1.5.0 → 1.5.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.
@@ -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">
@@ -12,9 +12,16 @@ export function getArgoApplicationStatus(app: any): StatusBadge {
12
12
  const sync = app.status?.sync?.status
13
13
  const opPhase = app.status?.operationState?.phase
14
14
 
15
- // Check for suspended (no automated sync policy)
15
+ // Check for suspended (no automated sync policy). Honor both the current
16
+ // "radarhq.io/suspended-prune" annotation and the legacy "skyhook.io/..."
17
+ // key still present on Applications suspended by older Radar builds.
18
+ // The annotation value stores the prior prune state as "true"/"false" for
19
+ // restore on resume — both strings are truthy in JS, which is intentional:
20
+ // the *presence* of the annotation is what signals suspended, not its value.
16
21
  const hasAutomatedSync = !!app.spec?.syncPolicy?.automated
17
- if (health === 'Suspended' || (!hasAutomatedSync && app.metadata?.annotations?.['radar.skyhook.io/suspended-prune'])) {
22
+ const annotations = app.metadata?.annotations
23
+ const suspendedByRadar = annotations?.['radarhq.io/suspended-prune'] || annotations?.['skyhook.io/suspended-prune']
24
+ if (health === 'Suspended' || (!hasAutomatedSync && suspendedByRadar)) {
18
25
  return { text: 'Suspended', color: healthColors.degraded, level: 'degraded' }
19
26
  }
20
27
 
@@ -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">
@@ -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";
@@ -5,22 +5,87 @@
5
5
 
6
6
  import type { LogLevel } from '../components/logs/useLogBuffer'
7
7
 
8
+ export type TimestampFormat =
9
+ | 'time-local'
10
+ | 'time-utc'
11
+ | 'iso-local'
12
+ | 'iso-utc'
13
+ | 'relative'
14
+ | 'epoch'
15
+
16
+ export const TIMESTAMP_FORMAT_LABELS: Record<TimestampFormat, string> = {
17
+ 'time-local': 'Time (local)',
18
+ 'time-utc': 'Time (UTC)',
19
+ 'iso-local': 'ISO (local)',
20
+ 'iso-utc': 'ISO (UTC)',
21
+ 'relative': 'Relative',
22
+ 'epoch': 'Epoch',
23
+ }
24
+
25
+ function pad2(n: number): string {
26
+ return n < 10 ? '0' + n : String(n)
27
+ }
28
+
29
+ function formatIsoLocal(date: Date): string {
30
+ const offset = -date.getTimezoneOffset()
31
+ const sign = offset >= 0 ? '+' : '-'
32
+ const abs = Math.abs(offset)
33
+ return (
34
+ date.getFullYear() +
35
+ '-' + pad2(date.getMonth() + 1) +
36
+ '-' + pad2(date.getDate()) +
37
+ 'T' + pad2(date.getHours()) +
38
+ ':' + pad2(date.getMinutes()) +
39
+ ':' + pad2(date.getSeconds()) +
40
+ sign + pad2(Math.floor(abs / 60)) + ':' + pad2(abs % 60)
41
+ )
42
+ }
43
+
44
+ function formatRelative(date: Date, now: number): string {
45
+ const diffSec = Math.round((now - date.getTime()) / 1000)
46
+ const abs = Math.abs(diffSec)
47
+ const suffix = diffSec >= 0 ? ' ago' : ' from now'
48
+ if (abs < 2) return 'just now'
49
+ if (abs < 60) return `${abs}s${suffix}`
50
+ if (abs < 3600) return `${Math.floor(abs / 60)}m${suffix}`
51
+ if (abs < 86400) return `${Math.floor(abs / 3600)}h${suffix}`
52
+ return `${Math.floor(abs / 86400)}d${suffix}`
53
+ }
54
+
8
55
  /**
9
56
  * Format a K8s log timestamp for display.
10
- * Extracts and formats the time portion (HH:MM:SS).
57
+ * Supports multiple display formats and UTC/local time zones.
58
+ * `now` is accepted for testability; defaults to Date.now().
11
59
  */
12
- export function formatLogTimestamp(ts: string): string {
60
+ export function formatLogTimestamp(
61
+ ts: string,
62
+ format: TimestampFormat = 'time-local',
63
+ now?: number,
64
+ ): string {
13
65
  const date = new Date(ts)
14
66
  if (isNaN(date.getTime())) {
15
- // Fallback: extract HH:MM:SS from ISO timestamp
16
67
  return ts.slice(11, 19) || ts
17
68
  }
18
- return date.toLocaleTimeString('en-US', {
19
- hour12: false,
20
- hour: '2-digit',
21
- minute: '2-digit',
22
- second: '2-digit',
23
- })
69
+ switch (format) {
70
+ case 'time-utc':
71
+ return date.toISOString().slice(11, 19)
72
+ case 'iso-local':
73
+ return formatIsoLocal(date)
74
+ case 'iso-utc':
75
+ return date.toISOString()
76
+ case 'relative':
77
+ return formatRelative(date, now ?? Date.now())
78
+ case 'epoch':
79
+ return String(Math.floor(date.getTime() / 1000))
80
+ case 'time-local':
81
+ default:
82
+ return date.toLocaleTimeString('en-US', {
83
+ hour12: false,
84
+ hour: '2-digit',
85
+ minute: '2-digit',
86
+ second: '2-digit',
87
+ })
88
+ }
24
89
  }
25
90
 
26
91
  /** Map a detected LogLevel to a Tailwind color class. */
@@ -294,9 +359,9 @@ export function parseLogRange(logRange: string): { tailLines?: number; sinceSeco
294
359
  // Syntax highlight colors shared between JSON and logfmt rendering
295
360
  export const SYNTAX_COLOR_KEY = '#7cacf8'
296
361
  export const SYNTAX_COLOR_STRING = '#73c991'
297
- const SYNTAX_COLOR_NUMBER = '#e5c07b'
298
- const SYNTAX_COLOR_BOOLEAN = '#c678dd'
299
- const SYNTAX_COLOR_NULL = '#808080'
362
+ export const SYNTAX_COLOR_NUMBER = '#e5c07b'
363
+ export const SYNTAX_COLOR_BOOLEAN = '#c678dd'
364
+ export const SYNTAX_COLOR_NULL = '#808080'
300
365
 
301
366
  /**
302
367
  * Syntax-highlight a pretty-printed JSON string for HTML display.