@skyhook-io/k8s-ui 1.5.10 → 1.5.12
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 +1 -1
- package/src/components/audit/AuditFindingsTable.tsx +66 -30
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +65 -12
- package/src/components/dock/BottomDock.tsx +23 -12
- package/src/components/gitops/ManagedResourcesList.tsx +4 -2
- package/src/components/logs/LogCore.tsx +109 -14
- package/src/components/resources/ResourcesView.tsx +19 -2
- package/src/components/resources/get-pod-phase-display.test.ts +254 -0
- package/src/components/resources/renderers/AWSManagedMachinePoolRenderer.tsx +2 -4
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +3 -2
- package/src/components/resources/renderers/AzureManagedMachinePoolRenderer.tsx +3 -8
- package/src/components/resources/renderers/ChallengeRenderer.tsx +2 -1
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -1
- package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +2 -1
- package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +2 -1
- package/src/components/resources/renderers/GatewayClassRenderer.tsx +2 -1
- package/src/components/resources/renderers/GatewayRenderer.tsx +5 -4
- package/src/components/resources/renderers/IngressClassRenderer.tsx +2 -1
- package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +2 -1
- package/src/components/resources/renderers/IstioGatewayRenderer.tsx +3 -2
- package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +2 -1
- package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +2 -2
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.test.tsx +31 -0
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +5 -5
- package/src/components/resources/renderers/OrderRenderer.tsx +2 -1
- package/src/components/resources/renderers/PodRenderer.tsx +23 -2
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +3 -2
- package/src/components/resources/renderers/RolloutRenderer.tsx +2 -1
- package/src/components/resources/renderers/SecretRenderer.tsx +15 -6
- package/src/components/resources/renderers/aws-capi-cells.tsx +2 -4
- package/src/components/resources/renderers/azure-capi-cells.tsx +3 -8
- package/src/components/resources/renderers/istio-cells.tsx +3 -2
- package/src/components/resources/renderers/karpenter-cells.tsx +1 -1
- package/src/components/resources/resource-utils-karpenter.ts +0 -6
- package/src/components/resources/resource-utils.ts +110 -0
- package/src/components/resources/resources-search-sidebar-hint.test.ts +85 -0
- package/src/components/timeline/shared.tsx +5 -6
- package/src/components/topology/TopologySearch.tsx +49 -16
- package/src/components/topology/topology-search-match.test.ts +112 -0
- package/src/components/ui/Tooltip.tsx +73 -40
- package/src/components/ui/tooltip-position.test.ts +132 -0
- package/src/components/ui/tooltip-position.ts +86 -0
- package/src/components/workload/ResourceDetailDrawer.tsx +3 -3
- package/src/components/workload/WorkloadView.tsx +6 -6
- package/src/types/core.ts +13 -1
- package/src/utils/badge-colors.ts +19 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/validators.test.ts +207 -0
- package/src/utils/validators.ts +178 -0
- package/src/utils/view-transition.test.ts +81 -0
- package/src/utils/view-transition.ts +62 -0
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useMemo, useRef, useEffect } from 'react'
|
|
1
|
+
import { useState, useMemo, useRef, useEffect, type Dispatch, type SetStateAction } from 'react'
|
|
2
2
|
import { ShieldAlert, AlertTriangle, ChevronRight, CheckCircle2, Search, ExternalLink, MoreHorizontal, EyeOff, Layers } from 'lucide-react'
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
4
|
import type { AuditFinding } from './AuditAlerts'
|
|
@@ -48,15 +48,35 @@ export interface AuditFindingsTableProps {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
export function AuditFindingsTable({ groups, findings, checks, onResourceClick, onHideCheck, onHideCategory, onHideNamespace, multiCluster, onClusterClick }: AuditFindingsTableProps) {
|
|
51
|
-
const [categoryFilter, setCategoryFilter] = useState<string
|
|
52
|
-
const [severityFilter, setSeverityFilter] = useState<string
|
|
53
|
-
const [frameworkFilter, setFrameworkFilter] = useState<string
|
|
51
|
+
const [categoryFilter, setCategoryFilter] = useState<Set<string>>(new Set())
|
|
52
|
+
const [severityFilter, setSeverityFilter] = useState<Set<string>>(new Set())
|
|
53
|
+
const [frameworkFilter, setFrameworkFilter] = useState<Set<string>>(new Set())
|
|
54
54
|
const [searchTerm, setSearchTerm] = useState('')
|
|
55
55
|
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
|
56
56
|
const [expandedNS, setExpandedNS] = useState<Set<string>>(new Set())
|
|
57
57
|
const [groupByNS, setGroupByNS] = useState(false)
|
|
58
58
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
|
59
59
|
|
|
60
|
+
const toggleInSet = (setter: Dispatch<SetStateAction<Set<string>>>, value: string) => {
|
|
61
|
+
setter(prev => {
|
|
62
|
+
const next = new Set(prev)
|
|
63
|
+
if (next.has(value)) next.delete(value)
|
|
64
|
+
else next.add(value)
|
|
65
|
+
return next
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const clearChipFilters = () => {
|
|
70
|
+
setCategoryFilter(new Set())
|
|
71
|
+
setSeverityFilter(new Set())
|
|
72
|
+
setFrameworkFilter(new Set())
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const clearAllFilters = () => {
|
|
76
|
+
clearChipFilters()
|
|
77
|
+
setSearchTerm('')
|
|
78
|
+
}
|
|
79
|
+
|
|
60
80
|
// "/" keyboard shortcut to focus search
|
|
61
81
|
useEffect(() => {
|
|
62
82
|
const handler = (e: KeyboardEvent) => {
|
|
@@ -88,13 +108,14 @@ export function AuditFindingsTable({ groups, findings, checks, onResourceClick,
|
|
|
88
108
|
|
|
89
109
|
const searchLower = searchTerm.toLowerCase()
|
|
90
110
|
|
|
91
|
-
// Match a finding against category/severity/framework filters
|
|
111
|
+
// Match a finding against category/severity/framework filters.
|
|
112
|
+
// Within a dimension, multiple selected values are OR'd. Across dimensions, AND.
|
|
92
113
|
const matchesFinding = (f: AuditFinding) => {
|
|
93
|
-
if (categoryFilter && f.category
|
|
94
|
-
if (severityFilter && f.severity
|
|
95
|
-
if (frameworkFilter && checks) {
|
|
96
|
-
const
|
|
97
|
-
if (!
|
|
114
|
+
if (categoryFilter.size > 0 && !categoryFilter.has(f.category)) return false
|
|
115
|
+
if (severityFilter.size > 0 && !severityFilter.has(f.severity)) return false
|
|
116
|
+
if (frameworkFilter.size > 0 && checks) {
|
|
117
|
+
const fws = checks[f.checkID]?.frameworks
|
|
118
|
+
if (!fws || !fws.some(fw => frameworkFilter.has(fw))) return false
|
|
98
119
|
}
|
|
99
120
|
return true
|
|
100
121
|
}
|
|
@@ -141,7 +162,8 @@ export function AuditFindingsTable({ groups, findings, checks, onResourceClick,
|
|
|
141
162
|
}
|
|
142
163
|
|
|
143
164
|
// Compute counts from filtered results (so summary reflects active filters)
|
|
144
|
-
const
|
|
165
|
+
const hasActiveChipFilters = categoryFilter.size > 0 || severityFilter.size > 0 || frameworkFilter.size > 0
|
|
166
|
+
const hasActiveFilters = hasActiveChipFilters || searchTerm !== ''
|
|
145
167
|
const filteredAllFindings = filteredGroups
|
|
146
168
|
? filteredGroups.flatMap(g => g.findings)
|
|
147
169
|
: filteredFindings ?? []
|
|
@@ -243,28 +265,30 @@ export function AuditFindingsTable({ groups, findings, checks, onResourceClick,
|
|
|
243
265
|
)}
|
|
244
266
|
</div>
|
|
245
267
|
|
|
246
|
-
{/* Row 2: Filter chips — three groups separated by dividers (All | Categories | Severities | Frameworks).
|
|
268
|
+
{/* Row 2: Filter chips — three groups separated by dividers (All | Categories | Severities | Frameworks).
|
|
269
|
+
Each chip is an independent toggle. Multiple chips within a dimension OR together;
|
|
270
|
+
dimensions AND together. */}
|
|
247
271
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
248
|
-
<FilterPill label="All" active={!
|
|
272
|
+
<FilterPill label="All" active={!hasActiveChipFilters} onClick={clearChipFilters} />
|
|
249
273
|
<span className="w-px h-5 bg-theme-border mx-2" />
|
|
250
274
|
{CATEGORIES.map(cat => (
|
|
251
|
-
<FilterPill key={cat} label={cat} active={categoryFilter
|
|
275
|
+
<FilterPill key={cat} label={cat} active={categoryFilter.has(cat)} onClick={() => toggleInSet(setCategoryFilter, cat)} />
|
|
252
276
|
))}
|
|
253
277
|
<span className="w-px h-5 bg-theme-border mx-2" />
|
|
254
278
|
{SEVERITIES.map(sev => (
|
|
255
279
|
<FilterPill
|
|
256
280
|
key={sev}
|
|
257
281
|
label={sev === 'danger' ? 'Critical' : 'Warning'}
|
|
258
|
-
active={severityFilter
|
|
282
|
+
active={severityFilter.has(sev)}
|
|
259
283
|
tone={sev === 'danger' ? 'danger' : 'warn'}
|
|
260
|
-
onClick={() => setSeverityFilter
|
|
284
|
+
onClick={() => toggleInSet(setSeverityFilter, sev)}
|
|
261
285
|
/>
|
|
262
286
|
))}
|
|
263
287
|
{frameworks.length > 0 && (
|
|
264
288
|
<>
|
|
265
289
|
<span className="w-px h-5 bg-theme-border mx-2" />
|
|
266
290
|
{frameworks.map(fw => (
|
|
267
|
-
<FilterPill key={fw} label={fw} active={frameworkFilter
|
|
291
|
+
<FilterPill key={fw} label={fw} active={frameworkFilter.has(fw)} onClick={() => toggleInSet(setFrameworkFilter, fw)} />
|
|
268
292
|
))}
|
|
269
293
|
</>
|
|
270
294
|
)}
|
|
@@ -292,7 +316,7 @@ export function AuditFindingsTable({ groups, findings, checks, onResourceClick,
|
|
|
292
316
|
action={
|
|
293
317
|
<button
|
|
294
318
|
type="button"
|
|
295
|
-
onClick={
|
|
319
|
+
onClick={clearAllFilters}
|
|
296
320
|
className="badge badge-sm border border-theme-border bg-theme-elevated text-theme-text-primary hover:bg-theme-hover transition-colors"
|
|
297
321
|
>
|
|
298
322
|
Clear all filters
|
|
@@ -309,9 +333,16 @@ export function AuditFindingsTable({ groups, findings, checks, onResourceClick,
|
|
|
309
333
|
const nsWarning = nsGroups.reduce((n, g) => n + g.warning, 0)
|
|
310
334
|
return (
|
|
311
335
|
<div key={ns}>
|
|
312
|
-
<
|
|
336
|
+
<div
|
|
337
|
+
role="button"
|
|
338
|
+
tabIndex={0}
|
|
339
|
+
aria-expanded={nsExpanded}
|
|
313
340
|
onClick={() => toggleNS(ns)}
|
|
314
|
-
|
|
341
|
+
onKeyDown={(e) => {
|
|
342
|
+
if (e.target !== e.currentTarget) return
|
|
343
|
+
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleNS(ns) }
|
|
344
|
+
}}
|
|
345
|
+
className="group flex items-center gap-3 w-full px-4 py-2 rounded-lg hover:bg-theme-hover/30 transition-colors text-left cursor-pointer focus-visible:ring-2 focus-visible:ring-theme-text-primary/20 focus-visible:outline-none"
|
|
315
346
|
>
|
|
316
347
|
<ChevronRight className={clsx('w-4 h-4 text-theme-text-tertiary shrink-0 transition-transform duration-200', nsExpanded && 'rotate-90')} />
|
|
317
348
|
<span className="text-sm font-semibold text-theme-text-primary">{ns}</span>
|
|
@@ -324,7 +355,7 @@ export function AuditFindingsTable({ groups, findings, checks, onResourceClick,
|
|
|
324
355
|
{onHideNamespace && ns !== '(cluster-scoped)' && (
|
|
325
356
|
<ContextMenu items={[{ label: `Hide ${ns} namespace`, onClick: () => onHideNamespace(ns) }]} />
|
|
326
357
|
)}
|
|
327
|
-
</
|
|
358
|
+
</div>
|
|
328
359
|
<div
|
|
329
360
|
className="grid transition-[grid-template-rows] duration-200 ease-out"
|
|
330
361
|
style={{ gridTemplateRows: nsExpanded ? '1fr' : '0fr' }}
|
|
@@ -422,9 +453,16 @@ function ResourceGroupRow({ group: g, checks, expanded, onToggle, onResourceClic
|
|
|
422
453
|
|
|
423
454
|
return (
|
|
424
455
|
<div>
|
|
425
|
-
<
|
|
456
|
+
<div
|
|
457
|
+
role="button"
|
|
458
|
+
tabIndex={0}
|
|
459
|
+
aria-expanded={isExpanded}
|
|
426
460
|
onClick={() => onToggle(key)}
|
|
427
|
-
|
|
461
|
+
onKeyDown={(e) => {
|
|
462
|
+
if (e.target !== e.currentTarget) return
|
|
463
|
+
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggle(key) }
|
|
464
|
+
}}
|
|
465
|
+
className="group flex items-center gap-3 w-full px-4 py-2.5 rounded-lg hover:bg-theme-hover/50 transition-colors text-left cursor-pointer focus-visible:ring-2 focus-visible:ring-theme-text-primary/20 focus-visible:outline-none"
|
|
428
466
|
>
|
|
429
467
|
<ChevronRight className={clsx('w-3.5 h-3.5 text-theme-text-tertiary shrink-0 transition-transform duration-200', isExpanded && 'rotate-90')} />
|
|
430
468
|
{hasDanger ? (
|
|
@@ -434,16 +472,14 @@ function ResourceGroupRow({ group: g, checks, expanded, onToggle, onResourceClic
|
|
|
434
472
|
)}
|
|
435
473
|
<span className="text-xs text-theme-text-tertiary shrink-0">{g.kind}</span>
|
|
436
474
|
{onResourceClick ? (
|
|
437
|
-
<
|
|
438
|
-
|
|
439
|
-
tabIndex={0}
|
|
475
|
+
<button
|
|
476
|
+
type="button"
|
|
440
477
|
onClick={(e) => { e.stopPropagation(); onResourceClick(g.kind, g.namespace, g.name) }}
|
|
441
|
-
|
|
442
|
-
className="text-sm font-medium text-skyhook-500 hover:text-skyhook-400 hover:underline cursor-pointer truncate max-w-[300px] inline-flex items-center gap-1"
|
|
478
|
+
className="text-sm font-medium text-skyhook-500 hover:text-skyhook-400 hover:underline cursor-pointer truncate max-w-[300px] inline-flex items-center gap-1 text-left"
|
|
443
479
|
>
|
|
444
480
|
{showNamespace && g.namespace ? `${g.namespace} / ` : ''}{g.name}
|
|
445
481
|
<ExternalLink className="w-3 h-3 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" />
|
|
446
|
-
</
|
|
482
|
+
</button>
|
|
447
483
|
) : (
|
|
448
484
|
<span className="text-sm font-medium text-theme-text-primary truncate max-w-[300px]">
|
|
449
485
|
{showNamespace && g.namespace ? `${g.namespace} / ` : ''}{g.name}
|
|
@@ -457,7 +493,7 @@ function ResourceGroupRow({ group: g, checks, expanded, onToggle, onResourceClic
|
|
|
457
493
|
{showNamespace && onHideNamespace && g.namespace && (
|
|
458
494
|
<ContextMenu items={[{ label: `Hide ${g.namespace} namespace`, onClick: () => onHideNamespace(g.namespace) }]} />
|
|
459
495
|
)}
|
|
460
|
-
</
|
|
496
|
+
</div>
|
|
461
497
|
<div
|
|
462
498
|
className="grid transition-[grid-template-rows] duration-200 ease-out"
|
|
463
499
|
style={{ gridTemplateRows: isExpanded ? '1fr' : '0fr' }}
|
|
@@ -4,9 +4,12 @@ import {
|
|
|
4
4
|
useMemo,
|
|
5
5
|
useRef,
|
|
6
6
|
useState,
|
|
7
|
+
forwardRef,
|
|
8
|
+
useImperativeHandle,
|
|
7
9
|
} from 'react'
|
|
8
|
-
import { ChevronDown, Check, Loader2, Search, Server, X } from 'lucide-react'
|
|
10
|
+
import { ChevronDown, Check, FolderOpen, Loader2, Search, Server, X } from 'lucide-react'
|
|
9
11
|
import { ClusterName } from '../ui/ClusterName'
|
|
12
|
+
import { MiddleEllipsis } from '../ui/MiddleEllipsis'
|
|
10
13
|
import { StatusDot, type StatusTone } from '../ui/status-tone'
|
|
11
14
|
|
|
12
15
|
export interface ClusterSwitcherItem {
|
|
@@ -16,6 +19,10 @@ export interface ClusterSwitcherItem {
|
|
|
16
19
|
name: string
|
|
17
20
|
secondary?: string
|
|
18
21
|
badge?: string
|
|
22
|
+
/** Origin label, rendered as a folder-icon line under the name.
|
|
23
|
+
* Caller must only set this when 2+ distinct sources exist — the
|
|
24
|
+
* chip surfaces unconditionally when present. */
|
|
25
|
+
sourceLabel?: string
|
|
19
26
|
group?: { key: string; label?: string }
|
|
20
27
|
disabled?: boolean
|
|
21
28
|
status?: StatusTone
|
|
@@ -27,11 +34,18 @@ export interface ClusterSwitcherItem {
|
|
|
27
34
|
title?: string
|
|
28
35
|
}
|
|
29
36
|
|
|
37
|
+
export interface ClusterSwitcherHandle {
|
|
38
|
+
open: () => void
|
|
39
|
+
}
|
|
40
|
+
|
|
30
41
|
export interface ClusterSwitcherProps {
|
|
31
42
|
currentId?: string
|
|
32
43
|
/** Raw context / display string. Pass it as-is — the trigger renders
|
|
33
44
|
* through ClusterName, which handles parse + provider badge + tooltip. */
|
|
34
45
|
currentName: string
|
|
46
|
+
/** Trigger-side counterpart to {@link ClusterSwitcherItem.sourceLabel}.
|
|
47
|
+
* Only pass when 2+ kubeconfig sources are loaded. */
|
|
48
|
+
currentSourceLabel?: string
|
|
35
49
|
items: ClusterSwitcherItem[]
|
|
36
50
|
onSelect?: (item: ClusterSwitcherItem) => void
|
|
37
51
|
searchable?: boolean
|
|
@@ -54,9 +68,10 @@ export interface ClusterSwitcherProps {
|
|
|
54
68
|
// full — comfortably covering parsed cluster names from any provider.
|
|
55
69
|
const TRIGGER_NAME_MAX_WIDTH = 'max-w-[160px] sm:max-w-[260px] xl:max-w-[400px]'
|
|
56
70
|
|
|
57
|
-
export
|
|
71
|
+
export const ClusterSwitcher = forwardRef<ClusterSwitcherHandle, ClusterSwitcherProps>(({
|
|
58
72
|
currentId,
|
|
59
73
|
currentName,
|
|
74
|
+
currentSourceLabel,
|
|
60
75
|
items,
|
|
61
76
|
onSelect,
|
|
62
77
|
searchable = true,
|
|
@@ -69,13 +84,19 @@ export function ClusterSwitcher({
|
|
|
69
84
|
errorSlot,
|
|
70
85
|
className = '',
|
|
71
86
|
align = 'left',
|
|
72
|
-
}
|
|
87
|
+
}, ref) => {
|
|
73
88
|
const [isOpen, setIsOpen] = useState(false)
|
|
74
89
|
const [search, setSearch] = useState('')
|
|
75
90
|
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
|
76
91
|
const rootRef = useRef<HTMLDivElement>(null)
|
|
77
92
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
|
78
93
|
|
|
94
|
+
useImperativeHandle(ref, () => ({
|
|
95
|
+
open: () => {
|
|
96
|
+
if (!disabled && !loading) setIsOpen(true)
|
|
97
|
+
}
|
|
98
|
+
}), [disabled, loading])
|
|
99
|
+
|
|
79
100
|
const groups = useMemo(() => {
|
|
80
101
|
const q = search.trim().toLowerCase()
|
|
81
102
|
const matches = (item: ClusterSwitcherItem) => {
|
|
@@ -84,6 +105,7 @@ export function ClusterSwitcher({
|
|
|
84
105
|
item.name.toLowerCase().includes(q) ||
|
|
85
106
|
item.secondary?.toLowerCase().includes(q) ||
|
|
86
107
|
item.badge?.toLowerCase().includes(q) ||
|
|
108
|
+
item.sourceLabel?.toLowerCase().includes(q) ||
|
|
87
109
|
item.group?.label?.toLowerCase().includes(q)
|
|
88
110
|
)
|
|
89
111
|
}
|
|
@@ -175,7 +197,7 @@ export function ClusterSwitcher({
|
|
|
175
197
|
onClick={() => setIsOpen(v => !v)}
|
|
176
198
|
disabled={disabled || loading}
|
|
177
199
|
className={`
|
|
178
|
-
flex items-center gap-1.5 px-2.5 py-1.5
|
|
200
|
+
flex items-center gap-1.5 px-2.5 py-1.5 min-w-[140px]
|
|
179
201
|
bg-theme-elevated border border-theme-border rounded text-sm font-medium
|
|
180
202
|
text-theme-text-primary hover:bg-theme-hover hover:border-theme-border-light
|
|
181
203
|
transition-colors cursor-pointer
|
|
@@ -195,14 +217,31 @@ export function ClusterSwitcher({
|
|
|
195
217
|
// while the dropdown is open — the popover already shows the raw
|
|
196
218
|
// context inline (per-row secondary line), and an extra hover
|
|
197
219
|
// tooltip would just overlap the search input.
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
220
|
+
<>
|
|
221
|
+
<ClusterName
|
|
222
|
+
name={currentName}
|
|
223
|
+
fallbackBadge={<Server className="w-3.5 h-3.5 text-theme-text-secondary" />}
|
|
224
|
+
className={TRIGGER_NAME_MAX_WIDTH}
|
|
225
|
+
noTooltip={isOpen}
|
|
226
|
+
/>
|
|
227
|
+
{currentSourceLabel && (
|
|
228
|
+
// Icon-only on the trigger: long folder paths (the very case
|
|
229
|
+
// that motivates the chip) middle-truncate to something
|
|
230
|
+
// useless like "kube-cluster-pro…ion-eu" and steal width
|
|
231
|
+
// from the cluster name + nav. The folder icon signals
|
|
232
|
+
// "multi-source — disambiguation in dropdown"; hover or
|
|
233
|
+
// open the dropdown for the full label.
|
|
234
|
+
<span
|
|
235
|
+
className="shrink-0 inline-flex items-center text-theme-text-tertiary opacity-80"
|
|
236
|
+
title={`From kubeconfig: ${currentSourceLabel}`}
|
|
237
|
+
aria-label={`From kubeconfig: ${currentSourceLabel}`}
|
|
238
|
+
>
|
|
239
|
+
<FolderOpen className="w-3 h-3" />
|
|
240
|
+
</span>
|
|
241
|
+
)}
|
|
242
|
+
</>
|
|
204
243
|
)}
|
|
205
|
-
<ChevronDown className={`w-3 h-3 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
|
244
|
+
<ChevronDown className={`w-3 h-3 ml-auto transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
|
206
245
|
</button>
|
|
207
246
|
|
|
208
247
|
{isOpen && (
|
|
@@ -308,6 +347,20 @@ export function ClusterSwitcher({
|
|
|
308
347
|
</span>
|
|
309
348
|
)}
|
|
310
349
|
</div>
|
|
350
|
+
{item.sourceLabel && (
|
|
351
|
+
// Source chip lives on its own line under the
|
|
352
|
+
// name so long folder paths get the full row
|
|
353
|
+
// width to render via MiddleEllipsis. Inline
|
|
354
|
+
// would steal width from the name and force
|
|
355
|
+
// both to truncate.
|
|
356
|
+
<div
|
|
357
|
+
className="flex items-center gap-0.5 text-[10px] text-theme-text-tertiary opacity-80 mt-0.5"
|
|
358
|
+
title={`From kubeconfig: ${item.sourceLabel}`}
|
|
359
|
+
>
|
|
360
|
+
<FolderOpen className="w-2.5 h-2.5 shrink-0" />
|
|
361
|
+
<MiddleEllipsis text={item.sourceLabel} className="font-mono" />
|
|
362
|
+
</div>
|
|
363
|
+
)}
|
|
311
364
|
{item.secondary && (
|
|
312
365
|
<div
|
|
313
366
|
className="text-[10px] text-theme-text-tertiary opacity-70 truncate mt-0.5"
|
|
@@ -344,4 +397,4 @@ export function ClusterSwitcher({
|
|
|
344
397
|
)}
|
|
345
398
|
</div>
|
|
346
399
|
)
|
|
347
|
-
}
|
|
400
|
+
})
|
|
@@ -3,7 +3,7 @@ import { DURATION_DOCK } from '../../utils/animation'
|
|
|
3
3
|
import { X, ChevronDown, ChevronUp, Terminal, FileText, Trash2, Layers, Maximize2, Minimize2, Activity } from 'lucide-react'
|
|
4
4
|
import { clsx } from 'clsx'
|
|
5
5
|
import { useDock, DockTab } from './DockContext'
|
|
6
|
-
import {
|
|
6
|
+
import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
|
|
7
7
|
|
|
8
8
|
const MIN_HEIGHT = 200
|
|
9
9
|
const DEFAULT_HEIGHT = 400
|
|
@@ -80,25 +80,36 @@ export function BottomDock({ renderTabContent, renderTabHeaderExtra, leftOffset:
|
|
|
80
80
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
81
81
|
}, [isMaximized])
|
|
82
82
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
83
|
+
useRegisterShortcuts([
|
|
84
|
+
{
|
|
85
|
+
id: 'dock-toggle-backtick',
|
|
86
|
+
keys: 'Ctrl+`',
|
|
87
|
+
description: 'Toggle dock',
|
|
88
|
+
category: 'Dock',
|
|
89
|
+
scope: 'global',
|
|
90
|
+
handler: () => toggleExpanded(),
|
|
91
|
+
enabled: tabs.length > 0,
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
id: 'dock-toggle-j',
|
|
95
|
+
keys: 'Cmd+J',
|
|
96
|
+
description: 'Toggle dock',
|
|
97
|
+
category: 'Dock',
|
|
98
|
+
scope: 'global',
|
|
99
|
+
handler: () => toggleExpanded(),
|
|
100
|
+
enabled: tabs.length > 0,
|
|
101
|
+
},
|
|
102
|
+
])
|
|
92
103
|
|
|
93
104
|
if (tabs.length === 0) {
|
|
94
105
|
return null
|
|
95
106
|
}
|
|
96
107
|
|
|
97
|
-
const effectiveHeight = !isExpanded ? 36 : isMaximized ? `calc(
|
|
108
|
+
const effectiveHeight = !isExpanded ? 36 : isMaximized ? `calc(100% - ${MAXIMIZED_TOP_OFFSET}px)` : height
|
|
98
109
|
|
|
99
110
|
return (
|
|
100
111
|
<div
|
|
101
|
-
className="
|
|
112
|
+
className="absolute bottom-0 right-0 bg-theme-base border-t border-theme-border flex flex-col z-40 overflow-hidden"
|
|
102
113
|
style={{ height: effectiveHeight, left: leftOffset, transition: `height ${DURATION_DOCK}ms cubic-bezier(0.4, 0, 0.2, 1), left ${DURATION_DOCK}ms ease-out` }}
|
|
103
114
|
>
|
|
104
115
|
{isExpanded && !isMaximized && (
|
|
@@ -132,8 +132,10 @@ function ResourceItem({ resource, onClick, showHealth }: ResourceItemProps) {
|
|
|
132
132
|
|
|
133
133
|
const content = (
|
|
134
134
|
<div className="flex items-center gap-2 py-0.5">
|
|
135
|
-
{showHealth &&
|
|
136
|
-
<
|
|
135
|
+
{showHealth && (
|
|
136
|
+
<span className="shrink-0 w-1.5 h-1.5 inline-flex items-center justify-center">
|
|
137
|
+
{resource.health && <HealthDot health={resource.health} />}
|
|
138
|
+
</span>
|
|
137
139
|
)}
|
|
138
140
|
<span className="text-xs text-theme-text-secondary truncate" title={displayName}>
|
|
139
141
|
{displayName}
|
|
@@ -72,6 +72,22 @@ const TIMESTAMP_FORMAT_ORDER: TimestampFormat[] = [
|
|
|
72
72
|
'time-local', 'time-utc', 'iso-local', 'iso-utc', 'relative', 'epoch',
|
|
73
73
|
]
|
|
74
74
|
|
|
75
|
+
export type StructuredMode = 'compact' | 'expanded' | 'raw'
|
|
76
|
+
|
|
77
|
+
const STRUCTURED_MODE_ORDER: StructuredMode[] = ['compact', 'expanded', 'raw']
|
|
78
|
+
|
|
79
|
+
const STRUCTURED_MODE_LABELS: Record<StructuredMode, string> = {
|
|
80
|
+
compact: 'Compact',
|
|
81
|
+
expanded: 'Expanded',
|
|
82
|
+
raw: 'Raw',
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const STRUCTURED_MODE_DESCRIPTIONS: Record<StructuredMode, string> = {
|
|
86
|
+
compact: 'Summary line with field count',
|
|
87
|
+
expanded: 'All fields shown as a tree',
|
|
88
|
+
raw: 'Original log line, unparsed',
|
|
89
|
+
}
|
|
90
|
+
|
|
75
91
|
const TIMESTAMP_FORMAT_SHORT_LABELS: Record<TimestampFormat, string> = {
|
|
76
92
|
'time-local': 'Local time',
|
|
77
93
|
'time-utc': 'UTC time',
|
|
@@ -163,7 +179,13 @@ export function LogCore({
|
|
|
163
179
|
)
|
|
164
180
|
const [showDownloadMenu, setShowDownloadMenu] = useState(false)
|
|
165
181
|
const [showTsMenu, setShowTsMenu] = useState(false)
|
|
166
|
-
const [
|
|
182
|
+
const [showStructuredMenu, setShowStructuredMenu] = useState(false)
|
|
183
|
+
const [structuredMode, setStructuredMode] = useState<StructuredMode>(() => {
|
|
184
|
+
try {
|
|
185
|
+
const v = localStorage.getItem('radar-logs-structured-mode') as StructuredMode | null
|
|
186
|
+
return v && STRUCTURED_MODE_ORDER.includes(v) ? v : 'compact'
|
|
187
|
+
} catch { return 'compact' }
|
|
188
|
+
})
|
|
167
189
|
const [expandedStacks, setExpandedStacks] = useState<Set<number>>(() => new Set())
|
|
168
190
|
|
|
169
191
|
// Re-render every 15s so "relative" timestamps tick forward during idle viewing.
|
|
@@ -225,6 +247,33 @@ export function LogCore({
|
|
|
225
247
|
return () => window.removeEventListener('click', handleClick)
|
|
226
248
|
}, [showTsMenu])
|
|
227
249
|
|
|
250
|
+
const structuredMenuRef = useRef<HTMLDivElement>(null)
|
|
251
|
+
useEffect(() => {
|
|
252
|
+
if (!showStructuredMenu) return
|
|
253
|
+
const handleClick = (e: MouseEvent) => {
|
|
254
|
+
if (structuredMenuRef.current?.contains(e.target as Node)) return
|
|
255
|
+
setShowStructuredMenu(false)
|
|
256
|
+
}
|
|
257
|
+
window.addEventListener('click', handleClick)
|
|
258
|
+
return () => window.removeEventListener('click', handleClick)
|
|
259
|
+
}, [showStructuredMenu])
|
|
260
|
+
|
|
261
|
+
const pickStructuredMode = useCallback((mode: StructuredMode) => {
|
|
262
|
+
setStructuredMode(mode)
|
|
263
|
+
try { localStorage.setItem('radar-logs-structured-mode', mode) } catch {}
|
|
264
|
+
setShowStructuredMenu(false)
|
|
265
|
+
}, [])
|
|
266
|
+
|
|
267
|
+
// Icon click cycles through the three modes; chevron exposes the explicit picker.
|
|
268
|
+
const cycleStructuredMode = useCallback(() => {
|
|
269
|
+
setStructuredMode(prev => {
|
|
270
|
+
const idx = STRUCTURED_MODE_ORDER.indexOf(prev)
|
|
271
|
+
const next = STRUCTURED_MODE_ORDER[(idx + 1) % STRUCTURED_MODE_ORDER.length]
|
|
272
|
+
try { localStorage.setItem('radar-logs-structured-mode', next) } catch {}
|
|
273
|
+
return next
|
|
274
|
+
})
|
|
275
|
+
}, [])
|
|
276
|
+
|
|
228
277
|
// Keyboard shortcut: Ctrl+F to open search
|
|
229
278
|
useEffect(() => {
|
|
230
279
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
@@ -429,18 +478,59 @@ export function LogCore({
|
|
|
429
478
|
|
|
430
479
|
<div className="flex-1" />
|
|
431
480
|
|
|
432
|
-
{/*
|
|
481
|
+
{/* Structured-log display mode: icon cycles compact→expanded→raw, chevron picks explicitly. */}
|
|
433
482
|
{hasStructuredEntries && (
|
|
434
|
-
<
|
|
435
|
-
<
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
483
|
+
<div className="flex items-center">
|
|
484
|
+
<Tooltip content={`Structured: ${STRUCTURED_MODE_LABELS[structuredMode]} — click to cycle`} delay={TIP_DELAY} position="bottom">
|
|
485
|
+
<button
|
|
486
|
+
onClick={cycleStructuredMode}
|
|
487
|
+
className={`p-1.5 rounded-l transition-colors ${
|
|
488
|
+
structuredMode === 'compact' ? iconBtnInactive : palette.toolbarActive
|
|
489
|
+
}`}
|
|
490
|
+
aria-label={`Structured log display mode: ${STRUCTURED_MODE_LABELS[structuredMode]}`}
|
|
491
|
+
>
|
|
492
|
+
<Braces className="w-4 h-4" />
|
|
493
|
+
</button>
|
|
494
|
+
</Tooltip>
|
|
495
|
+
<div className="relative" ref={structuredMenuRef}>
|
|
496
|
+
<Tooltip content="Pick structured display mode" delay={TIP_DELAY} position="bottom">
|
|
497
|
+
<button
|
|
498
|
+
onClick={() => setShowStructuredMenu(prev => !prev)}
|
|
499
|
+
className={`px-2 py-1.5 rounded-r text-[10px] font-medium transition-colors whitespace-nowrap ${
|
|
500
|
+
structuredMode === 'compact' ? iconBtnInactiveTertiary : palette.toolbarActive
|
|
501
|
+
}`}
|
|
502
|
+
aria-label="Pick structured log display mode"
|
|
503
|
+
>
|
|
504
|
+
<span className="inline-flex items-center gap-1">
|
|
505
|
+
<span>{STRUCTURED_MODE_LABELS[structuredMode]}</span>
|
|
506
|
+
<ChevronDown className="w-3 h-3" />
|
|
507
|
+
</span>
|
|
508
|
+
</button>
|
|
509
|
+
</Tooltip>
|
|
510
|
+
{showStructuredMenu && (
|
|
511
|
+
<div className={`absolute top-full right-0 mt-1 w-56 ${palette.menuBg} border ${palette.border} rounded-lg shadow-lg z-50`}>
|
|
512
|
+
<div className={`px-3 py-1.5 text-[10px] uppercase tracking-wide ${palette.textTertiary} border-b ${palette.border}`}>
|
|
513
|
+
Structured display
|
|
514
|
+
</div>
|
|
515
|
+
{STRUCTURED_MODE_ORDER.map(mode => (
|
|
516
|
+
<button
|
|
517
|
+
key={mode}
|
|
518
|
+
onClick={() => pickStructuredMode(mode)}
|
|
519
|
+
className={`w-full text-left px-3 py-1.5 text-xs ${palette.hoverBg} flex items-start justify-between gap-2 ${
|
|
520
|
+
structuredMode === mode ? palette.textPrimary : palette.textSecondary
|
|
521
|
+
}`}
|
|
522
|
+
>
|
|
523
|
+
<span className="flex flex-col">
|
|
524
|
+
<span>{STRUCTURED_MODE_LABELS[mode]}</span>
|
|
525
|
+
<span className={`text-[10px] ${palette.textTertiary}`}>{STRUCTURED_MODE_DESCRIPTIONS[mode]}</span>
|
|
526
|
+
</span>
|
|
527
|
+
{structuredMode === mode && <span className={`text-[10px] ${palette.textAccent} mt-0.5`}>✓</span>}
|
|
528
|
+
</button>
|
|
529
|
+
))}
|
|
530
|
+
</div>
|
|
531
|
+
)}
|
|
532
|
+
</div>
|
|
533
|
+
</div>
|
|
444
534
|
)}
|
|
445
535
|
|
|
446
536
|
{/* Timestamp toggle + format picker */}
|
|
@@ -731,7 +821,8 @@ export function LogCore({
|
|
|
731
821
|
ansiEnabled={ansiEnabled}
|
|
732
822
|
isCurrentMatch={group.head.id === currentHighlightId}
|
|
733
823
|
wordWrap={wordWrap}
|
|
734
|
-
defaultExpanded={
|
|
824
|
+
defaultExpanded={structuredMode === 'expanded'}
|
|
825
|
+
rawStructured={structuredMode === 'raw'}
|
|
735
826
|
onFilterValue={handleFilterValue}
|
|
736
827
|
isStackExpanded={expandedStacks.has(group.head.id)}
|
|
737
828
|
onToggleStack={toggleStackExpanded}
|
|
@@ -785,6 +876,8 @@ interface LogLineProps {
|
|
|
785
876
|
isCurrentMatch: boolean
|
|
786
877
|
wordWrap: boolean
|
|
787
878
|
defaultExpanded: boolean
|
|
879
|
+
/** When true, JSON/logfmt entries render as plain raw text instead of via StructuredLogLine. */
|
|
880
|
+
rawStructured: boolean
|
|
788
881
|
onFilterValue?: (value: string) => void
|
|
789
882
|
/** Optional lead element rendered at the start of the row (e.g. stack-trace toggle). */
|
|
790
883
|
leadSlot?: ReactNode
|
|
@@ -804,6 +897,7 @@ function LogLine({
|
|
|
804
897
|
isCurrentMatch,
|
|
805
898
|
wordWrap,
|
|
806
899
|
defaultExpanded,
|
|
900
|
+
rawStructured,
|
|
807
901
|
onFilterValue,
|
|
808
902
|
leadSlot,
|
|
809
903
|
isDark,
|
|
@@ -825,7 +919,7 @@ function LogLine({
|
|
|
825
919
|
dangerouslySetInnerHTML={{ __html: highlighted }}
|
|
826
920
|
/>
|
|
827
921
|
)
|
|
828
|
-
} else if (entry.isJson || entry.isLogfmt) {
|
|
922
|
+
} else if ((entry.isJson || entry.isLogfmt) && !rawStructured) {
|
|
829
923
|
contentElement = (
|
|
830
924
|
<StructuredLogLine
|
|
831
925
|
content={entry.content}
|
|
@@ -901,6 +995,7 @@ interface LogGroupItemProps {
|
|
|
901
995
|
isCurrentMatch: boolean
|
|
902
996
|
wordWrap: boolean
|
|
903
997
|
defaultExpanded: boolean
|
|
998
|
+
rawStructured: boolean
|
|
904
999
|
onFilterValue: (value: string) => void
|
|
905
1000
|
isStackExpanded: boolean
|
|
906
1001
|
onToggleStack: (id: number) => void
|