@skyhook-io/k8s-ui 1.14.6 → 1.14.8
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/gitops/tree/GitOpsTreeGraph.test.ts +92 -0
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +36 -6
- 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/K8sResourceNode.tsx +4 -2
- 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 +61 -0
- package/src/components/ui/Collapse.tsx +115 -29
- package/src/components/ui/DialogPortal.tsx +7 -2
- package/src/components/ui/Disclosure.test.tsx +26 -0
- package/src/components/ui/Disclosure.tsx +69 -0
- 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 +31 -22
- package/src/components/workload/WorkloadView.tsx +6 -10
- package/src/hooks/useAnimatedUnmount.ts +21 -3
- package/src/theme/components.css +0 -19
- package/src/utils/animation.ts +93 -13
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { useState } from 'react'
|
|
2
|
-
import {
|
|
1
|
+
import { useState, useId } from 'react'
|
|
2
|
+
import { Box } from 'lucide-react'
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
4
|
import { formatAge } from '../../resource-utils'
|
|
5
|
+
import { Collapse, CollapseChevron, disclosurePanelId } from '../../../ui/Collapse'
|
|
5
6
|
import { revisionRoleBadges } from '../../../shared/ResourceActionsBar'
|
|
6
7
|
import type { WorkloadRevision, WorkloadPodInfo } from '../../../../types'
|
|
7
8
|
|
|
@@ -38,6 +39,9 @@ function podToneClass(pod: WorkloadPodInfo): string {
|
|
|
38
39
|
// to that dialog — this is purely informational.
|
|
39
40
|
export function ReplicaSetProgression({ revisions, pods, namespace, onNavigate }: ReplicaSetProgressionProps) {
|
|
40
41
|
const [expanded, setExpanded] = useState<Set<number>>(new Set())
|
|
42
|
+
// Revision rows are mapped inline, so they can't each call useDisclosure;
|
|
43
|
+
// one generated prefix plus the revision number keeps aria-controls unique.
|
|
44
|
+
const panelBase = useId()
|
|
41
45
|
|
|
42
46
|
const podsByHash = new Map<string, WorkloadPodInfo[]>()
|
|
43
47
|
for (const pod of pods ?? []) {
|
|
@@ -59,17 +63,20 @@ export function ReplicaSetProgression({ revisions, pods, namespace, onNavigate }
|
|
|
59
63
|
<div className="space-y-1">
|
|
60
64
|
{revisions.map((rev) => {
|
|
61
65
|
const revPods = rev.podHash ? podsByHash.get(rev.podHash) ?? [] : []
|
|
62
|
-
|
|
66
|
+
// One predicate for header, caret and panel: no pods, nothing to open.
|
|
67
|
+
const isOpen = expanded.has(rev.number) && revPods.length > 0
|
|
63
68
|
return (
|
|
64
69
|
<div key={rev.number} className="rounded border border-theme-border/50">
|
|
65
70
|
<button
|
|
66
71
|
type="button"
|
|
72
|
+
aria-expanded={isOpen}
|
|
73
|
+
aria-controls={disclosurePanelId(panelBase, rev.number)}
|
|
67
74
|
onClick={() => toggle(rev.number)}
|
|
68
75
|
className="flex w-full items-center gap-2 px-2 py-1.5 text-left hover:bg-theme-hover"
|
|
69
76
|
disabled={revPods.length === 0}
|
|
70
77
|
>
|
|
71
78
|
{revPods.length > 0 ? (
|
|
72
|
-
|
|
79
|
+
<CollapseChevron open={isOpen} className="h-3.5 w-3.5" />
|
|
73
80
|
) : (
|
|
74
81
|
<span className="w-3.5 shrink-0" />
|
|
75
82
|
)}
|
|
@@ -85,7 +92,7 @@ export function ReplicaSetProgression({ revisions, pods, namespace, onNavigate }
|
|
|
85
92
|
<span className="shrink-0 text-xs tabular-nums text-theme-text-tertiary">{rev.replicas ?? 0} replicas</span>
|
|
86
93
|
<span className="shrink-0 text-xs text-theme-text-tertiary">{formatAge(rev.createdAt)}</span>
|
|
87
94
|
</button>
|
|
88
|
-
{isOpen
|
|
95
|
+
<Collapse open={isOpen} mountLazily id={disclosurePanelId(panelBase, rev.number)}>
|
|
89
96
|
<div className="space-y-0.5 border-t border-theme-border/50 px-2 py-1.5 pl-8">
|
|
90
97
|
{revPods.map((pod) => (
|
|
91
98
|
<div key={pod.name} className="flex items-center gap-2 py-0.5 text-xs">
|
|
@@ -107,7 +114,7 @@ export function ReplicaSetProgression({ revisions, pods, namespace, onNavigate }
|
|
|
107
114
|
</div>
|
|
108
115
|
))}
|
|
109
116
|
</div>
|
|
110
|
-
|
|
117
|
+
</Collapse>
|
|
111
118
|
</div>
|
|
112
119
|
)
|
|
113
120
|
})}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState, type DragEvent as ReactDragEvent } from 'react'
|
|
2
|
-
import { AlertTriangle, Check,
|
|
2
|
+
import { AlertTriangle, Check, Loader2, Upload, X } from 'lucide-react'
|
|
3
|
+
import { Collapse, CollapseChevron, useDisclosure } from '../ui/Collapse'
|
|
3
4
|
import { ConfirmDialog } from '../ui/ConfirmDialog'
|
|
4
5
|
import { DialogPortal } from '../ui/DialogPortal'
|
|
5
6
|
import { YamlEditor, type YamlSchemaLoader } from '../ui/YamlEditor'
|
|
@@ -588,6 +589,7 @@ export function CreateResourceDialog({
|
|
|
588
589
|
|
|
589
590
|
function ApplyErrorBanner({ error }: { error: string }) {
|
|
590
591
|
const [expanded, setExpanded] = useState(false)
|
|
592
|
+
const { panelId, buttonProps } = useDisclosure(expanded)
|
|
591
593
|
const parsed = formatApplyError(error)
|
|
592
594
|
const hasFriendly = Boolean(parsed.suggestion)
|
|
593
595
|
return (
|
|
@@ -603,19 +605,20 @@ function ApplyErrorBanner({ error }: { error: string }) {
|
|
|
603
605
|
</div>
|
|
604
606
|
{hasFriendly && (
|
|
605
607
|
<button
|
|
608
|
+
{...buttonProps}
|
|
606
609
|
type="button"
|
|
607
610
|
onClick={() => setExpanded((value) => !value)}
|
|
608
611
|
className="flex items-center gap-1 px-3 pb-2 text-red-500/60 hover:text-red-500/80 dark:text-red-400/60 dark:hover:text-red-400/80"
|
|
609
612
|
>
|
|
610
|
-
|
|
613
|
+
<CollapseChevron open={expanded} inheritColor className="h-3 w-3" />
|
|
611
614
|
Details
|
|
612
615
|
</button>
|
|
613
616
|
)}
|
|
614
|
-
{expanded && hasFriendly
|
|
617
|
+
<Collapse open={expanded && hasFriendly} id={panelId}>
|
|
615
618
|
<div className="break-all px-3 pb-2 font-mono leading-relaxed text-red-500/60 dark:text-red-400/60">
|
|
616
619
|
{parsed.raw}
|
|
617
620
|
</div>
|
|
618
|
-
|
|
621
|
+
</Collapse>
|
|
619
622
|
</div>
|
|
620
623
|
)
|
|
621
624
|
}
|
|
@@ -7,13 +7,13 @@ import {
|
|
|
7
7
|
CheckCircle,
|
|
8
8
|
Clock,
|
|
9
9
|
RefreshCw,
|
|
10
|
-
ChevronRight,
|
|
11
10
|
Plus,
|
|
12
11
|
Trash2,
|
|
13
12
|
Shield,
|
|
14
13
|
X,
|
|
15
14
|
} from 'lucide-react'
|
|
16
15
|
import { clsx } from 'clsx'
|
|
16
|
+
import { Collapse, CollapseChevron } from '../ui/Collapse'
|
|
17
17
|
import { DiffViewer, DiffBadge } from './DiffViewer'
|
|
18
18
|
import { TimelineToolbar } from './TimelineToolbar'
|
|
19
19
|
import {
|
|
@@ -696,17 +696,20 @@ function ActivityCard({ item, expanded, onToggle, onResourceClick, compact, sele
|
|
|
696
696
|
aria-label={expanded ? 'Collapse changes' : 'Expand changes'}
|
|
697
697
|
className="shrink-0 rounded p-0.5 text-theme-text-disabled hover:bg-theme-elevated hover:text-theme-text-secondary"
|
|
698
698
|
>
|
|
699
|
-
<
|
|
699
|
+
<CollapseChevron open={expanded} inheritColor className="w-4 h-4" />
|
|
700
700
|
</button>
|
|
701
701
|
)}
|
|
702
702
|
</div>
|
|
703
703
|
|
|
704
|
-
{/* Expanded details - only for items with diffs
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
<
|
|
709
|
-
|
|
704
|
+
{/* Expanded details - only for items with diffs. A list holds many
|
|
705
|
+
cards, each diff is real work to render: mount on first open. */}
|
|
706
|
+
{hasExpandableContent && item.diff && (
|
|
707
|
+
<Collapse open={expanded} mountLazily>
|
|
708
|
+
<div className="mt-3 pt-3 border-t-subtle">
|
|
709
|
+
<div className="text-xs text-theme-text-tertiary mb-2">Changes:</div>
|
|
710
|
+
<DiffViewer diff={item.diff} />
|
|
711
|
+
</div>
|
|
712
|
+
</Collapse>
|
|
710
713
|
)}
|
|
711
714
|
</div>
|
|
712
715
|
</div>
|
|
@@ -826,12 +829,12 @@ function AggregatedActivityCard({ first, last, count, reason, expanded, onToggle
|
|
|
826
829
|
aria-label={expanded ? 'Collapse occurrences' : 'Expand occurrences'}
|
|
827
830
|
className="shrink-0 rounded p-0.5 text-theme-text-disabled hover:bg-theme-elevated hover:text-theme-text-secondary"
|
|
828
831
|
>
|
|
829
|
-
<
|
|
832
|
+
<CollapseChevron open={expanded} inheritColor className="w-4 h-4" />
|
|
830
833
|
</button>
|
|
831
834
|
</div>
|
|
832
835
|
|
|
833
836
|
{/* Expanded details */}
|
|
834
|
-
{expanded
|
|
837
|
+
<Collapse open={expanded}>
|
|
835
838
|
<div className="mt-3 pt-3 border-t-subtle space-y-3">
|
|
836
839
|
{/* First occurrence */}
|
|
837
840
|
<div className="flex items-start gap-2">
|
|
@@ -865,7 +868,7 @@ function AggregatedActivityCard({ first, last, count, reason, expanded, onToggle
|
|
|
865
868
|
</div>
|
|
866
869
|
</div>
|
|
867
870
|
</div>
|
|
868
|
-
|
|
871
|
+
</Collapse>
|
|
869
872
|
</div>
|
|
870
873
|
</div>
|
|
871
874
|
)
|
|
@@ -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}
|
|
@@ -173,8 +173,10 @@ function getIssueTooltip(issue: string | undefined): React.ReactNode {
|
|
|
173
173
|
// a multi-port Service's other ports are visible without opening the detail
|
|
174
174
|
// page. Formatting (hide targetPort when it matches port) mirrors
|
|
175
175
|
// ServicePortCards in ServiceRenderer.tsx so a Service's ports read the same
|
|
176
|
-
// whether glanced at in the graph or opened in the full resource view.
|
|
177
|
-
|
|
176
|
+
// whether glanced at in the graph or opened in the full resource view. Exported
|
|
177
|
+
// because the GitOps resource tree's Service node summarises ports the same way
|
|
178
|
+
// and needs the same hover to reach the ones the summary drops.
|
|
179
|
+
export function servicePortsTooltip(ports: ServicePortEntry[]): React.ReactNode | null {
|
|
178
180
|
if (ports.length < 2) return null;
|
|
179
181
|
return (
|
|
180
182
|
<div className="max-w-xs space-y-0.5">
|
|
@@ -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
|
}
|