@skyhook-io/k8s-ui 1.14.6 → 1.14.7
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/applications/ApplicationDetail.tsx +36 -4
- package/src/components/applications/ApplicationsView.tsx +2 -1
- package/src/components/audit/AuditAlerts.tsx +35 -37
- package/src/components/audit/AuditFindingsTable.tsx +23 -27
- package/src/components/charts/PrometheusChartsView.tsx +29 -10
- package/src/components/checks/ChecksView.tsx +56 -26
- package/src/components/gitops/GitOpsDetailLayout.tsx +12 -9
- package/src/components/issues/IssuesView.tsx +35 -54
- package/src/components/logs/LogCore.tsx +40 -9
- package/src/components/resources/ResourcesSidebar.tsx +66 -78
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +21 -15
- package/src/components/resources/renderers/CompositionRenderer.tsx +30 -10
- package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +16 -12
- package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +10 -4
- package/src/components/resources/renderers/GenericRenderer.tsx +13 -7
- package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +6 -4
- package/src/components/resources/renderers/PolicyCoverageSection.tsx +7 -4
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +13 -15
- package/src/components/resources/renderers/SbomReportRenderer.tsx +10 -4
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +10 -4
- package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +13 -6
- package/src/components/shared/CreateResourceDialog.tsx +7 -4
- package/src/components/timeline/TimelineList.tsx +14 -11
- package/src/components/timeline/TimelineStrip.tsx +26 -4
- package/src/components/timeline/TimelineSwimlanes.tsx +3 -5
- package/src/components/timeline/TimelineToolbar.tsx +43 -10
- package/src/components/topology/TopologyControls.tsx +27 -8
- package/src/components/topology/TopologyGraph.tsx +14 -5
- package/src/components/trace/ReachabilityView.tsx +17 -16
- package/src/components/ui/CardSection.tsx +79 -35
- package/src/components/ui/Collapse.test.tsx +74 -0
- package/src/components/ui/Collapse.tsx +115 -29
- package/src/components/ui/DialogPortal.tsx +7 -2
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +7 -4
- package/src/components/ui/RestrictedState.tsx +7 -4
- package/src/components/ui/RowActionMenu.tsx +20 -8
- package/src/components/ui/SelectMenu.tsx +18 -6
- package/src/components/ui/YamlEditor.tsx +7 -9
- package/src/components/ui/drawer-components.tsx +14 -21
- package/src/components/ui/index.ts +30 -22
- package/src/components/workload/WorkloadView.tsx +6 -10
- package/src/hooks/useAnimatedUnmount.ts +21 -3
- package/src/theme/components.css +4 -1
- package/src/utils/animation.ts +93 -13
|
@@ -26,6 +26,8 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react
|
|
|
26
26
|
import { clsx } from 'clsx'
|
|
27
27
|
import { Calendar, ChevronDown, ChevronLeft, ChevronRight, Minus, Plus } from 'lucide-react'
|
|
28
28
|
import { Tooltip } from '../ui/Tooltip'
|
|
29
|
+
import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
|
|
30
|
+
import { TRANSITION_MENU, overlayExitMs, overlayTransitionStyle } from '../../utils/animation'
|
|
29
31
|
import {
|
|
30
32
|
barHeight,
|
|
31
33
|
clampLensToSelection,
|
|
@@ -100,6 +102,7 @@ function DateTimeField({ label, valueMs, onChange, nowMs }: {
|
|
|
100
102
|
nowMs: number
|
|
101
103
|
}) {
|
|
102
104
|
const [open, setOpen] = useState(false)
|
|
105
|
+
const { shouldRender, isOpen } = useAnimatedUnmount(open, overlayExitMs('menu'))
|
|
103
106
|
const rootRef = useRef<HTMLDivElement>(null)
|
|
104
107
|
const value = new Date(valueMs)
|
|
105
108
|
const [viewY, setViewY] = useState(() => value.getFullYear())
|
|
@@ -145,9 +148,16 @@ function DateTimeField({ label, valueMs, onChange, nowMs }: {
|
|
|
145
148
|
{fieldStamp(valueMs)}
|
|
146
149
|
<Calendar className="h-3 w-3 shrink-0 text-theme-text-tertiary" />
|
|
147
150
|
</button>
|
|
148
|
-
{
|
|
151
|
+
{shouldRender && (
|
|
149
152
|
<div
|
|
150
|
-
|
|
153
|
+
inert={!open}
|
|
154
|
+
className={clsx(
|
|
155
|
+
'absolute left-0 top-full z-[60] mt-1 w-[232px] origin-top-left rounded-lg border border-theme-border bg-theme-surface p-2.5 shadow-theme-lg',
|
|
156
|
+
TRANSITION_MENU,
|
|
157
|
+
isOpen ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 -translate-y-1 scale-[0.97]',
|
|
158
|
+
!open && 'pointer-events-none',
|
|
159
|
+
)}
|
|
160
|
+
style={overlayTransitionStyle(isOpen, 'menu')}
|
|
151
161
|
role="dialog"
|
|
152
162
|
aria-label={`Pick ${label.toLowerCase()} date and time`}
|
|
153
163
|
>
|
|
@@ -294,6 +304,7 @@ export function TimelineStrip({
|
|
|
294
304
|
const trackRef = useRef<HTMLDivElement>(null)
|
|
295
305
|
const [width, setWidth] = useState(800)
|
|
296
306
|
const [pickerOpen, setPickerOpen] = useState(false)
|
|
307
|
+
const { shouldRender: pickerRender, isOpen: pickerShown } = useAnimatedUnmount(pickerOpen, overlayExitMs('menu'))
|
|
297
308
|
const [customFromMs, setCustomFromMs] = useState(0)
|
|
298
309
|
const [customToMs, setCustomToMs] = useState(0)
|
|
299
310
|
|
|
@@ -528,11 +539,22 @@ export function TimelineStrip({
|
|
|
528
539
|
<ChevronDown className="h-3 w-3 text-theme-text-tertiary" />
|
|
529
540
|
</button>
|
|
530
541
|
</Tooltip>
|
|
531
|
-
{
|
|
542
|
+
{pickerRender && (
|
|
532
543
|
// NO overflow-hidden: the DateTimeField calendars pop past the
|
|
533
544
|
// dialog's bottom edge and were getting clipped to a header sliver.
|
|
534
545
|
// The footer rounds its own bottom corners instead.
|
|
535
|
-
<div
|
|
546
|
+
<div
|
|
547
|
+
inert={!pickerOpen}
|
|
548
|
+
className={clsx(
|
|
549
|
+
'absolute left-0 top-full z-50 mt-2 w-[460px] max-w-[92vw] origin-top-left rounded-xl border border-theme-border bg-theme-surface shadow-theme-lg',
|
|
550
|
+
TRANSITION_MENU,
|
|
551
|
+
pickerShown ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 -translate-y-1 scale-[0.97]',
|
|
552
|
+
!pickerOpen && 'pointer-events-none',
|
|
553
|
+
)}
|
|
554
|
+
style={overlayTransitionStyle(pickerShown, 'menu')}
|
|
555
|
+
role="dialog"
|
|
556
|
+
aria-label="Time range picker"
|
|
557
|
+
>
|
|
536
558
|
<p className="border-b border-theme-border/60 px-4 py-2.5 text-[11px] leading-relaxed text-theme-text-secondary">
|
|
537
559
|
How much history to load. The blue band zooms into a slice of it.
|
|
538
560
|
</p>
|
|
@@ -43,6 +43,7 @@ import { isChangeEvent, isHistoricalEvent, isOperation, displayKind } from '../.
|
|
|
43
43
|
import { DiffViewer } from './DiffViewer'
|
|
44
44
|
import { getHealthBadgeColor, getEventTypeColor } from '../../utils/badge-colors'
|
|
45
45
|
import { MiddleEllipsis } from '../ui/MiddleEllipsis'
|
|
46
|
+
import { CollapseChevron } from '../ui/Collapse'
|
|
46
47
|
import { Tooltip } from '../ui/Tooltip'
|
|
47
48
|
import { ResourceRefBadge } from '../ui/drawer-components'
|
|
48
49
|
import { buildResourceHierarchy, extractPinnedLanes, removePinnedLanes, isProblematicEvent, laneTrackEvents, isChildVisibleInWindow, collidingLaneKeys, laneCollisionKey, type ResourceLane as BaseResourceLane, type TimelineGrouping, type PinnedLaneRef } from '../../utils/resource-hierarchy'
|
|
@@ -1644,10 +1645,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
1644
1645
|
aria-label={isExpanded ? 'Collapse' : 'Expand'}
|
|
1645
1646
|
className="p-1 -m-0.5 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
|
|
1646
1647
|
>
|
|
1647
|
-
<
|
|
1648
|
-
'w-4 h-4 transition-transform',
|
|
1649
|
-
isExpanded && 'rotate-90'
|
|
1650
|
-
)} />
|
|
1648
|
+
<CollapseChevron open={isExpanded} inheritColor className="w-4 h-4" />
|
|
1651
1649
|
</button>
|
|
1652
1650
|
) : compact ? null : (
|
|
1653
1651
|
<div className="w-4" />
|
|
@@ -2285,7 +2283,7 @@ function ChildLaneLabel({ kind, group, showGroupChip, kindTitle, name, labelWidt
|
|
|
2285
2283
|
style={{ left: selfRailPx }}
|
|
2286
2284
|
aria-label={expanded ? 'Collapse' : 'Expand'}
|
|
2287
2285
|
>
|
|
2288
|
-
<
|
|
2286
|
+
<CollapseChevron open={!!expanded} inheritColor className="w-4 h-4" />
|
|
2289
2287
|
</button>
|
|
2290
2288
|
)}
|
|
2291
2289
|
{/* Single-line child row: kind chip · name (middle-ellipsis). Colored chips
|
|
@@ -18,6 +18,8 @@ import {
|
|
|
18
18
|
import { SearchBox } from '../ui/SearchBox'
|
|
19
19
|
import { type ShortcutScope } from '../../hooks/useKeyboardShortcuts'
|
|
20
20
|
import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
|
|
21
|
+
import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
|
|
22
|
+
import { TRANSITION_MENU, overlayExitMs, overlayTransitionStyle } from '../../utils/animation'
|
|
21
23
|
import { pluralize } from '../../utils/pluralize'
|
|
22
24
|
import type { TimelineEvent, TimeRange } from '../../types'
|
|
23
25
|
import type { TimelineGrouping } from '../../utils/resource-hierarchy'
|
|
@@ -475,6 +477,7 @@ function SegmentedRadioGroup<T extends string>({
|
|
|
475
477
|
*/
|
|
476
478
|
export function ViewMenu({ viewOptions }: { viewOptions: TimelineViewOptions }) {
|
|
477
479
|
const [open, setOpen] = useState(false)
|
|
480
|
+
const { shouldRender, isOpen } = useAnimatedUnmount(open, overlayExitMs('menu'))
|
|
478
481
|
const rootRef = useRef<HTMLDivElement | null>(null)
|
|
479
482
|
usePopoverDismiss(open, setOpen, rootRef)
|
|
480
483
|
|
|
@@ -497,11 +500,18 @@ export function ViewMenu({ viewOptions }: { viewOptions: TimelineViewOptions })
|
|
|
497
500
|
<ChevronDown className="w-3.5 h-3.5 text-theme-text-tertiary" />
|
|
498
501
|
</button>
|
|
499
502
|
|
|
500
|
-
{
|
|
503
|
+
{shouldRender && (
|
|
501
504
|
<div
|
|
502
505
|
role="menu"
|
|
503
506
|
aria-label="View options"
|
|
504
|
-
|
|
507
|
+
inert={!open}
|
|
508
|
+
className={clsx(
|
|
509
|
+
'absolute right-0 top-full z-50 mt-1 min-w-[14rem] origin-top-right rounded-lg border border-theme-border bg-theme-elevated p-2 shadow-theme-lg',
|
|
510
|
+
TRANSITION_MENU,
|
|
511
|
+
isOpen ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 -translate-y-1 scale-[0.97]',
|
|
512
|
+
!open && 'pointer-events-none',
|
|
513
|
+
)}
|
|
514
|
+
style={overlayTransitionStyle(isOpen, 'menu')}
|
|
505
515
|
>
|
|
506
516
|
<span className="block px-2 pb-1 text-[10px] font-bold uppercase tracking-[0.08em] text-theme-text-tertiary">Sort</span>
|
|
507
517
|
<SegmentedRadioGroup
|
|
@@ -634,10 +644,11 @@ function usePopoverDismiss(
|
|
|
634
644
|
*/
|
|
635
645
|
function KindsMenu({ kindFilter, onKindFilterChange, kindOptions }: KindsMenuProps) {
|
|
636
646
|
const [open, setOpen] = useState(false)
|
|
647
|
+
const { shouldRender, isOpen } = useAnimatedUnmount(open, overlayExitMs('menu'))
|
|
637
648
|
const [search, setSearch] = useState('')
|
|
638
649
|
const rootRef = useRef<HTMLDivElement | null>(null)
|
|
639
650
|
const panelRef = useRef<HTMLDivElement | null>(null)
|
|
640
|
-
const [anchor, setAnchor] = useState<{ left: number; top: number; maxHeight: number } | null>(null)
|
|
651
|
+
const [anchor, setAnchor] = useState<{ left: number; top: number; maxHeight: number; flipUp: boolean } | null>(null)
|
|
641
652
|
const activeCount = kindFilter.length
|
|
642
653
|
const selected = useMemo(() => new Set(kindFilter), [kindFilter])
|
|
643
654
|
|
|
@@ -645,9 +656,11 @@ function KindsMenu({ kindFilter, onKindFilterChange, kindOptions }: KindsMenuPro
|
|
|
645
656
|
// clips absolutely-positioned children. Portal the popover to the body and pin
|
|
646
657
|
// it to the button with fixed coords so it escapes the clip. Dismiss must treat
|
|
647
658
|
// BOTH the chip and the portaled panel as "inside".
|
|
659
|
+
// The anchor is kept through the exit transition (clearing it at logical
|
|
660
|
+
// close would unmount the panel mid-fade) and re-placed on the next open.
|
|
648
661
|
const PANEL_WIDTH = 256
|
|
649
662
|
useLayoutEffect(() => {
|
|
650
|
-
if (!open)
|
|
663
|
+
if (!open) return
|
|
651
664
|
const place = () => {
|
|
652
665
|
const r = rootRef.current?.getBoundingClientRect()
|
|
653
666
|
if (!r) return
|
|
@@ -666,11 +679,13 @@ function KindsMenu({ kindFilter, onKindFilterChange, kindOptions }: KindsMenuPro
|
|
|
666
679
|
const top = flipUp
|
|
667
680
|
? Math.max(4, r.top - 4 - Math.min(panelH, maxHeight))
|
|
668
681
|
: r.bottom + 4
|
|
669
|
-
setAnchor({ left, top, maxHeight })
|
|
682
|
+
setAnchor({ left, top, maxHeight, flipUp })
|
|
670
683
|
}
|
|
671
684
|
place()
|
|
672
685
|
// Re-place after the panel mounts so the first open can measure its height and
|
|
673
|
-
// flip; the initial pass runs with panelH=0 (panel not yet in the DOM).
|
|
686
|
+
// flip; the initial pass runs with panelH=0 (panel not yet in the DOM). The
|
|
687
|
+
// presence hook mounts the panel a tick after `open`, hence `shouldRender` in
|
|
688
|
+
// the deps: the pass that runs once it is in the DOM does the real measurement.
|
|
674
689
|
const raf = requestAnimationFrame(place)
|
|
675
690
|
window.addEventListener('scroll', place, true)
|
|
676
691
|
window.addEventListener('resize', place)
|
|
@@ -679,7 +694,7 @@ function KindsMenu({ kindFilter, onKindFilterChange, kindOptions }: KindsMenuPro
|
|
|
679
694
|
window.removeEventListener('scroll', place, true)
|
|
680
695
|
window.removeEventListener('resize', place)
|
|
681
696
|
}
|
|
682
|
-
}, [open])
|
|
697
|
+
}, [open, shouldRender])
|
|
683
698
|
|
|
684
699
|
useEffect(() => {
|
|
685
700
|
if (!open) return
|
|
@@ -731,11 +746,29 @@ function KindsMenu({ kindFilter, onKindFilterChange, kindOptions }: KindsMenuPro
|
|
|
731
746
|
<ChevronDown className="w-3.5 h-3.5 text-theme-text-tertiary" />
|
|
732
747
|
</button>
|
|
733
748
|
|
|
734
|
-
{
|
|
749
|
+
{shouldRender && anchor && createPortal(
|
|
735
750
|
<div
|
|
736
751
|
ref={panelRef}
|
|
737
|
-
|
|
738
|
-
|
|
752
|
+
inert={!open}
|
|
753
|
+
style={{
|
|
754
|
+
position: 'fixed',
|
|
755
|
+
left: anchor.left,
|
|
756
|
+
top: anchor.top,
|
|
757
|
+
width: PANEL_WIDTH,
|
|
758
|
+
maxHeight: anchor.maxHeight,
|
|
759
|
+
...overlayTransitionStyle(isOpen, 'menu'),
|
|
760
|
+
}}
|
|
761
|
+
className={clsx(
|
|
762
|
+
'z-50 overflow-y-auto rounded-md border border-theme-border bg-theme-surface shadow-theme-lg',
|
|
763
|
+
anchor.flipUp ? 'origin-bottom-left' : 'origin-top-left',
|
|
764
|
+
TRANSITION_MENU,
|
|
765
|
+
isOpen
|
|
766
|
+
? 'opacity-100 translate-y-0 scale-100'
|
|
767
|
+
: anchor.flipUp
|
|
768
|
+
? 'opacity-0 translate-y-1 scale-[0.97]'
|
|
769
|
+
: 'opacity-0 -translate-y-1 scale-[0.97]',
|
|
770
|
+
!open && 'pointer-events-none',
|
|
771
|
+
)}
|
|
739
772
|
>
|
|
740
773
|
<MultiSelectPicker
|
|
741
774
|
items={kindOptions}
|
|
@@ -3,6 +3,8 @@ import { FolderTree, ShieldCheck, ChevronDown, Check } from 'lucide-react'
|
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
4
|
import type { TopologyMode, GroupingMode } from '../../types/core'
|
|
5
5
|
import { Tooltip } from '../ui/Tooltip'
|
|
6
|
+
import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
|
|
7
|
+
import { TRANSITION_MENU, overlayExitMs, overlayTransitionStyle } from '../../utils/animation'
|
|
6
8
|
|
|
7
9
|
interface TopologyControlsProps {
|
|
8
10
|
viewMode: TopologyMode
|
|
@@ -37,6 +39,7 @@ export function TopologyControls({
|
|
|
37
39
|
leadingSlot,
|
|
38
40
|
}: TopologyControlsProps) {
|
|
39
41
|
const [groupOpen, setGroupOpen] = useState(false)
|
|
42
|
+
const { shouldRender: groupRender, isOpen: groupShown } = useAnimatedUnmount(groupOpen, overlayExitMs('menu'))
|
|
40
43
|
const groupRef = useRef<HTMLDivElement>(null)
|
|
41
44
|
const groupTriggerRef = useRef<HTMLButtonElement>(null)
|
|
42
45
|
const groupItemRefs = useRef<(HTMLButtonElement | null)[]>([])
|
|
@@ -53,20 +56,25 @@ export function TopologyControls({
|
|
|
53
56
|
if (restoreFocus) groupTriggerRef.current?.focus()
|
|
54
57
|
}, [])
|
|
55
58
|
|
|
56
|
-
// On open, move focus onto the active option so the menu is keyboard-navigable
|
|
57
|
-
//
|
|
59
|
+
// On open, move focus onto the active option so the menu is keyboard-navigable.
|
|
60
|
+
// The items exist only once the presence hook has mounted the menu, so this
|
|
61
|
+
// waits for `groupRender`.
|
|
58
62
|
useEffect(() => {
|
|
59
|
-
if (!groupOpen) return
|
|
63
|
+
if (!groupOpen || !groupRender) return
|
|
60
64
|
const active = Math.max(0, groupOptions.findIndex((o) => o.value === groupingMode))
|
|
61
65
|
groupItemRefs.current[active]?.focus()
|
|
66
|
+
// groupOptions/groupingMode are read once at open; re-running on their
|
|
67
|
+
// identity change would steal focus mid-interaction.
|
|
68
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
69
|
+
}, [groupOpen, groupRender])
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (!groupOpen) return
|
|
62
73
|
const onDown = (e: MouseEvent) => {
|
|
63
74
|
if (groupRef.current && !groupRef.current.contains(e.target as Node)) setGroupOpen(false)
|
|
64
75
|
}
|
|
65
76
|
document.addEventListener('mousedown', onDown)
|
|
66
77
|
return () => document.removeEventListener('mousedown', onDown)
|
|
67
|
-
// groupOptions/groupingMode are read once at open; re-running on their
|
|
68
|
-
// identity change would steal focus mid-interaction.
|
|
69
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
70
78
|
}, [groupOpen])
|
|
71
79
|
|
|
72
80
|
const onGroupMenuKey = (e: KeyboardEvent<HTMLDivElement>) => {
|
|
@@ -123,8 +131,19 @@ export function TopologyControls({
|
|
|
123
131
|
{currentGroupLabel}
|
|
124
132
|
<ChevronDown className="w-3 h-3 text-theme-text-tertiary" />
|
|
125
133
|
</button>
|
|
126
|
-
{
|
|
127
|
-
<div
|
|
134
|
+
{groupRender && (
|
|
135
|
+
<div
|
|
136
|
+
role="menu"
|
|
137
|
+
onKeyDown={onGroupMenuKey}
|
|
138
|
+
inert={!groupOpen}
|
|
139
|
+
className={clsx(
|
|
140
|
+
'absolute right-0 top-full mt-1 z-50 min-w-[160px] origin-top-right rounded-lg border border-theme-border bg-theme-surface py-1 shadow-xl',
|
|
141
|
+
TRANSITION_MENU,
|
|
142
|
+
groupShown ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 -translate-y-1 scale-[0.97]',
|
|
143
|
+
!groupOpen && 'pointer-events-none',
|
|
144
|
+
)}
|
|
145
|
+
style={overlayTransitionStyle(groupShown, 'menu')}
|
|
146
|
+
>
|
|
128
147
|
{groupOptions.map((o, idx) => (
|
|
129
148
|
<button
|
|
130
149
|
key={o.value}
|
|
@@ -23,6 +23,7 @@ import { toCanvas } from 'html-to-image'
|
|
|
23
23
|
|
|
24
24
|
import { AlertTriangle, ChevronsDownUp, ChevronsUpDown, Download, Info, Layers, LayoutGrid, Loader2, Maximize, Minus, Pause, Play, Plus, RotateCw, Shield } from 'lucide-react'
|
|
25
25
|
import { PaneLoader } from '../ui/PaneLoader'
|
|
26
|
+
import { Collapse, CollapseChevron, useDisclosure } from '../ui/Collapse'
|
|
26
27
|
import { TopologyOverlayBar } from './TopologyOverlayBar'
|
|
27
28
|
import { Input } from '../ui/Input'
|
|
28
29
|
import { Tooltip } from '../ui/Tooltip'
|
|
@@ -315,6 +316,8 @@ export function TopologyGraph({
|
|
|
315
316
|
const [fitViewCounter, setFitViewCounter] = useState(0)
|
|
316
317
|
const [isExporting, setIsExporting] = useState(false)
|
|
317
318
|
const [showLegend, setShowLegend] = useState(false)
|
|
319
|
+
const [warningsOpen, setWarningsOpen] = useState(false)
|
|
320
|
+
const warningsDisclosure = useDisclosure(warningsOpen)
|
|
318
321
|
const prevStructureRef = useRef<string>('')
|
|
319
322
|
const layoutVersionRef = useRef(0) // Used to invalidate stale layout results
|
|
320
323
|
// Saved node positions for preservation across topology updates.
|
|
@@ -1064,10 +1067,16 @@ export function TopologyGraph({
|
|
|
1064
1067
|
? 'Large retained run history is summarized to keep the graph readable.'
|
|
1065
1068
|
: 'Some resources failed to load. Data may be incomplete.'}
|
|
1066
1069
|
</span>
|
|
1067
|
-
<
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1070
|
+
<button
|
|
1071
|
+
{...warningsDisclosure.buttonProps}
|
|
1072
|
+
type="button"
|
|
1073
|
+
onClick={() => setWarningsOpen((v) => !v)}
|
|
1074
|
+
className="mt-1 flex items-center gap-1 text-xs text-amber-400/80 hover:text-amber-400"
|
|
1075
|
+
>
|
|
1076
|
+
<CollapseChevron open={warningsOpen} inheritColor className="h-3 w-3" />
|
|
1077
|
+
Show details ({topology.warnings.length})
|
|
1078
|
+
</button>
|
|
1079
|
+
<Collapse open={warningsOpen} id={warningsDisclosure.panelId}>
|
|
1071
1080
|
<ul className="mt-1 text-xs text-theme-text-tertiary space-y-0.5">
|
|
1072
1081
|
{rbacWarnings.length > 0 && otherWarnings.length > 0 && (
|
|
1073
1082
|
<li className="text-amber-400/60 font-medium mt-1">RBAC restrictions:</li>
|
|
@@ -1088,7 +1097,7 @@ export function TopologyGraph({
|
|
|
1088
1097
|
<li key={`other-${i}`} className="font-mono">{w}</li>
|
|
1089
1098
|
))}
|
|
1090
1099
|
</ul>
|
|
1091
|
-
</
|
|
1100
|
+
</Collapse>
|
|
1092
1101
|
</div>
|
|
1093
1102
|
</div>
|
|
1094
1103
|
</div>
|
|
@@ -4,7 +4,8 @@ import { ReachActions, JustTestedNote, CopyableCommand, completedRequestMode, ty
|
|
|
4
4
|
import { AlertBanner } from '../ui/drawer-components'
|
|
5
5
|
import { PaneLoader } from '../ui/PaneLoader'
|
|
6
6
|
import { ReachabilityGraph, MarkGlyph } from './ReachabilityGraph'
|
|
7
|
-
import { Activity
|
|
7
|
+
import { Activity } from 'lucide-react'
|
|
8
|
+
import { Collapse, CollapseChevron, useDisclosure } from '../ui/Collapse'
|
|
8
9
|
import { Tooltip } from '../ui/Tooltip'
|
|
9
10
|
import { buildGraph, noteHeadline } from './reachGraphModel'
|
|
10
11
|
import { buildOrigins, defaultOrigin, probeCheckStats, type Origin, type OriginId } from './reachOrigins'
|
|
@@ -778,10 +779,10 @@ function untestableSummary(out: Origin[]): string {
|
|
|
778
779
|
// ---------------------------------------------------------------- inspector
|
|
779
780
|
|
|
780
781
|
/**
|
|
781
|
-
* The panel's one disclosure,
|
|
782
|
-
*
|
|
783
|
-
*
|
|
784
|
-
*
|
|
782
|
+
* The panel's one disclosure, on the shared Collapse primitive like
|
|
783
|
+
* drawer-components' Section. Hand-rolled "›"/"⌄" toggles neither looked like
|
|
784
|
+
* the rest of Radar nor animated, so a section appearing was an abrupt jump
|
|
785
|
+
* with no affordance saying it could be opened at all.
|
|
785
786
|
*/
|
|
786
787
|
function Disclosure({
|
|
787
788
|
open,
|
|
@@ -796,21 +797,22 @@ function Disclosure({
|
|
|
796
797
|
count?: number
|
|
797
798
|
children: React.ReactNode
|
|
798
799
|
}) {
|
|
800
|
+
const { panelId, buttonProps } = useDisclosure(open)
|
|
799
801
|
return (
|
|
800
802
|
<div>
|
|
801
803
|
<button
|
|
804
|
+
{...buttonProps}
|
|
802
805
|
type="button"
|
|
803
806
|
onClick={onToggle}
|
|
804
|
-
aria-expanded={open}
|
|
805
807
|
className="flex w-full items-center gap-1.5 text-[9.5px] font-bold tracking-[0.07em] text-theme-text-tertiary transition-colors hover:text-theme-text-secondary"
|
|
806
808
|
>
|
|
807
|
-
<
|
|
809
|
+
<CollapseChevron open={open} className="h-3 w-3 flex-none" />
|
|
808
810
|
<span>{label}</span>
|
|
809
811
|
{count !== undefined && <span className="flex-1 text-right font-normal tracking-normal">{count}</span>}
|
|
810
812
|
</button>
|
|
811
|
-
<
|
|
812
|
-
|
|
813
|
-
</
|
|
813
|
+
<Collapse open={open} id={panelId}>
|
|
814
|
+
{children}
|
|
815
|
+
</Collapse>
|
|
814
816
|
</div>
|
|
815
817
|
)
|
|
816
818
|
}
|
|
@@ -981,6 +983,7 @@ function HopSection({
|
|
|
981
983
|
}) {
|
|
982
984
|
const [openOverride, setOpenOverride] = useState<boolean | null>(null)
|
|
983
985
|
const open = openOverride ?? hop.expanded
|
|
986
|
+
const { panelId, buttonProps } = useDisclosure(open)
|
|
984
987
|
const ref = useRef<HTMLDivElement>(null)
|
|
985
988
|
// A click on the graph scrolls its hop into view instead of replacing the
|
|
986
989
|
// panel - the gesture still means "I care about this one", it just no longer
|
|
@@ -1012,15 +1015,15 @@ function HopSection({
|
|
|
1012
1015
|
className={`mt-2 rounded-md px-2 py-1 transition-colors duration-200 ${selected ? 'selection selection-ring' : ''}`}
|
|
1013
1016
|
>
|
|
1014
1017
|
<button
|
|
1018
|
+
{...buttonProps}
|
|
1015
1019
|
type="button"
|
|
1016
1020
|
onClick={() => {
|
|
1017
1021
|
setOpenOverride(!open)
|
|
1018
1022
|
onSelect(hop.id)
|
|
1019
1023
|
}}
|
|
1020
|
-
aria-expanded={open}
|
|
1021
1024
|
className="flex w-full items-center gap-1.5 text-left"
|
|
1022
1025
|
>
|
|
1023
|
-
<
|
|
1026
|
+
<CollapseChevron open={open} className="h-3 w-3 flex-none" />
|
|
1024
1027
|
<span className="text-[9px] font-bold tracking-[0.04em] text-theme-text-tertiary">{hop.kind}</span>
|
|
1025
1028
|
<span className="min-w-0 flex-1 truncate font-mono text-[11.5px] font-semibold text-theme-text-primary">{hop.name}</span>
|
|
1026
1029
|
<span className={`badge-sm whitespace-nowrap ${SEV_BADGE[hop.chipTone]}`}>{hop.chipText || HOP_STATE_NOTE[hop.state]}</span>
|
|
@@ -1032,8 +1035,7 @@ function HopSection({
|
|
|
1032
1035
|
.join(' — ')}
|
|
1033
1036
|
</div>
|
|
1034
1037
|
)}
|
|
1035
|
-
<
|
|
1036
|
-
<div className="overflow-hidden">
|
|
1038
|
+
<Collapse open={open} id={panelId}>
|
|
1037
1039
|
<div className="pb-1 pl-[18px]">
|
|
1038
1040
|
{hop.openRef?.name && (
|
|
1039
1041
|
<button type="button" onClick={() => onOpen(hop.openRef!)} className="text-[11px] text-accent-text hover:underline">
|
|
@@ -1084,8 +1086,7 @@ function HopSection({
|
|
|
1084
1086
|
<Caveats items={hop.notProve} />
|
|
1085
1087
|
</div>
|
|
1086
1088
|
</div>
|
|
1087
|
-
|
|
1088
|
-
</div>
|
|
1089
|
+
</Collapse>
|
|
1089
1090
|
</div>
|
|
1090
1091
|
)
|
|
1091
1092
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Fragment, type ComponentType, type ReactNode } from
|
|
1
|
+
import { Fragment, type ComponentType, type ReactNode } from "react";
|
|
2
2
|
|
|
3
3
|
// =============================================================================
|
|
4
4
|
// CARD SECTION PRIMITIVES — the shared "Sectioned + icons" vocabulary used by
|
|
@@ -12,106 +12,150 @@ import { Fragment, type ComponentType, type ReactNode } from 'react'
|
|
|
12
12
|
|
|
13
13
|
// Section role → eyebrow + icon hue. Body text always uses the secondary token
|
|
14
14
|
// (the contrast floor), regardless of tone.
|
|
15
|
-
export type CardSectionTone =
|
|
15
|
+
export type CardSectionTone = "neutral" | "warn" | "fix";
|
|
16
16
|
|
|
17
17
|
const SECTION_TONE_CLASS: Record<CardSectionTone, string> = {
|
|
18
18
|
// Eyebrow grey — the default, for neutral sections (Why it matters, Raw error,
|
|
19
19
|
// Affected resources, Context, Subject).
|
|
20
|
-
neutral:
|
|
20
|
+
neutral: "text-theme-text-tertiary",
|
|
21
21
|
// What's wrong — amber, the diagnosis beat.
|
|
22
|
-
warn:
|
|
22
|
+
warn: "text-amber-700 dark:text-amber-300",
|
|
23
23
|
// Next step / How to fix — emerald, the action beat.
|
|
24
|
-
fix:
|
|
25
|
-
}
|
|
24
|
+
fix: "text-emerald-700 dark:text-emerald-400",
|
|
25
|
+
};
|
|
26
26
|
|
|
27
27
|
// The single eyebrow style: 11px / 600 / uppercase, with an icon in a fixed
|
|
28
28
|
// gutter so every section's body aligns under the same left edge.
|
|
29
29
|
export function CardSection({
|
|
30
30
|
icon: Icon,
|
|
31
31
|
label,
|
|
32
|
-
tone =
|
|
32
|
+
tone = "neutral",
|
|
33
33
|
labelExtra,
|
|
34
34
|
children,
|
|
35
35
|
}: {
|
|
36
|
-
icon: ComponentType<{ className?: string }
|
|
37
|
-
label: string
|
|
38
|
-
tone?: CardSectionTone
|
|
36
|
+
icon: ComponentType<{ className?: string }>;
|
|
37
|
+
label: string;
|
|
38
|
+
tone?: CardSectionTone;
|
|
39
39
|
/** Optional trailing eyebrow text (e.g. "· retried 5×"), rendered muted. */
|
|
40
|
-
labelExtra?: ReactNode
|
|
41
|
-
children: ReactNode
|
|
40
|
+
labelExtra?: ReactNode;
|
|
41
|
+
children: ReactNode;
|
|
42
42
|
}) {
|
|
43
|
-
const toneClass = SECTION_TONE_CLASS[tone]
|
|
43
|
+
const toneClass = SECTION_TONE_CLASS[tone];
|
|
44
44
|
return (
|
|
45
45
|
<section className="flex items-start gap-2.5">
|
|
46
46
|
{/* Icon sits in a fixed gutter, anchored to the eyebrow line (top-aligned
|
|
47
47
|
with a nudge to its optical center) — sections like Affected resources
|
|
48
48
|
can be dozens of rows tall, and a section-centered glyph would float
|
|
49
49
|
detached mid-list. */}
|
|
50
|
-
<Icon
|
|
50
|
+
<Icon
|
|
51
|
+
className={`mt-[-1px] h-[18px] w-[18px] shrink-0 ${toneClass}`}
|
|
52
|
+
aria-hidden
|
|
53
|
+
/>
|
|
51
54
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
52
|
-
<h4
|
|
55
|
+
<h4
|
|
56
|
+
className={`text-[11px] font-semibold uppercase tracking-[0.06em] ${toneClass}`}
|
|
57
|
+
>
|
|
53
58
|
{label}
|
|
54
|
-
{labelExtra ?
|
|
59
|
+
{labelExtra ? (
|
|
60
|
+
<span className="font-medium normal-case tracking-normal text-theme-text-tertiary">
|
|
61
|
+
{" "}
|
|
62
|
+
{labelExtra}
|
|
63
|
+
</span>
|
|
64
|
+
) : null}
|
|
55
65
|
</h4>
|
|
56
66
|
{children}
|
|
57
67
|
</div>
|
|
58
68
|
</section>
|
|
59
|
-
)
|
|
69
|
+
);
|
|
60
70
|
}
|
|
61
71
|
|
|
62
72
|
// Body paragraph at the contrast floor — 14px, secondary token, never lighter.
|
|
63
|
-
export function CardBody({
|
|
64
|
-
|
|
73
|
+
export function CardBody({
|
|
74
|
+
children,
|
|
75
|
+
className,
|
|
76
|
+
}: {
|
|
77
|
+
children: ReactNode;
|
|
78
|
+
className?: string;
|
|
79
|
+
}) {
|
|
80
|
+
return (
|
|
81
|
+
<p
|
|
82
|
+
className={`text-sm leading-relaxed text-theme-text-secondary ${className ?? ""}`}
|
|
83
|
+
>
|
|
84
|
+
{children}
|
|
85
|
+
</p>
|
|
86
|
+
);
|
|
65
87
|
}
|
|
66
88
|
|
|
67
89
|
// The dark terminal block for raw error output. Intentionally dark in BOTH
|
|
68
90
|
// themes (it's a console surface, not chrome), so it draws from the dedicated
|
|
69
91
|
// --terminal-* tokens rather than the theme surface tokens.
|
|
70
|
-
export function
|
|
92
|
+
export function TerminalBlockLabel({
|
|
93
|
+
children,
|
|
94
|
+
divider,
|
|
95
|
+
}: {
|
|
96
|
+
children: ReactNode;
|
|
97
|
+
divider?: boolean;
|
|
98
|
+
}) {
|
|
99
|
+
return (
|
|
100
|
+
<div
|
|
101
|
+
className={`${divider ? "border-t" : "border-b"} border-[var(--terminal-divider)] px-3 py-1.5 text-[10px] font-semibold uppercase tracking-[0.06em] text-[var(--terminal-label)]`}
|
|
102
|
+
>
|
|
103
|
+
{children}
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function TerminalBlock({
|
|
109
|
+
label,
|
|
110
|
+
children,
|
|
111
|
+
footer,
|
|
112
|
+
}: {
|
|
113
|
+
label?: ReactNode;
|
|
114
|
+
children: ReactNode;
|
|
115
|
+
/** Rendered under the output, inside the same block: a folded tail, a divider. */
|
|
116
|
+
footer?: ReactNode;
|
|
117
|
+
}) {
|
|
71
118
|
return (
|
|
72
119
|
<div className="overflow-hidden rounded-lg border border-[var(--terminal-border)] bg-[var(--terminal-bg)]">
|
|
73
|
-
{label ?
|
|
74
|
-
<div className="border-b border-[var(--terminal-divider)] px-3 py-1.5 text-[10px] font-semibold uppercase tracking-[0.06em] text-[var(--terminal-label)]">
|
|
75
|
-
{label}
|
|
76
|
-
</div>
|
|
77
|
-
) : null}
|
|
120
|
+
{label ? <TerminalBlockLabel>{label}</TerminalBlockLabel> : null}
|
|
78
121
|
<pre className="overflow-x-auto whitespace-pre-wrap break-words px-3 py-2.5 font-mono text-xs leading-relaxed text-[var(--terminal-text)]">
|
|
79
122
|
{children}
|
|
80
123
|
</pre>
|
|
124
|
+
{footer}
|
|
81
125
|
</div>
|
|
82
|
-
)
|
|
126
|
+
);
|
|
83
127
|
}
|
|
84
128
|
|
|
85
129
|
// The neutral category chip — drops the per-category hue so severity is the only
|
|
86
130
|
// status color. Self-contained (don't pair with badge-sm): 11px / 600, 2px×8px
|
|
87
131
|
// padding, no border, neutral grey.
|
|
88
132
|
export const NEUTRAL_CHIP_CLASS =
|
|
89
|
-
|
|
133
|
+
"inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-semibold leading-tight bg-theme-elevated text-theme-text-secondary";
|
|
90
134
|
|
|
91
135
|
// Mono kind chip — the grey tag for an all-caps resource Kind (the Issue row
|
|
92
136
|
// header + the Subject footer row). Mono and tighter than NEUTRAL_CHIP_CLASS,
|
|
93
137
|
// which carries the label/category chips.
|
|
94
138
|
export const KIND_CHIP_CLASS =
|
|
95
|
-
|
|
139
|
+
"shrink-0 rounded bg-theme-elevated px-1.5 py-px font-mono text-[11px] uppercase text-theme-text-secondary";
|
|
96
140
|
|
|
97
141
|
// Render a prose string, turning markdown `inline-code` spans into mono chips
|
|
98
142
|
// via the shared .inline-code class. Plain-text passthrough when the string
|
|
99
143
|
// carries no backticks, so it's a no-op for today's plain catalog copy and
|
|
100
144
|
// lights up automatically once backtick markup is added.
|
|
101
145
|
export function renderProse(text: string | undefined | null): ReactNode {
|
|
102
|
-
if (!text) return null
|
|
103
|
-
if (!text.includes(
|
|
104
|
-
const parts = text.split(/(`[^`]+`)/g)
|
|
146
|
+
if (!text) return null;
|
|
147
|
+
if (!text.includes("`")) return text;
|
|
148
|
+
const parts = text.split(/(`[^`]+`)/g);
|
|
105
149
|
return parts.map((part, i) => {
|
|
106
150
|
// length > 2 excludes a bare "``" (which the split leaves in a plain part)
|
|
107
151
|
// so it renders as literal text, not an empty chip.
|
|
108
|
-
if (part.length > 2 && part.startsWith(
|
|
152
|
+
if (part.length > 2 && part.startsWith("`") && part.endsWith("`")) {
|
|
109
153
|
return (
|
|
110
154
|
<code key={i} className="inline-code">
|
|
111
155
|
{part.slice(1, -1)}
|
|
112
156
|
</code>
|
|
113
|
-
)
|
|
157
|
+
);
|
|
114
158
|
}
|
|
115
|
-
return <Fragment key={i}>{part}</Fragment
|
|
116
|
-
})
|
|
159
|
+
return <Fragment key={i}>{part}</Fragment>;
|
|
160
|
+
});
|
|
117
161
|
}
|