@skyhook-io/k8s-ui 1.7.15 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/package.json +2 -1
  2. package/src/components/applications/ApplicationsList.tsx +131 -115
  3. package/src/components/applications/index.ts +3 -1
  4. package/src/components/audit/AuditCard.tsx +1 -1
  5. package/src/components/charts/AreaChart.tsx +52 -18
  6. package/src/components/charts/MetricsSummary.tsx +12 -3
  7. package/src/components/charts/saturation.test.ts +34 -0
  8. package/src/components/charts/saturation.ts +1 -0
  9. package/src/components/charts/types.ts +5 -4
  10. package/src/components/checks/ChecksView.tsx +12 -23
  11. package/src/components/checks/severity.ts +11 -11
  12. package/src/components/gitops/GitOpsTableView.tsx +298 -274
  13. package/src/components/issues/severity.ts +19 -11
  14. package/src/components/resources/ResourcesSidebar.tsx +3 -1
  15. package/src/components/resources/ResourcesView.tsx +13 -0
  16. package/src/components/resources/index.ts +1 -0
  17. package/src/components/resources/renderers/HPARenderer.test.tsx +91 -0
  18. package/src/components/resources/renderers/HPARenderer.tsx +93 -86
  19. package/src/components/resources/renderers/WorkloadRenderer.test.tsx +83 -0
  20. package/src/components/resources/renderers/WorkloadRenderer.tsx +73 -4
  21. package/src/components/resources/resource-utils-hpa.test.ts +30 -0
  22. package/src/components/resources/resource-utils-hpa.ts +106 -0
  23. package/src/components/resources/resource-utils.ts +2 -10
  24. package/src/components/shared/ResourceRendererDispatch.tsx +10 -2
  25. package/src/components/ui/DistributionBar.tsx +88 -0
  26. package/src/components/ui/Facet.tsx +130 -0
  27. package/src/components/ui/PageHeader.tsx +43 -0
  28. package/src/components/ui/SearchPillInput.tsx +237 -0
  29. package/src/components/ui/SortableTh.tsx +58 -0
  30. package/src/components/ui/SummaryTile.tsx +60 -0
  31. package/src/components/ui/drawer-components.tsx +56 -17
  32. package/src/components/ui/index.ts +12 -0
  33. package/src/components/workload/WorkloadView.tsx +18 -1
  34. package/src/theme/tailwind-theme.css +1 -0
  35. package/src/theme/variables.css +7 -3
  36. package/src/types/core.ts +54 -2
  37. package/src/utils/rbac-blast-radius.test.ts +48 -3
  38. package/src/utils/rbac-blast-radius.ts +14 -4
  39. package/tsconfig.json +1 -0
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { readFileSync } from 'node:fs'
3
+ import { resolve } from 'node:path'
4
+ import { getHPATableState, hpaStateLabel } from './resource-utils-hpa'
5
+ import type { HPADiagnosisState } from '../../types'
6
+
7
+ interface FixtureCase {
8
+ name: string
9
+ hpa: unknown
10
+ expectedTableState: HPADiagnosisState
11
+ }
12
+
13
+ describe('getHPATableState', () => {
14
+ for (const tc of loadCases()) {
15
+ it(`classifies ${tc.name}`, () => {
16
+ expect(getHPATableState(tc.hpa)).toBe(tc.expectedTableState)
17
+ })
18
+ }
19
+ })
20
+
21
+ describe('hpaStateLabel', () => {
22
+ it('keeps max-bound wording terse for table cells', () => {
23
+ expect(hpaStateLabel('limited_max')).toBe('Maxed')
24
+ })
25
+ })
26
+
27
+ function loadCases(): FixtureCase[] {
28
+ const path = resolve(process.cwd(), '../../testdata/hpa-diagnosis/cases.json')
29
+ return JSON.parse(readFileSync(path, 'utf8')) as FixtureCase[]
30
+ }
@@ -0,0 +1,106 @@
1
+ import type { HealthLevel, StatusBadge } from './resource-utils'
2
+ import type { HPADiagnosisState } from '../../types'
3
+
4
+ export function getHPATableState(hpa: any): HPADiagnosisState {
5
+ // List rows only classify states worth scanning in a table; the Go diagnosis powers the full drawer context.
6
+ const conditions = Array.isArray(hpa?.status?.conditions) ? hpa.status.conditions : []
7
+ const current = hpa?.status?.currentReplicas ?? 0
8
+ const desired = hpa?.status?.desiredReplicas ?? 0
9
+ const min = hpa?.spec?.minReplicas ?? 1
10
+ const max = hpa?.spec?.maxReplicas ?? 0
11
+
12
+ if (conditionStatus(conditions, 'AbleToScale') === 'False') return 'unable_to_scale'
13
+ const scalingActive = condition(conditions, 'ScalingActive')
14
+ if (scalingActive?.status === 'False') {
15
+ if (isScalingDisabled(scalingActive)) return 'disabled'
16
+ return 'metrics_unavailable'
17
+ }
18
+ if (max > 0 && min === max && current === desired && desired === max) return 'pinned'
19
+ if (isMaxLimited(condition(conditions, 'ScalingLimited'))) return 'limited_max'
20
+
21
+ if (current < desired) return 'scaling_up'
22
+ if (current > desired) return 'scaling_down'
23
+ return 'ok'
24
+ }
25
+
26
+ export function hpaStateLabel(state: HPADiagnosisState): string {
27
+ switch (state) {
28
+ case 'unable_to_scale':
29
+ return 'Unable to scale'
30
+ case 'metrics_unavailable':
31
+ return 'Metrics unavailable'
32
+ case 'metrics_incomplete':
33
+ return 'Metrics incomplete'
34
+ case 'disabled':
35
+ return 'Disabled'
36
+ case 'pinned':
37
+ return 'Pinned'
38
+ case 'limited_max':
39
+ return 'Maxed'
40
+ case 'limited_min':
41
+ return 'At min'
42
+ case 'stale':
43
+ return 'Stale'
44
+ case 'stabilized':
45
+ return 'Stabilized'
46
+ case 'scaling_up':
47
+ return 'Scaling Up'
48
+ case 'scaling_down':
49
+ return 'Scaling Down'
50
+ case 'ok':
51
+ return 'Stable'
52
+ default:
53
+ return 'Unknown'
54
+ }
55
+ }
56
+
57
+ export function hpaStateLevel(state: HPADiagnosisState): HealthLevel {
58
+ switch (state) {
59
+ case 'unable_to_scale':
60
+ case 'metrics_unavailable':
61
+ return 'unhealthy'
62
+ case 'limited_max':
63
+ case 'metrics_incomplete':
64
+ return 'degraded'
65
+ case 'scaling_up':
66
+ case 'scaling_down':
67
+ return 'degraded'
68
+ case 'limited_min':
69
+ case 'disabled':
70
+ case 'pinned':
71
+ case 'stabilized':
72
+ return 'neutral'
73
+ case 'stale':
74
+ return 'unknown'
75
+ case 'ok':
76
+ return 'healthy'
77
+ default:
78
+ return 'unknown'
79
+ }
80
+ }
81
+
82
+ export function hpaStatusFromState(state: HPADiagnosisState, healthColors: Record<HealthLevel, string>): StatusBadge {
83
+ const level = hpaStateLevel(state)
84
+ return { text: hpaStateLabel(state), color: healthColors[level], level }
85
+ }
86
+
87
+ function conditionStatus(conditions: any[], type: string): string | undefined {
88
+ return condition(conditions, type)?.status
89
+ }
90
+
91
+ function condition(conditions: any[], type: string): any | undefined {
92
+ return conditions.find((item) => item?.type === type)
93
+ }
94
+
95
+ function isScalingDisabled(item: any): boolean {
96
+ const reason = String(item?.reason ?? '').toLowerCase()
97
+ const message = String(item?.message ?? '').toLowerCase()
98
+ return reason === 'scalingdisabled' || message.includes('scaling is disabled')
99
+ }
100
+
101
+ function isMaxLimited(item: any): boolean {
102
+ if (item?.status !== 'True') return false
103
+ const reason = String(item?.reason ?? '').toLowerCase()
104
+ const message = String(item?.message ?? '').toLowerCase()
105
+ return reason.includes('toomany') || message.includes('maximum')
106
+ }
@@ -14,6 +14,7 @@ import { getResourceClaimStatus as _getResourceClaimStatus, getResourceClaimDevi
14
14
  import { getNvidiaClusterPolicyStatus as _getNvidiaClusterPolicyStatus, getNvidiaClusterPolicyEnabledComponents as _getNvidiaClusterPolicyEnabledComponents, getNvidiaDriverStatus as _getNvidiaDriverStatus } from './resource-utils-nvidia'
15
15
  import { getBackupStatus as _getBackupStatus, getRestoreStatus as _getRestoreStatus, getScheduleStatus as _getScheduleStatus, getBSLStatus as _getBSLStatus } from './resource-utils-velero'
16
16
  import { getExternalSecretStatus as _getExternalSecretStatus, getClusterExternalSecretStatus as _getClusterExternalSecretStatus, getSecretStoreStatus as _getSecretStoreStatus, getClusterSecretStoreStatus as _getClusterSecretStoreStatus, getSecretStoreProviderType as _getSecretStoreProviderType } from './resource-utils-eso'
17
+ import { getHPATableState, hpaStatusFromState } from './resource-utils-hpa'
17
18
 
18
19
  // ============================================================================
19
20
  // STATUS & HEALTH UTILITIES
@@ -960,16 +961,7 @@ export function getCronJobLastRun(cj: any): string | null {
960
961
  // ============================================================================
961
962
 
962
963
  export function getHPAStatus(hpa: any): StatusBadge {
963
- const current = hpa.status?.currentReplicas || 0
964
- const desired = hpa.status?.desiredReplicas || 0
965
-
966
- if (current === desired) {
967
- return { text: 'Stable', color: healthColors.healthy, level: 'healthy' }
968
- }
969
- if (current < desired) {
970
- return { text: 'Scaling Up', color: healthColors.degraded, level: 'degraded' }
971
- }
972
- return { text: 'Scaling Down', color: healthColors.degraded, level: 'degraded' }
964
+ return hpaStatusFromState(getHPATableState(hpa), healthColors)
973
965
  }
974
966
 
975
967
  export function getHPAReplicas(hpa: any): { current: number; min: number; max: number } {
@@ -226,10 +226,11 @@ import {
226
226
  isComposite,
227
227
  isClaim,
228
228
  } from '../resources/resource-utils-crossplane'
229
- import type { SelectedResource, Relationships, ResourceRef, SecretCertificateInfo, ResolvedEnvFrom, TimelineEvent } from '../../types'
229
+ import type { SelectedResource, Relationships, ResourceRef, SecretCertificateInfo, ResolvedEnvFrom, TimelineEvent, HPADiagnosis } from '../../types'
230
230
  import type { CopyHandler } from '../ui/drawer-components'
231
231
  import { AlertBanner } from '../ui/drawer-components'
232
232
  import { replicaScalers } from '../../utils/replica-scalers'
233
+ import type { ScalerDiagnosis } from '../resources/renderers/WorkloadRenderer'
233
234
 
234
235
  /**
235
236
  * Override map letting each platform consumer swap in its own renderer components.
@@ -258,6 +259,7 @@ export interface RendererOverrides {
258
259
  onNavigate?: (ref: ResourceRef) => void
259
260
  relationships?: Relationships
260
261
  scaleBlockedBy?: ResourceRef[]
262
+ scalerDiagnostics?: ScalerDiagnosis[]
261
263
  }>
262
264
  // Optional override for Crossplane Composite / Claim — host wraps the
263
265
  // package renderer to fan out per-composed-ref status fetches via React Query.
@@ -297,6 +299,7 @@ export interface RendererOverrides {
297
299
  HPARenderer?: React.ComponentType<{
298
300
  data: any
299
301
  onNavigate?: (ref: ResourceRef) => void
302
+ hpaDiagnosis?: HPADiagnosis
300
303
  }>
301
304
  // PVC: host wraps the base renderer to add a kubelet-derived usage gauge
302
305
  // when Prometheus is scraping kubelet endpoints.
@@ -376,6 +379,8 @@ interface ResourceRendererDispatchProps {
376
379
  data: any
377
380
  relationships?: Relationships
378
381
  certificateInfo?: SecretCertificateInfo
382
+ hpaDiagnosis?: HPADiagnosis
383
+ scalerDiagnostics?: ScalerDiagnosis[]
379
384
  onCopy: (text: string, key: string) => void
380
385
  copied: string | null
381
386
  onNavigate?: (ref: ResourceRef) => void
@@ -417,6 +422,8 @@ export function ResourceRendererDispatch({
417
422
  data,
418
423
  relationships,
419
424
  certificateInfo,
425
+ hpaDiagnosis,
426
+ scalerDiagnostics,
420
427
  onCopy,
421
428
  copied,
422
429
  onNavigate,
@@ -503,6 +510,7 @@ export function ResourceRendererDispatch({
503
510
  onNavigate={onNavigate}
504
511
  relationships={relationships}
505
512
  scaleBlockedBy={scaleBlockedBy}
513
+ scalerDiagnostics={scalerDiagnostics}
506
514
  />
507
515
  )}
508
516
  {kind === 'replicasets' && <ReplicaSetRenderer data={data} />}
@@ -513,7 +521,7 @@ export function ResourceRendererDispatch({
513
521
  {kind === 'secrets' && <SecretRenderer data={data} certificateInfo={certificateInfo} resourceData={data} onSaveSecretValue={onSaveSecretValue} isSaving={isSavingSecret} />}
514
522
  {kind === 'jobs' && <JobRenderer data={data} />}
515
523
  {kind === 'cronjobs' && <CronJobRenderer data={data} onNavigate={onNavigate} />}
516
- {(kind === 'hpas' || kind === 'horizontalpodautoscalers') && <HPAComp data={data} onNavigate={onNavigate} />}
524
+ {(kind === 'hpas' || kind === 'horizontalpodautoscalers') && <HPAComp data={data} onNavigate={onNavigate} hpaDiagnosis={hpaDiagnosis} />}
517
525
  {kind === 'nodes' && <NodeComp data={data} relationships={relationships} />}
518
526
  {kind === 'persistentvolumeclaims' && <PVCComp data={data} onNavigate={onNavigate} />}
519
527
  {kind === 'rollouts' && <RolloutRenderer data={data} />}
@@ -0,0 +1,88 @@
1
+ import { clsx } from 'clsx'
2
+
3
+ export interface DistributionSegment {
4
+ key: string
5
+ count: number
6
+ /** Tailwind background class for this segment's fill (e.g. status-tone bg). */
7
+ fillClass: string
8
+ }
9
+
10
+ // A single segmented proportion bar — the shared "distribution of statuses"
11
+ // element behind the Checks severity rollup, the Applications/GitOps health
12
+ // rollup, and the Issues rollup. One height, one track, one radius everywhere.
13
+ //
14
+ // Intentional empty state: with no data (sum === 0) it renders NOTHING rather
15
+ // than a flat grey track that reads as a skeleton/loading bar on sparse clusters.
16
+ export function DistributionBar({
17
+ segments,
18
+ className,
19
+ ariaLabel = 'Distribution',
20
+ }: {
21
+ segments: DistributionSegment[]
22
+ className?: string
23
+ ariaLabel?: string
24
+ }) {
25
+ const sum = segments.reduce((n, s) => n + s.count, 0)
26
+ if (sum === 0) return null
27
+ return (
28
+ <div
29
+ className={clsx('flex h-1.5 overflow-hidden rounded-full bg-theme-elevated', className)}
30
+ role="img"
31
+ aria-label={ariaLabel}
32
+ >
33
+ {segments.map((s) =>
34
+ s.count > 0 ? (
35
+ <div
36
+ key={s.key}
37
+ className={clsx(s.fillClass, 'transition-[width] duration-500 ease-out')}
38
+ style={{ width: `${(s.count / sum) * 100}%` }}
39
+ />
40
+ ) : null,
41
+ )}
42
+ </div>
43
+ )
44
+ }
45
+
46
+ // A legend entry for a DistributionBar: a tone dot + count + label. When onClick
47
+ // is provided it's a toggle (the legend doubles as the filter, as in the Checks
48
+ // and Issues queues); without it, it's a static legend item.
49
+ export function DistributionLegendChip({
50
+ label,
51
+ count,
52
+ fillClass,
53
+ textClass,
54
+ active = false,
55
+ onClick,
56
+ }: {
57
+ label: string
58
+ count: number
59
+ fillClass: string
60
+ /** Tone text class applied to the count when count > 0. */
61
+ textClass?: string
62
+ active?: boolean
63
+ onClick?: () => void
64
+ }) {
65
+ const inner = (
66
+ <>
67
+ <span className={clsx('h-2 w-2 rounded-full', fillClass, count === 0 && 'opacity-30')} />
68
+ <span className={clsx('font-semibold tabular-nums', count > 0 ? textClass : 'text-theme-text-tertiary')}>{count}</span>
69
+ <span>{label}</span>
70
+ </>
71
+ )
72
+ if (!onClick) {
73
+ return <span className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs text-theme-text-secondary">{inner}</span>
74
+ }
75
+ return (
76
+ <button
77
+ type="button"
78
+ onClick={onClick}
79
+ aria-pressed={active}
80
+ className={clsx(
81
+ 'group inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition-colors',
82
+ active ? 'border-theme-border bg-theme-elevated text-theme-text-primary' : 'border-transparent text-theme-text-secondary hover:bg-theme-hover/60',
83
+ )}
84
+ >
85
+ {inner}
86
+ </button>
87
+ )
88
+ }
@@ -0,0 +1,130 @@
1
+ import type { ComponentType, ReactNode } from 'react'
2
+ import { clsx } from 'clsx'
3
+ import { Info } from 'lucide-react'
4
+ import { Tooltip } from './Tooltip'
5
+
6
+ // Shared faceted-filter primitives for sidebar filter rails. One implementation
7
+ // behind the GitOps and Applications rails so the "this filter is on" language
8
+ // stays identical everywhere — active = the skyhook accent, the same signal the
9
+ // nav rail and header status tiles use.
10
+
11
+ export type FacetTone = 'neutral' | 'success' | 'warning' | 'error' | 'info'
12
+
13
+ const DOT_CLASS: Record<FacetTone, string> = {
14
+ neutral: 'bg-theme-text-tertiary',
15
+ success: 'bg-emerald-500',
16
+ warning: 'bg-amber-500',
17
+ error: 'bg-red-500',
18
+ info: 'bg-sky-500',
19
+ }
20
+
21
+ // A titled group in a filter rail: optional leading icon, optional info tooltip,
22
+ // and a bottom divider so stacked sections read as distinct facets.
23
+ export function FacetSection({
24
+ icon: Icon,
25
+ title,
26
+ info,
27
+ children,
28
+ }: {
29
+ icon?: ComponentType<{ className?: string }>
30
+ title: string
31
+ info?: ReactNode
32
+ children: ReactNode
33
+ }) {
34
+ return (
35
+ <section className="border-b border-theme-border px-3 py-2">
36
+ <div className="mb-1.5 flex items-center gap-1.5">
37
+ {Icon && <Icon className="h-3.5 w-3.5 text-theme-text-tertiary" />}
38
+ <span className="text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">{title}</span>
39
+ {info && (
40
+ <Tooltip content={info} delay={150} position="right">
41
+ <Info className="h-3 w-3 cursor-default text-theme-text-tertiary/70 hover:text-theme-text-secondary" aria-label={`About ${title}`} />
42
+ </Tooltip>
43
+ )}
44
+ </div>
45
+ <div className="space-y-0.5">{children}</div>
46
+ </section>
47
+ )
48
+ }
49
+
50
+ // A single multi-select filter toggle: leading status dot (tone-colored), label,
51
+ // trailing count. Active uses the skyhook accent.
52
+ export function FacetButton({
53
+ label,
54
+ count,
55
+ active,
56
+ tone = 'neutral',
57
+ tooltip,
58
+ onClick,
59
+ }: {
60
+ label: string
61
+ count?: number
62
+ active: boolean
63
+ tone?: FacetTone
64
+ tooltip?: ReactNode
65
+ onClick: () => void
66
+ }) {
67
+ const btn = (
68
+ <button
69
+ type="button"
70
+ onClick={onClick}
71
+ aria-pressed={active}
72
+ className={clsx(
73
+ 'flex w-full items-center gap-2 rounded px-2 py-1 text-left text-[11px] transition-colors',
74
+ active
75
+ ? 'bg-skyhook-500/15 text-skyhook-700 dark:text-skyhook-300'
76
+ : 'text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary',
77
+ )}
78
+ >
79
+ <span className={clsx('h-2 w-2 shrink-0 rounded-full', DOT_CLASS[tone])} />
80
+ <span className="min-w-0 flex-1 truncate font-medium">{label}</span>
81
+ {count != null && count > 0 && <span className="tabular-nums text-theme-text-tertiary">{count}</span>}
82
+ </button>
83
+ )
84
+ return tooltip ? (
85
+ <Tooltip content={tooltip} delay={300} position="right" wrapperClassName="w-full">
86
+ {btn}
87
+ </Tooltip>
88
+ ) : (
89
+ btn
90
+ )
91
+ }
92
+
93
+ // Convenience wrapper: a FacetSection of multi-select FacetButtons driven by a
94
+ // Set. Auto-hides zero-count options (quieter than a hard slice cap) unless
95
+ // hideZero is turned off.
96
+ export function Facet<T extends string>({
97
+ title,
98
+ icon,
99
+ info,
100
+ options,
101
+ selected,
102
+ onToggle,
103
+ hideZero = true,
104
+ }: {
105
+ title: string
106
+ icon?: ComponentType<{ className?: string }>
107
+ info?: ReactNode
108
+ options: { value: T; label: string; count: number; tone?: FacetTone; tooltip?: ReactNode }[]
109
+ selected: Set<T>
110
+ onToggle: (v: T) => void
111
+ hideZero?: boolean
112
+ }) {
113
+ const visible = hideZero ? options.filter((o) => o.count > 0) : options
114
+ if (visible.length === 0) return null
115
+ return (
116
+ <FacetSection icon={icon} title={title} info={info}>
117
+ {visible.map((o) => (
118
+ <FacetButton
119
+ key={o.value}
120
+ label={o.label}
121
+ count={o.count}
122
+ active={selected.has(o.value)}
123
+ tone={o.tone}
124
+ tooltip={o.tooltip}
125
+ onClick={() => onToggle(o.value)}
126
+ />
127
+ ))}
128
+ </FacetSection>
129
+ )
130
+ }
@@ -0,0 +1,43 @@
1
+ import type { ComponentType, ReactNode } from 'react'
2
+ import { ArrowLeft } from 'lucide-react'
3
+
4
+ // Standard view page header — icon + title + description, optional back button
5
+ // and right-aligned custom content (counts, tiles, settings, actions…). The
6
+ // consistent "where am I / what is this page" signal across views (Rancher
7
+ // Masthead / Headlamp SectionHeader pattern).
8
+ interface PageHeaderProps {
9
+ icon: ComponentType<{ className?: string }>
10
+ title: string
11
+ description?: string
12
+ /** Renders a back button to the left of the title when provided. */
13
+ onBack?: () => void
14
+ /** Right-aligned custom content — wraps onto multiple lines when it overflows
15
+ * (e.g. a row of count tiles). */
16
+ actions?: ReactNode
17
+ }
18
+
19
+ export function PageHeader({ icon: Icon, title, description, onBack, actions }: PageHeaderProps) {
20
+ return (
21
+ <div className="flex items-center gap-4">
22
+ {onBack && (
23
+ <button
24
+ onClick={onBack}
25
+ aria-label="Back"
26
+ className="p-1.5 rounded-lg hover:bg-theme-hover transition-colors shrink-0"
27
+ >
28
+ <ArrowLeft className="w-5 h-5 text-theme-text-secondary" />
29
+ </button>
30
+ )}
31
+ <div className="flex-1 min-w-0">
32
+ <div className="flex items-center gap-2">
33
+ <Icon className="w-5 h-5 text-theme-text-secondary shrink-0" />
34
+ <h1 className="text-lg font-semibold text-theme-text-primary truncate">{title}</h1>
35
+ </div>
36
+ {description && (
37
+ <p className="text-sm text-theme-text-tertiary mt-1 ml-7 truncate">{description}</p>
38
+ )}
39
+ </div>
40
+ {actions && <div className="flex flex-wrap items-center justify-end gap-2 shrink-0">{actions}</div>}
41
+ </div>
42
+ )
43
+ }