@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.
Files changed (51) hide show
  1. package/package.json +1 -1
  2. package/src/components/audit/AuditFindingsTable.tsx +66 -30
  3. package/src/components/cluster-switcher/ClusterSwitcher.tsx +65 -12
  4. package/src/components/dock/BottomDock.tsx +23 -12
  5. package/src/components/gitops/ManagedResourcesList.tsx +4 -2
  6. package/src/components/logs/LogCore.tsx +109 -14
  7. package/src/components/resources/ResourcesView.tsx +19 -2
  8. package/src/components/resources/get-pod-phase-display.test.ts +254 -0
  9. package/src/components/resources/renderers/AWSManagedMachinePoolRenderer.tsx +2 -4
  10. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +3 -2
  11. package/src/components/resources/renderers/AzureManagedMachinePoolRenderer.tsx +3 -8
  12. package/src/components/resources/renderers/ChallengeRenderer.tsx +2 -1
  13. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -1
  14. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +2 -1
  15. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +2 -1
  16. package/src/components/resources/renderers/GatewayClassRenderer.tsx +2 -1
  17. package/src/components/resources/renderers/GatewayRenderer.tsx +5 -4
  18. package/src/components/resources/renderers/IngressClassRenderer.tsx +2 -1
  19. package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +2 -1
  20. package/src/components/resources/renderers/IstioGatewayRenderer.tsx +3 -2
  21. package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +2 -1
  22. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +2 -2
  23. package/src/components/resources/renderers/KarpenterNodePoolRenderer.test.tsx +31 -0
  24. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +5 -5
  25. package/src/components/resources/renderers/OrderRenderer.tsx +2 -1
  26. package/src/components/resources/renderers/PodRenderer.tsx +23 -2
  27. package/src/components/resources/renderers/RoleBindingRenderer.tsx +3 -2
  28. package/src/components/resources/renderers/RolloutRenderer.tsx +2 -1
  29. package/src/components/resources/renderers/SecretRenderer.tsx +15 -6
  30. package/src/components/resources/renderers/aws-capi-cells.tsx +2 -4
  31. package/src/components/resources/renderers/azure-capi-cells.tsx +3 -8
  32. package/src/components/resources/renderers/istio-cells.tsx +3 -2
  33. package/src/components/resources/renderers/karpenter-cells.tsx +1 -1
  34. package/src/components/resources/resource-utils-karpenter.ts +0 -6
  35. package/src/components/resources/resource-utils.ts +110 -0
  36. package/src/components/resources/resources-search-sidebar-hint.test.ts +85 -0
  37. package/src/components/timeline/shared.tsx +5 -6
  38. package/src/components/topology/TopologySearch.tsx +49 -16
  39. package/src/components/topology/topology-search-match.test.ts +112 -0
  40. package/src/components/ui/Tooltip.tsx +73 -40
  41. package/src/components/ui/tooltip-position.test.ts +132 -0
  42. package/src/components/ui/tooltip-position.ts +86 -0
  43. package/src/components/workload/ResourceDetailDrawer.tsx +3 -3
  44. package/src/components/workload/WorkloadView.tsx +6 -6
  45. package/src/types/core.ts +13 -1
  46. package/src/utils/badge-colors.ts +19 -0
  47. package/src/utils/index.ts +2 -0
  48. package/src/utils/validators.test.ts +207 -0
  49. package/src/utils/validators.ts +178 -0
  50. package/src/utils/view-transition.test.ts +81 -0
  51. package/src/utils/view-transition.ts +62 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.5.10",
3
+ "version": "1.5.12",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -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 | null>(null)
52
- const [severityFilter, setSeverityFilter] = useState<string | null>(null)
53
- const [frameworkFilter, setFrameworkFilter] = useState<string | null>(null)
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 !== categoryFilter) return false
94
- if (severityFilter && f.severity !== severityFilter) return false
95
- if (frameworkFilter && checks) {
96
- const meta = checks[f.checkID]
97
- if (!meta?.frameworks?.includes(frameworkFilter)) return false
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 hasActiveFilters = !!(categoryFilter || severityFilter || frameworkFilter || searchTerm)
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={!categoryFilter && !severityFilter && !frameworkFilter} onClick={() => { setCategoryFilter(null); setSeverityFilter(null); setFrameworkFilter(null) }} />
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 === cat} onClick={() => setCategoryFilter(categoryFilter === cat ? null : cat)} />
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 === sev}
282
+ active={severityFilter.has(sev)}
259
283
  tone={sev === 'danger' ? 'danger' : 'warn'}
260
- onClick={() => setSeverityFilter(severityFilter === sev ? null : sev)}
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 === fw} onClick={() => setFrameworkFilter(frameworkFilter === fw ? null : fw)} />
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={() => { setCategoryFilter(null); setSeverityFilter(null); setFrameworkFilter(null); setSearchTerm('') }}
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
- <button
336
+ <div
337
+ role="button"
338
+ tabIndex={0}
339
+ aria-expanded={nsExpanded}
313
340
  onClick={() => toggleNS(ns)}
314
- className="group flex items-center gap-3 w-full px-4 py-2 rounded-lg hover:bg-theme-hover/30 transition-colors text-left"
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
- </button>
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
- <button
456
+ <div
457
+ role="button"
458
+ tabIndex={0}
459
+ aria-expanded={isExpanded}
426
460
  onClick={() => onToggle(key)}
427
- 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 focus-visible:ring-2 focus-visible:ring-theme-text-primary/20 focus-visible:outline-none"
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
- <span
438
- role="link"
439
- tabIndex={0}
475
+ <button
476
+ type="button"
440
477
  onClick={(e) => { e.stopPropagation(); onResourceClick(g.kind, g.namespace, g.name) }}
441
- onKeyDown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); onResourceClick(g.kind, g.namespace, g.name) } }}
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
- </span>
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
- </button>
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 function ClusterSwitcher({
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
- }: ClusterSwitcherProps) {
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
- <ClusterName
199
- name={currentName}
200
- fallbackBadge={<Server className="w-3.5 h-3.5 text-theme-text-secondary" />}
201
- className={TRIGGER_NAME_MAX_WIDTH}
202
- noTooltip={isOpen}
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 { useRegisterShortcut } from '../../hooks/useKeyboardShortcuts'
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
- useRegisterShortcut({
84
- id: 'dock-toggle',
85
- keys: 'Ctrl+`',
86
- description: 'Toggle dock',
87
- category: 'Dock',
88
- scope: 'global',
89
- handler: () => toggleExpanded(),
90
- enabled: tabs.length > 0,
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(100vh - ${MAXIMIZED_TOP_OFFSET}px)` : height
108
+ const effectiveHeight = !isExpanded ? 36 : isMaximized ? `calc(100% - ${MAXIMIZED_TOP_OFFSET}px)` : height
98
109
 
99
110
  return (
100
111
  <div
101
- className="fixed bottom-0 right-0 bg-theme-base border-t border-theme-border flex flex-col z-40 overflow-hidden"
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 && resource.health && (
136
- <HealthDot health={resource.health} />
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 [expandAllStructured, setExpandAllStructured] = useState(false)
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
- {/* Expand all structured logs toggle */}
481
+ {/* Structured-log display mode: icon cycles compact→expanded→raw, chevron picks explicitly. */}
433
482
  {hasStructuredEntries && (
434
- <Tooltip content={expandAllStructured ? 'Collapse all structured' : 'Expand all structured'} delay={TIP_DELAY} position="bottom">
435
- <button
436
- onClick={() => setExpandAllStructured(prev => !prev)}
437
- className={`p-1.5 rounded transition-colors ${
438
- expandAllStructured ? palette.toolbarActive : iconBtnInactive
439
- }`}
440
- >
441
- <Braces className="w-4 h-4" />
442
- </button>
443
- </Tooltip>
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={expandAllStructured}
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