@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.
- package/package.json +7 -7
- package/src/components/applications/ApplicationsList.tsx +131 -115
- package/src/components/applications/index.ts +3 -1
- package/src/components/audit/AuditCard.tsx +1 -1
- package/src/components/charts/AreaChart.tsx +52 -18
- package/src/components/charts/MetricsSummary.tsx +12 -3
- package/src/components/charts/saturation.test.ts +34 -0
- package/src/components/charts/saturation.ts +1 -0
- package/src/components/charts/types.ts +5 -4
- package/src/components/checks/ChecksView.tsx +12 -23
- package/src/components/checks/severity.ts +11 -11
- package/src/components/gitops/GitOpsTableView.tsx +298 -274
- package/src/components/issues/IssuesView.tsx +4 -6
- package/src/components/issues/severity.ts +19 -11
- package/src/components/issues/types.ts +22 -2
- package/src/components/logs/WorkloadLogsViewer.tsx +16 -7
- package/src/components/resources/ResourcesSidebar.tsx +3 -1
- package/src/components/resources/ResourcesView.tsx +180 -21
- package/src/components/resources/get-pod-problems.test.ts +127 -0
- package/src/components/resources/index.ts +1 -0
- package/src/components/resources/renderers/HPARenderer.test.tsx +91 -0
- package/src/components/resources/renderers/HPARenderer.tsx +93 -86
- package/src/components/resources/renderers/PodRenderer.test.tsx +56 -0
- package/src/components/resources/renderers/PodRenderer.tsx +1 -1
- package/src/components/resources/renderers/WorkloadRenderer.test.tsx +83 -0
- package/src/components/resources/renderers/WorkloadRenderer.tsx +73 -4
- package/src/components/resources/resource-utils-hpa.test.ts +30 -0
- package/src/components/resources/resource-utils-hpa.ts +106 -0
- package/src/components/resources/resource-utils.ts +41 -28
- package/src/components/shared/ResourceRendererDispatch.tsx +10 -2
- package/src/components/ui/DistributionBar.tsx +88 -0
- package/src/components/ui/Facet.tsx +130 -0
- package/src/components/ui/PageHeader.tsx +43 -0
- package/src/components/ui/SearchPillInput.tsx +237 -0
- package/src/components/ui/SortableTh.tsx +58 -0
- package/src/components/ui/SummaryTile.tsx +60 -0
- package/src/components/ui/drawer-components.tsx +56 -17
- package/src/components/ui/index.ts +12 -0
- package/src/components/workload/WorkloadView.tsx +18 -1
- package/src/theme/tailwind-theme.css +1 -0
- package/src/theme/variables.css +7 -3
- package/src/types/core.ts +64 -2
- package/src/utils/bulk-workload-actions.test.ts +78 -0
- package/src/utils/bulk-workload-actions.ts +50 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/rbac-blast-radius.test.ts +48 -3
- package/src/utils/rbac-blast-radius.ts +14 -4
- 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=
|
|
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
|
|
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
|
|
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
|
|
311
|
-
const isOk =
|
|
312
|
-
const
|
|
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
|
-
:
|
|
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
|
-
:
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
869
|
-
{resourceRef.name}
|
|
908
|
+
{content}
|
|
870
909
|
</button>
|
|
871
910
|
)
|
|
872
911
|
}
|
|
873
912
|
|
|
874
913
|
return (
|
|
875
914
|
<span
|
|
876
|
-
className={clsx(
|
|
915
|
+
className={clsx(layoutClass, kindClass)}
|
|
877
916
|
title={`${resourceRef.kind}: ${resourceRef.namespace}/${resourceRef.name}`}
|
|
878
917
|
>
|
|
879
|
-
|
|
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}
|
package/src/theme/variables.css
CHANGED
|
@@ -30,10 +30,14 @@
|
|
|
30
30
|
--color-error-dark: #DC2626;
|
|
31
31
|
--color-info: #3B82F6;
|
|
32
32
|
|
|
33
|
-
/* ── Backgrounds ──
|
|
34
|
-
|
|
35
|
-
|
|
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);
|