@skyhook-io/k8s-ui 1.7.14 → 1.8.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.
Files changed (48) hide show
  1. package/package.json +7 -7
  2. package/src/components/applications/ApplicationsList.tsx +131 -115
  3. package/src/components/applications/index.ts +3 -1
  4. package/src/components/audit/AuditCard.tsx +1 -1
  5. package/src/components/charts/AreaChart.tsx +52 -18
  6. package/src/components/charts/MetricsSummary.tsx +12 -3
  7. package/src/components/charts/saturation.test.ts +34 -0
  8. package/src/components/charts/saturation.ts +1 -0
  9. package/src/components/charts/types.ts +5 -4
  10. package/src/components/checks/ChecksView.tsx +12 -23
  11. package/src/components/checks/severity.ts +11 -11
  12. package/src/components/gitops/GitOpsTableView.tsx +298 -274
  13. package/src/components/issues/IssuesView.tsx +4 -6
  14. package/src/components/issues/severity.ts +19 -11
  15. package/src/components/issues/types.ts +22 -2
  16. package/src/components/logs/WorkloadLogsViewer.tsx +16 -7
  17. package/src/components/resources/ResourcesSidebar.tsx +3 -1
  18. package/src/components/resources/ResourcesView.tsx +180 -21
  19. package/src/components/resources/get-pod-problems.test.ts +127 -0
  20. package/src/components/resources/index.ts +1 -0
  21. package/src/components/resources/renderers/HPARenderer.test.tsx +91 -0
  22. package/src/components/resources/renderers/HPARenderer.tsx +93 -86
  23. package/src/components/resources/renderers/PodRenderer.test.tsx +56 -0
  24. package/src/components/resources/renderers/PodRenderer.tsx +1 -1
  25. package/src/components/resources/renderers/WorkloadRenderer.test.tsx +83 -0
  26. package/src/components/resources/renderers/WorkloadRenderer.tsx +73 -4
  27. package/src/components/resources/resource-utils-hpa.test.ts +30 -0
  28. package/src/components/resources/resource-utils-hpa.ts +106 -0
  29. package/src/components/resources/resource-utils.ts +41 -28
  30. package/src/components/shared/ResourceRendererDispatch.tsx +10 -2
  31. package/src/components/ui/DistributionBar.tsx +88 -0
  32. package/src/components/ui/Facet.tsx +130 -0
  33. package/src/components/ui/PageHeader.tsx +43 -0
  34. package/src/components/ui/SearchPillInput.tsx +237 -0
  35. package/src/components/ui/SortableTh.tsx +58 -0
  36. package/src/components/ui/SummaryTile.tsx +60 -0
  37. package/src/components/ui/drawer-components.tsx +56 -17
  38. package/src/components/ui/index.ts +12 -0
  39. package/src/components/workload/WorkloadView.tsx +18 -1
  40. package/src/theme/tailwind-theme.css +1 -0
  41. package/src/theme/variables.css +7 -3
  42. package/src/types/core.ts +64 -2
  43. package/src/utils/bulk-workload-actions.test.ts +78 -0
  44. package/src/utils/bulk-workload-actions.ts +50 -0
  45. package/src/utils/index.ts +1 -0
  46. package/src/utils/rbac-blast-radius.test.ts +48 -3
  47. package/src/utils/rbac-blast-radius.ts +14 -4
  48. package/tsconfig.json +1 -0
@@ -0,0 +1,237 @@
1
+ import { useRef, useState, useMemo, useEffect, useCallback } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { X } from 'lucide-react'
4
+ import { clsx } from 'clsx'
5
+
6
+ // A structured search input: free-text plus removable `key:value` modifier
7
+ // PILLS (ns:, kind:, label:, …). Typing a complete modifier token and pressing
8
+ // space converts it to a pill; backspace from an empty input pops the last pill
9
+ // back to editable text. Modifiers with a known, bounded value set (ns, kind)
10
+ // get an inline autocomplete dropdown.
11
+ //
12
+ // PURE + host-agnostic: it knows nothing about search execution, results, or any
13
+ // app's data layer — the host injects `modifierOptions` and reads the structured
14
+ // query back via onChange. Shared so Radar (standalone) and Radar Cloud can both
15
+ // build their own results surface on top of the same advanced-search field.
16
+
17
+ export interface SearchModifier {
18
+ key: string
19
+ value: string
20
+ }
21
+
22
+ export interface SearchPillInputProps {
23
+ /** Free-text portion (everything that isn't a committed modifier pill). */
24
+ text: string
25
+ /** Committed modifier pills. */
26
+ pills: SearchModifier[]
27
+ onChange: (next: { text: string; pills: SearchModifier[] }) => void
28
+ /** Keys the control doesn't consume are forwarded (result nav: arrows/enter/etc). */
29
+ onKeyDown?: (e: React.KeyboardEvent) => void
30
+ onFocus?: () => void
31
+ placeholder?: string
32
+ /** Known values per CANONICAL modifier key for autocomplete (filtered locally). */
33
+ modifierOptions?: Record<string, string[]>
34
+ /** Alias→canonical map; defaults to the k8s search parser's set. */
35
+ aliases?: Record<string, string>
36
+ inputRef?: React.RefObject<HTMLInputElement | null>
37
+ leftSlot?: React.ReactNode
38
+ rightSlot?: React.ReactNode
39
+ /** Applied to the input container (host owns the box chrome: height, bg, border). */
40
+ className?: string
41
+ 'aria-label'?: string
42
+ /** Fires when the modifier autocomplete opens/closes, so the host can suppress
43
+ its own results dropdown while a modifier is being completed. */
44
+ onSuggestingChange?: (suggesting: boolean) => void
45
+ }
46
+
47
+ // Matches internal/search/parse.go.
48
+ const DEFAULT_ALIASES: Record<string, string> = {
49
+ ns: 'ns', n: 'ns', namespace: 'ns',
50
+ kind: 'kind', k: 'kind',
51
+ label: 'label', l: 'label',
52
+ image: 'image', img: 'image',
53
+ cluster: 'cluster', c: 'cluster',
54
+ }
55
+
56
+ const MAX_SUGGESTIONS = 50
57
+
58
+ function splitLastToken(s: string): { before: string; token: string } {
59
+ const i = s.lastIndexOf(' ')
60
+ if (i < 0) return { before: '', token: s }
61
+ return { before: s.slice(0, i + 1), token: s.slice(i + 1) }
62
+ }
63
+
64
+ // The modifier currently being typed at the end of `text` (e.g. "ns:dev"), or
65
+ // null. `partial` is the value-so-far; `before` is the text that precedes it.
66
+ function activeModifier(
67
+ text: string,
68
+ aliases: Record<string, string>,
69
+ ): { canon: string; partial: string; before: string } | null {
70
+ const { before, token } = splitLastToken(text)
71
+ const m = /^([a-zA-Z]+):(\S*)$/.exec(token)
72
+ if (!m) return null
73
+ const canon = aliases[m[1].toLowerCase()]
74
+ if (!canon) return null
75
+ return { canon, partial: m[2], before }
76
+ }
77
+
78
+ function highlightPartial(text: string, partial: string): React.ReactNode {
79
+ if (!partial) return text
80
+ const i = text.toLowerCase().indexOf(partial.toLowerCase())
81
+ if (i < 0) return text
82
+ return (
83
+ <>
84
+ {text.slice(0, i)}
85
+ <span className="font-semibold text-[var(--color-brand)]">{text.slice(i, i + partial.length)}</span>
86
+ {text.slice(i + partial.length)}
87
+ </>
88
+ )
89
+ }
90
+
91
+ export function SearchPillInput({
92
+ text,
93
+ pills,
94
+ onChange,
95
+ onKeyDown,
96
+ onFocus,
97
+ placeholder,
98
+ modifierOptions,
99
+ aliases = DEFAULT_ALIASES,
100
+ inputRef: inputRefProp,
101
+ leftSlot,
102
+ rightSlot,
103
+ className,
104
+ onSuggestingChange,
105
+ ...rest
106
+ }: SearchPillInputProps) {
107
+ const internalRef = useRef<HTMLInputElement>(null)
108
+ const inputRef = inputRefProp ?? internalRef
109
+ const containerRef = useRef<HTMLDivElement>(null)
110
+ const [sel, setSel] = useState(0)
111
+ const [dismissed, setDismissed] = useState(false)
112
+ const [anchor, setAnchor] = useState<{ left: number; bottom: number } | null>(null)
113
+
114
+ const mod = useMemo(() => activeModifier(text, aliases), [text, aliases])
115
+
116
+ const filtered = useMemo(() => {
117
+ if (!mod) return []
118
+ const opts = modifierOptions?.[mod.canon]
119
+ if (!opts) return []
120
+ const p = mod.partial.toLowerCase()
121
+ if (!p) return opts.slice(0, MAX_SUGGESTIONS)
122
+ const pref: string[] = []
123
+ const sub: string[] = []
124
+ for (const o of opts) {
125
+ const lo = o.toLowerCase()
126
+ if (lo.startsWith(p)) pref.push(o)
127
+ else if (lo.includes(p)) sub.push(o)
128
+ }
129
+ return [...pref, ...sub].slice(0, MAX_SUGGESTIONS)
130
+ }, [mod, modifierOptions])
131
+
132
+ const suggesting = !dismissed && filtered.length > 0
133
+
134
+ useEffect(() => { setSel(0) }, [filtered.length, mod?.canon, mod?.partial])
135
+ useEffect(() => { onSuggestingChange?.(suggesting) }, [suggesting, onSuggestingChange])
136
+
137
+ // Anchor the portaled suggestion dropdown under the input box.
138
+ useEffect(() => {
139
+ if (!suggesting) { setAnchor(null); return }
140
+ const update = () => {
141
+ const el = containerRef.current
142
+ if (el) { const r = el.getBoundingClientRect(); setAnchor({ left: r.left, bottom: r.bottom }) }
143
+ }
144
+ update()
145
+ window.addEventListener('resize', update)
146
+ window.addEventListener('scroll', update, true)
147
+ return () => { window.removeEventListener('resize', update); window.removeEventListener('scroll', update, true) }
148
+ }, [suggesting])
149
+
150
+ const commitPill = useCallback((key: string, value: string, before: string) => {
151
+ onChange({ pills: [...pills, { key, value }], text: before })
152
+ setDismissed(false)
153
+ }, [onChange, pills])
154
+
155
+ const removePill = useCallback((idx: number) => {
156
+ onChange({ pills: pills.filter((_, i) => i !== idx), text })
157
+ inputRef.current?.focus()
158
+ }, [onChange, pills, text, inputRef])
159
+
160
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
161
+ if (suggesting) {
162
+ if (e.key === 'ArrowDown') { e.preventDefault(); setSel((s) => Math.min(s + 1, filtered.length - 1)); return }
163
+ if (e.key === 'ArrowUp') { e.preventDefault(); setSel((s) => Math.max(s - 1, 0)); return }
164
+ if (e.key === 'Enter' || e.key === 'Tab') {
165
+ if (mod && filtered[sel]) { e.preventDefault(); commitPill(mod.canon, filtered[sel], mod.before); return }
166
+ }
167
+ if (e.key === 'Escape') { e.preventDefault(); setDismissed(true); return }
168
+ }
169
+ if (e.key === ' ' && mod && mod.partial.length > 0) {
170
+ e.preventDefault()
171
+ commitPill(mod.canon, mod.partial, mod.before)
172
+ return
173
+ }
174
+ if (e.key === 'Backspace' && text === '' && pills.length > 0) {
175
+ e.preventDefault()
176
+ const last = pills[pills.length - 1]
177
+ onChange({ pills: pills.slice(0, -1), text: `${last.key}:${last.value}` })
178
+ return
179
+ }
180
+ onKeyDown?.(e)
181
+ }, [suggesting, filtered, sel, mod, text, pills, commitPill, onChange, onKeyDown])
182
+
183
+ return (
184
+ <div ref={containerRef} className={clsx('flex items-center gap-1.5', className)} onClick={() => inputRef.current?.focus()}>
185
+ {leftSlot}
186
+ {pills.map((p, i) => (
187
+ <span key={`${p.key}:${p.value}:${i}`} className="inline-flex items-center gap-1 shrink-0 rounded-md bg-theme-elevated border border-theme-border-light pl-1.5 pr-1 py-0.5 text-xs whitespace-nowrap">
188
+ <span className="text-theme-text-tertiary">{p.key}:</span>
189
+ <span className="text-theme-text-primary font-medium">{p.value}</span>
190
+ <button
191
+ type="button"
192
+ tabIndex={-1}
193
+ aria-label={`Remove ${p.key}:${p.value} filter`}
194
+ onMouseDown={(e) => { e.preventDefault(); removePill(i) }}
195
+ className="text-theme-text-tertiary hover:text-theme-text-primary rounded"
196
+ >
197
+ <X className="w-3 h-3" />
198
+ </button>
199
+ </span>
200
+ ))}
201
+ <input
202
+ ref={inputRef}
203
+ type="text"
204
+ value={text}
205
+ onChange={(e) => { setDismissed(false); onChange({ text: e.target.value, pills }) }}
206
+ onKeyDown={handleKeyDown}
207
+ onFocus={onFocus}
208
+ placeholder={pills.length ? '' : placeholder}
209
+ aria-label={rest['aria-label']}
210
+ className="flex-1 min-w-[80px] bg-transparent text-sm text-theme-text-primary placeholder-theme-text-tertiary outline-none"
211
+ />
212
+ {rightSlot}
213
+ {suggesting && anchor && mod && createPortal(
214
+ <div
215
+ style={{ position: 'fixed', top: anchor.bottom + 4, left: anchor.left, width: 280, maxWidth: 'calc(100vw - 2rem)' }}
216
+ className="z-[130] dialog shadow-theme-lg overflow-hidden"
217
+ >
218
+ <div className="px-2.5 py-1 text-[10px] font-semibold uppercase tracking-wider text-theme-text-tertiary">{mod.canon}</div>
219
+ <div className="max-h-56 overflow-y-auto pb-1">
220
+ {filtered.map((opt, i) => (
221
+ <button
222
+ key={opt}
223
+ type="button"
224
+ onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); commitPill(mod.canon, opt, mod.before) }}
225
+ onMouseMove={() => setSel(i)}
226
+ className={clsx('w-full text-left px-2.5 py-1 text-sm truncate', i === sel ? 'selection' : 'hover:bg-theme-elevated/40 text-theme-text-primary')}
227
+ >
228
+ {highlightPartial(opt, mod.partial)}
229
+ </button>
230
+ ))}
231
+ </div>
232
+ </div>,
233
+ document.body,
234
+ )}
235
+ </div>
236
+ )
237
+ }
@@ -0,0 +1,58 @@
1
+ import type { ReactNode } from 'react'
2
+ import { clsx } from 'clsx'
3
+ import { ChevronUp, ChevronDown } from 'lucide-react'
4
+
5
+ export type SortDir = 'asc' | 'desc'
6
+
7
+ // Canonical dense-table header-cell styling, shared so the Applications and
8
+ // GitOps tables (modeled on the Resources table) read as one table family.
9
+ export const TH_CLASS =
10
+ 'border-b border-theme-border px-3 py-2 text-left text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary'
11
+
12
+ // A clickable, sortable column header. Clicking fires onSort(sortKey); the
13
+ // consumer owns the cycle (toggle dir, or asc→desc→off). One chevron marks the
14
+ // active column + its direction — the same affordance as the Resources table,
15
+ // replacing per-view sort dropdowns.
16
+ export function SortableTh<K extends string>({
17
+ label,
18
+ sortKey,
19
+ activeKey,
20
+ direction,
21
+ onSort,
22
+ align = 'left',
23
+ className,
24
+ }: {
25
+ label: ReactNode
26
+ sortKey: K
27
+ activeKey: K | null
28
+ direction: SortDir
29
+ onSort: (key: K) => void
30
+ align?: 'left' | 'right'
31
+ className?: string
32
+ }) {
33
+ const active = activeKey === sortKey
34
+ // Sort lives on a real <button> (keyboard-focusable, Enter/Space native) while
35
+ // aria-sort stays on the <th> — accessible sorting, not a mouse-only header.
36
+ return (
37
+ <th
38
+ aria-sort={active ? (direction === 'asc' ? 'ascending' : 'descending') : 'none'}
39
+ className={clsx(TH_CLASS, align === 'right' && 'text-right', className)}
40
+ >
41
+ <button
42
+ type="button"
43
+ onClick={() => onSort(sortKey)}
44
+ className={clsx(
45
+ 'inline-flex items-center gap-1 select-none hover:text-theme-text-primary focus-visible:outline-none focus-visible:text-theme-text-primary',
46
+ align === 'right' && 'w-full justify-end',
47
+ )}
48
+ >
49
+ {label}
50
+ {active ? (
51
+ direction === 'asc' ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />
52
+ ) : (
53
+ <span className="w-3" />
54
+ )}
55
+ </button>
56
+ </th>
57
+ )
58
+ }
@@ -0,0 +1,60 @@
1
+ import { clsx } from 'clsx'
2
+
3
+ export type SummaryTone = 'neutral' | 'success' | 'warning' | 'error' | 'info'
4
+
5
+ // A compact count tile for a view's status summary — value over label, with a
6
+ // tone-colored value and (when clickable) an active border that doubles as a
7
+ // one-click filter. Lives in the shared list-view chassis: GitOps and
8
+ // Applications both surface their status rollup as a row of these in the header.
9
+ export function SummaryTile({
10
+ label,
11
+ value,
12
+ tone = 'neutral',
13
+ onClick,
14
+ active = false,
15
+ }: {
16
+ label: string
17
+ value: number
18
+ tone?: SummaryTone
19
+ onClick?: () => void
20
+ active?: boolean
21
+ }) {
22
+ const toneClass = {
23
+ neutral: 'text-theme-text-primary',
24
+ success: 'text-emerald-600 dark:text-emerald-300',
25
+ warning: 'text-amber-600 dark:text-amber-300',
26
+ error: 'text-red-600 dark:text-red-300',
27
+ info: 'text-sky-600 dark:text-sky-300',
28
+ }[tone]
29
+ const activeBorderClass = {
30
+ neutral: 'border-skyhook-500',
31
+ success: 'border-emerald-500',
32
+ warning: 'border-amber-500',
33
+ error: 'border-red-500',
34
+ info: 'border-sky-500',
35
+ }[tone]
36
+ const value$ = <div className={`text-sm font-semibold ${toneClass}`}>{value}</div>
37
+ const label$ = <div className="text-xs text-theme-text-tertiary">{label}</div>
38
+ if (!onClick) {
39
+ return (
40
+ <div className="rounded-md border border-theme-border bg-theme-base px-3 py-2">
41
+ {value$}
42
+ {label$}
43
+ </div>
44
+ )
45
+ }
46
+ return (
47
+ <button
48
+ type="button"
49
+ onClick={onClick}
50
+ aria-pressed={active}
51
+ className={clsx(
52
+ 'cursor-pointer rounded-md border bg-theme-base px-3 py-2 text-left transition-colors hover:bg-theme-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-skyhook-500',
53
+ active ? activeBorderClass : 'border-theme-border',
54
+ )}
55
+ >
56
+ {value$}
57
+ {label$}
58
+ </button>
59
+ )
60
+ }
@@ -166,7 +166,7 @@ export function Property({ label, value, copyable, onCopy, copied }: PropertyPro
166
166
  return (
167
167
  <div className="flex items-start gap-2 text-sm">
168
168
  <span className="text-theme-text-tertiary w-40 shrink-0">{label}</span>
169
- <span className="text-theme-text-primary break-all flex-1">{displayValue}</span>
169
+ <span className={clsx('text-theme-text-primary flex-1 min-w-0', isReactElement(value) ? 'break-normal' : 'break-all')}>{displayValue}</span>
170
170
  {copyable && onCopy && !isReactElement(value) && (
171
171
  <button
172
172
  onClick={() => onCopy(strValue, labelKey)}
@@ -283,7 +283,20 @@ function isConditionHealthy(cond: { type?: string; status?: string }): boolean {
283
283
  return inverted ? cond.status === 'False' : cond.status === 'True'
284
284
  }
285
285
 
286
- export function ConditionsSection({ conditions }: { conditions?: any[] }) {
286
+ export type ConditionTone = 'ok' | 'warning' | 'fail' | 'unknown'
287
+
288
+ function defaultConditionTone(cond: { type?: string; status?: string }): ConditionTone {
289
+ if (cond.status !== 'True' && cond.status !== 'False') return 'unknown'
290
+ return isConditionHealthy(cond) ? 'ok' : 'fail'
291
+ }
292
+
293
+ export function ConditionsSection({
294
+ conditions,
295
+ getConditionTone,
296
+ }: {
297
+ conditions?: any[]
298
+ getConditionTone?: (condition: any) => ConditionTone | undefined
299
+ }) {
287
300
  if (!conditions || conditions.length === 0) return null
288
301
 
289
302
  // Sort by lastTransitionTime (most recent first), then alphabetically for ties
@@ -294,7 +307,8 @@ export function ConditionsSection({ conditions }: { conditions?: any[] }) {
294
307
  return (a.type || '').localeCompare(b.type || '')
295
308
  })
296
309
 
297
- const failCount = sorted.filter((c: any) => (c.status === 'True' || c.status === 'False') && !isConditionHealthy(c)).length
310
+ const conditionTone = (cond: any) => getConditionTone?.(cond) ?? defaultConditionTone(cond)
311
+ const failCount = sorted.filter((c: any) => conditionTone(c) === 'fail').length
298
312
 
299
313
  return (
300
314
  <Section
@@ -307,13 +321,15 @@ export function ConditionsSection({ conditions }: { conditions?: any[] }) {
307
321
 
308
322
  <div className="space-y-0.5">
309
323
  {sorted.map((cond: any) => {
310
- const isUnknown = cond.status !== 'True' && cond.status !== 'False'
311
- const isOk = !isUnknown && isConditionHealthy(cond)
312
- const isFail = !isOk && !isUnknown
324
+ const tone = conditionTone(cond)
325
+ const isOk = tone === 'ok'
326
+ const isWarning = tone === 'warning'
327
+ const isFail = tone === 'fail'
313
328
  return (
314
329
  <div key={cond.type} className={clsx(
315
330
  'flex items-start py-1.5 pr-1 text-sm relative',
316
- isFail && 'border-l-2 border-red-400/60 dark:border-red-500/40'
331
+ isFail && 'border-l-2 border-red-400/60 dark:border-red-500/40',
332
+ isWarning && 'border-l-2 border-amber-400/60 dark:border-amber-500/40'
317
333
  )}>
318
334
  {/* Timestamp column — fixed width on the left */}
319
335
  <div className="w-[48px] shrink-0 text-[10px] text-theme-text-tertiary text-right pr-2 pt-0.5">
@@ -323,16 +339,24 @@ export function ConditionsSection({ conditions }: { conditions?: any[] }) {
323
339
  <span className={clsx(
324
340
  'w-3 h-3 rounded-full flex items-center justify-center shrink-0 mt-1 z-10 ring-2 ring-theme-surface',
325
341
  isOk ? 'bg-emerald-500/20 text-emerald-500 dark:bg-emerald-500/30'
326
- : isUnknown ? 'bg-gray-400/20 text-gray-400 dark:bg-gray-400/30'
342
+ : tone === 'unknown' ? 'bg-gray-400/20 text-gray-400 dark:bg-gray-400/30'
343
+ : isWarning ? 'bg-amber-500/25 text-amber-600 dark:text-amber-400 dark:bg-amber-500/35'
327
344
  : 'bg-red-500/25 text-red-500 dark:bg-red-500/35'
328
345
  )}>
329
346
  {isOk ? <Check className="w-2 h-2" strokeWidth={4} />
330
- : isUnknown ? <Minus className="w-2 h-2" strokeWidth={4} />
347
+ : tone === 'unknown' ? <Minus className="w-2 h-2" strokeWidth={4} />
348
+ : isWarning ? <AlertTriangle className="w-2 h-2" strokeWidth={4} />
331
349
  : <X className="w-2 h-2" strokeWidth={4} />}
332
350
  </span>
333
351
  {/* Content */}
334
352
  <div className="min-w-0 flex-1 pl-2">
335
- <span className={clsx('font-medium text-[13px]', isOk ? 'text-theme-text-primary' : isUnknown ? 'text-theme-text-secondary' : 'text-red-600 dark:text-red-400')}>{cond.type}</span>
353
+ <span className={clsx(
354
+ 'font-medium text-[13px]',
355
+ isOk ? 'text-theme-text-primary'
356
+ : tone === 'unknown' ? 'text-theme-text-secondary'
357
+ : isWarning ? 'text-amber-600 dark:text-amber-400'
358
+ : 'text-red-600 dark:text-red-400'
359
+ )}>{cond.type}</span>
336
360
  {cond.reason && cond.reason !== cond.type && (
337
361
  <div className="text-[10px] text-theme-text-secondary">{cond.reason}</div>
338
362
  )}
@@ -848,36 +872,50 @@ function RelationshipGroup({ label, refs, onNavigate }: RelationshipGroupProps)
848
872
  export interface ResourceRefBadgeProps {
849
873
  resourceRef: ResourceRef
850
874
  onClick?: (ref: ResourceRef) => void
875
+ wrapAtSeparator?: boolean
851
876
  }
852
877
 
853
878
  /** Reusable chip/badge for showing a related resource with click-to-navigate */
854
- export function ResourceRefBadge({ resourceRef, onClick }: ResourceRefBadgeProps) {
879
+ export function ResourceRefBadge({ resourceRef, onClick, wrapAtSeparator }: ResourceRefBadgeProps) {
855
880
  const kindClass = getKindColor(resourceRef.kind)
856
881
  const kindName = formatKindForRef(resourceRef.kind)
882
+ const content = wrapAtSeparator ? (
883
+ <>
884
+ <span className="shrink-0 opacity-60">{kindName}/</span>
885
+ <span className="min-w-0 max-w-full whitespace-normal break-normal text-left leading-tight">{resourceRef.name}</span>
886
+ </>
887
+ ) : (
888
+ <>
889
+ <span className="opacity-60">{kindName}/</span>
890
+ {resourceRef.name}
891
+ </>
892
+ )
893
+ const layoutClass = wrapAtSeparator
894
+ ? 'badge max-w-full min-w-0 flex-wrap items-center whitespace-normal text-left leading-tight'
895
+ : 'badge'
857
896
 
858
897
  if (onClick) {
859
898
  return (
860
899
  <button
861
900
  onClick={() => onClick(resourceRef)}
862
901
  className={clsx(
863
- 'badge hover:brightness-[0.92] dark:hover:brightness-125 transition-[filter]',
902
+ layoutClass,
903
+ 'hover:brightness-[0.92] dark:hover:brightness-125 transition-[filter]',
864
904
  kindClass
865
905
  )}
866
906
  title={`${resourceRef.kind}: ${resourceRef.namespace}/${resourceRef.name}`}
867
907
  >
868
- <span className="opacity-60">{kindName}/</span>
869
- {resourceRef.name}
908
+ {content}
870
909
  </button>
871
910
  )
872
911
  }
873
912
 
874
913
  return (
875
914
  <span
876
- className={clsx('badge', kindClass)}
915
+ className={clsx(layoutClass, kindClass)}
877
916
  title={`${resourceRef.kind}: ${resourceRef.namespace}/${resourceRef.name}`}
878
917
  >
879
- <span className="opacity-60">{kindName}/</span>
880
- {resourceRef.name}
918
+ {content}
881
919
  </span>
882
920
  )
883
921
  }
@@ -922,6 +960,7 @@ function formatKindForRef(kind: string): string {
922
960
  job: 'job',
923
961
  cronjob: 'cj',
924
962
  hpa: 'hpa',
963
+ horizontalpodautoscaler: 'hpa',
925
964
  }
926
965
  return shortNames[k] || k
927
966
  }
@@ -23,3 +23,15 @@ export { CodeViewer } from './CodeViewer'
23
23
  export { YamlEditor, YamlDiffEditor } from './YamlEditor'
24
24
  export { RowActionMenu } from './RowActionMenu'
25
25
  export type { RowActionItem } from './RowActionMenu'
26
+
27
+ export { PageHeader } from './PageHeader'
28
+ export { SummaryTile } from './SummaryTile'
29
+ export type { SummaryTone } from './SummaryTile'
30
+ export { Facet, FacetSection, FacetButton } from './Facet'
31
+ export type { FacetTone } from './Facet'
32
+ export { SortableTh, TH_CLASS } from './SortableTh'
33
+ export type { SortDir } from './SortableTh'
34
+ export { DistributionBar, DistributionLegendChip } from './DistributionBar'
35
+ export type { DistributionSegment } from './DistributionBar'
36
+ export { SearchPillInput } from './SearchPillInput'
37
+ export type { SearchModifier, SearchPillInputProps } from './SearchPillInput'
@@ -22,7 +22,7 @@ import {
22
22
  BarChart3,
23
23
  Network,
24
24
  } from 'lucide-react'
25
- import type { TimelineEvent, ResourceRef, Relationships, SelectedResource, ResolvedEnvFrom, Topology, TopologyNode } from '../../types'
25
+ import type { TimelineEvent, ResourceRef, Relationships, SelectedResource, ResolvedEnvFrom, Topology, TopologyNode, HPADiagnosis } from '../../types'
26
26
  import type { GitOpsStatus } from '../../types/gitops'
27
27
  import type { NavigateToResource } from '../../utils/navigation'
28
28
  import { refToSelectedResource, pluralToKind, kindToPlural, apiVersionToGroup } from '../../utils/navigation'
@@ -49,6 +49,7 @@ import {
49
49
  import { ResourceActionsBar } from '../shared/ResourceActionsBar'
50
50
  import { EditableYamlView, SaveSuccessAnimation } from '../shared/EditableYamlView'
51
51
  import { ResourceRendererDispatch, getResourceStatus, type RendererOverrides } from '../shared/ResourceRendererDispatch'
52
+ import type { ScalerDiagnosis } from '../resources/renderers/WorkloadRenderer'
52
53
  import { DetailShell, type DetailShellTab } from '../shared/DetailShell'
53
54
  import { HelmManagedByChip, ManagedByChip, type HelmOwnerRef } from '../shared/ManagedByChip'
54
55
  import { getKindColorOutline, displayKindName } from '../ui/drawer-components'
@@ -105,6 +106,10 @@ interface WorkloadViewProps {
105
106
  relationships?: Relationships
106
107
  /** TLS certificate info for secrets */
107
108
  certificateInfo?: any
109
+ /** HPA diagnosis for HorizontalPodAutoscaler detail responses */
110
+ hpaDiagnosis?: HPADiagnosis
111
+ /** Compact diagnosis for autoscalers controlling this workload */
112
+ scalerDiagnostics?: ScalerDiagnosis[]
108
113
  /** Whether the resource is loading */
109
114
  isLoading?: boolean
110
115
  /** Fetch error for the resource (preserves status + message so the
@@ -244,6 +249,8 @@ export function WorkloadView({
244
249
  resource,
245
250
  relationships,
246
251
  certificateInfo,
252
+ hpaDiagnosis,
253
+ scalerDiagnostics,
247
254
  isLoading: resourceLoading = false,
248
255
  resourceError,
249
256
  refetch: refetchProp,
@@ -651,6 +658,8 @@ export function WorkloadView({
651
658
  data={resource}
652
659
  relationships={relationships}
653
660
  certificateInfo={certificateInfo}
661
+ hpaDiagnosis={hpaDiagnosis}
662
+ scalerDiagnostics={scalerDiagnostics}
654
663
  onCopy={copyToClipboard}
655
664
  copied={copied}
656
665
  onNavigate={onNavigateToResource ? (ref) => onNavigateToResource(refToSelectedResource(ref)) : undefined}
@@ -774,6 +783,8 @@ export function WorkloadView({
774
783
  resource={resource}
775
784
  selectedResource={selectedResource}
776
785
  relationships={relationships}
786
+ hpaDiagnosis={hpaDiagnosis}
787
+ scalerDiagnostics={scalerDiagnostics}
777
788
  isLoading={resourceLoading}
778
789
  error={resourceError}
779
790
  onNavigate={onNavigateToResource}
@@ -1305,6 +1316,8 @@ function InfoTab({
1305
1316
  resource,
1306
1317
  selectedResource,
1307
1318
  relationships,
1319
+ hpaDiagnosis,
1320
+ scalerDiagnostics,
1308
1321
  isLoading,
1309
1322
  error,
1310
1323
  onNavigate,
@@ -1326,6 +1339,8 @@ function InfoTab({
1326
1339
  resource: any
1327
1340
  selectedResource: SelectedResource
1328
1341
  relationships?: Relationships
1342
+ hpaDiagnosis?: HPADiagnosis
1343
+ scalerDiagnostics?: ScalerDiagnosis[]
1329
1344
  isLoading: boolean
1330
1345
  error?: unknown
1331
1346
  onNavigate?: NavigateToResource
@@ -1354,6 +1369,8 @@ function InfoTab({
1354
1369
  resource={selectedResource}
1355
1370
  data={resource}
1356
1371
  relationships={relationships}
1372
+ hpaDiagnosis={hpaDiagnosis}
1373
+ scalerDiagnostics={scalerDiagnostics}
1357
1374
  onCopy={onCopy}
1358
1375
  copied={copied}
1359
1376
  onNavigate={onNavigate ? (ref) => onNavigate(refToSelectedResource(ref)) : undefined}
@@ -8,6 +8,7 @@
8
8
 
9
9
  @theme inline {
10
10
  /* Backgrounds */
11
+ --color-theme-sidebar: var(--bg-sidebar);
11
12
  --color-theme-base: var(--bg-base);
12
13
  --color-theme-surface: var(--bg-surface);
13
14
  --color-theme-elevated: var(--bg-elevated);
@@ -30,10 +30,14 @@
30
30
  --color-error-dark: #DC2626;
31
31
  --color-info: #3B82F6;
32
32
 
33
- /* ── Backgrounds ──
34
- base = page bg, header/toolbar chrome (barely-blue cool gray)
35
- surface = sidebars, content panels, cards (near-white)
33
+ /* ── Backgrounds (elevation scale) ──
34
+ sidebar = primary nav rail — the DEEPEST layer, its own token so the rail
35
+ reads as chrome on every view regardless of what content puts
36
+ next to it (vs. borrowing contrast from an adjacent surface pane)
37
+ base = content floor, page bg, header/toolbar chrome (barely-blue gray)
38
+ surface = content panels, cards, tables, facet panes (raised, near-white)
36
39
  elevated = inputs, count badges, dropdowns (very lightly blue-tinted) */
40
+ --bg-sidebar: light-dark(#E7ECF5, #080B16);
37
41
  --bg-base: light-dark(#F0F3F9, #0B0F1E);
38
42
  --bg-surface: light-dark(#F8FBFE, #14192A);
39
43
  --bg-elevated: light-dark(#E6ECF5, #1E2338);