@skyhook-io/k8s-ui 1.7.15 → 1.8.1

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 (42) 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 +14 -25
  11. package/src/components/checks/severity.ts +11 -11
  12. package/src/components/gitops/GitOpsTableView.tsx +307 -280
  13. package/src/components/gitops/detail-helpers.test.ts +1 -1
  14. package/src/components/issues/severity.ts +19 -11
  15. package/src/components/resources/ResourcesSidebar.tsx +3 -1
  16. package/src/components/resources/ResourcesView.tsx +13 -0
  17. package/src/components/resources/index.ts +1 -0
  18. package/src/components/resources/renderers/HPARenderer.test.tsx +91 -0
  19. package/src/components/resources/renderers/HPARenderer.tsx +93 -86
  20. package/src/components/resources/renderers/WorkloadRenderer.test.tsx +83 -0
  21. package/src/components/resources/renderers/WorkloadRenderer.tsx +73 -4
  22. package/src/components/resources/resource-utils-hpa.test.ts +30 -0
  23. package/src/components/resources/resource-utils-hpa.ts +106 -0
  24. package/src/components/resources/resource-utils.ts +6 -10
  25. package/src/components/shared/ResourceRendererDispatch.tsx +10 -2
  26. package/src/components/timeline/TimelineList.tsx +31 -3
  27. package/src/components/timeline/TimelineSwimlanes.tsx +27 -5
  28. package/src/components/ui/DistributionBar.tsx +88 -0
  29. package/src/components/ui/Facet.tsx +130 -0
  30. package/src/components/ui/PageHeader.tsx +43 -0
  31. package/src/components/ui/SearchPillInput.tsx +278 -0
  32. package/src/components/ui/SortableTh.tsx +58 -0
  33. package/src/components/ui/SummaryTile.tsx +60 -0
  34. package/src/components/ui/drawer-components.tsx +56 -17
  35. package/src/components/ui/index.ts +12 -0
  36. package/src/components/workload/WorkloadView.tsx +18 -1
  37. package/src/theme/tailwind-theme.css +1 -0
  38. package/src/theme/variables.css +7 -3
  39. package/src/types/core.ts +54 -2
  40. package/src/utils/rbac-blast-radius.test.ts +48 -3
  41. package/src/utils/rbac-blast-radius.ts +14 -4
  42. 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 } {
@@ -1135,6 +1127,10 @@ export function cronToHuman(cron: string): string {
1135
1127
  if (minute === '0' && hour === '0' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
1136
1128
  return 'Daily at midnight'
1137
1129
  }
1130
+ if (minute === '0' && hour.startsWith('*/') && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
1131
+ const interval = hour.slice(2)
1132
+ return interval === '1' ? 'Every hour' : `Every ${interval} hours`
1133
+ }
1138
1134
  if (minute === '0' && hour !== '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
1139
1135
  return `Daily at ${hour}:00`
1140
1136
  }
@@ -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} />}
@@ -53,6 +53,11 @@ export interface TimelineListProps {
53
53
  onResourceClick?: NavigateToResource
54
54
  initialFilter?: ActivityTypeFilter
55
55
  initialTimeRange?: TimeRange
56
+ // Controlled "show deleted" toggle. When omitted the component manages it
57
+ // internally; the host passes it to share one toggle across list + swimlane
58
+ // and to drive server-side delete filtering.
59
+ showDeleted?: boolean
60
+ onShowDeletedChange?: (showDeleted: boolean) => void
56
61
  }
57
62
 
58
63
  const TIME_RANGES: { value: TimeRange; label: string }[] = [
@@ -80,11 +85,14 @@ const RESOURCE_KINDS = [
80
85
  'StatefulSet',
81
86
  ]
82
87
 
83
- export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasLimitedAccess, namespaces, onViewChange, currentView = 'list', onResourceClick, initialFilter, initialTimeRange }: TimelineListProps) {
88
+ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasLimitedAccess, namespaces, onViewChange, currentView = 'list', onResourceClick, initialFilter, initialTimeRange, showDeleted: showDeletedProp, onShowDeletedChange }: TimelineListProps) {
84
89
  const [searchTerm, setSearchTerm] = useState('')
85
90
  const [activityTypeFilter, setActivityTypeFilter] = useState<ActivityTypeFilter>(initialFilter ?? 'all')
86
91
  const [timeRange, setTimeRange] = useState<TimeRange>(initialTimeRange ?? '1h')
87
92
  const [kindFilter, setKindFilter] = useState<string>('')
93
+ const [showDeletedInternal, setShowDeletedInternal] = useState(true)
94
+ const showDeleted = showDeletedProp ?? showDeletedInternal
95
+ const setShowDeleted = onShowDeletedChange ?? setShowDeletedInternal
88
96
  const [expandedItem, setExpandedItem] = useState<string | null>(null)
89
97
 
90
98
  useEffect(() => {
@@ -111,6 +119,7 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
111
119
  const isUnhealthyChange = isChangeEvent(item) && (item.healthState === 'unhealthy' || item.healthState === 'degraded')
112
120
  if (!isUnhealthyChange) return false
113
121
  }
122
+ if (!showDeleted && item.eventType === 'delete') return false
114
123
 
115
124
  // Filter by search term
116
125
  if (searchTerm) {
@@ -129,7 +138,7 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
129
138
 
130
139
  return true
131
140
  })
132
- }, [events, activityTypeFilter, searchTerm])
141
+ }, [events, activityTypeFilter, searchTerm, showDeleted])
133
142
 
134
143
  // Aggregated event group type
135
144
  type AggregatedItem = {
@@ -245,12 +254,13 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
245
254
 
246
255
  // Count stats
247
256
  const stats = useMemo(() => {
248
- if (!events) return { total: 0, changes: 0, warnings: 0, unhealthy: 0 }
257
+ if (!events) return { total: 0, changes: 0, warnings: 0, unhealthy: 0, deleted: 0 }
249
258
  return {
250
259
  total: events.length,
251
260
  changes: events.filter((e) => isChangeEvent(e)).length,
252
261
  warnings: events.filter((e) => e.eventType === 'Warning').length,
253
262
  unhealthy: events.filter((e) => isChangeEvent(e) && (e.healthState === 'unhealthy' || e.healthState === 'degraded')).length,
263
+ deleted: events.filter((e) => e.eventType === 'delete').length,
254
264
  }
255
265
  }, [events])
256
266
 
@@ -306,6 +316,24 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
306
316
  />
307
317
  </div>
308
318
 
319
+ <button
320
+ type="button"
321
+ onClick={() => setShowDeleted(!showDeleted)}
322
+ title="Show or hide resources that were deleted, including Pods that no longer exist"
323
+ className={clsx(
324
+ 'px-3 py-1.5 text-sm rounded-md transition-colors flex items-center gap-2 bg-theme-elevated',
325
+ showDeleted ? 'text-theme-text-primary' : 'text-theme-text-secondary hover:text-theme-text-primary'
326
+ )}
327
+ >
328
+ <Trash2 className="w-3 h-3" />
329
+ Deleted
330
+ {stats.deleted > 0 && (
331
+ <span className="text-xs px-1.5 rounded bg-theme-hover/50">
332
+ {stats.deleted}
333
+ </span>
334
+ )}
335
+ </button>
336
+
309
337
  {/* Kind filter */}
310
338
  <select
311
339
  value={kindFilter}
@@ -21,6 +21,7 @@ import {
21
21
  Timer,
22
22
  RotateCcw,
23
23
  Shield,
24
+ Trash2,
24
25
  } from 'lucide-react'
25
26
  import type { TimelineEvent, Topology } from '../../types'
26
27
  import type { NavigateToResource } from '../../utils/navigation'
@@ -54,9 +55,14 @@ export interface TimelineSwimlanesProps {
54
55
  // RBAC capability flag (was a radar/web context); host passes it. Default false.
55
56
  hasLimitedAccess?: boolean
56
57
  // GitOps lane labels deep-link to a controller path; the host decides how to
57
- // navigate (radar router push, or cross-SPA href). When omitted, GitOps lanes
58
+ // navigate (radar router push, or cross-route-tree href). When omitted, GitOps lanes
58
59
  // fall back to onResourceClick (the resource drawer).
59
60
  onNavigatePath?: (path: string) => void
61
+ // Controlled "show deleted" toggle. When omitted the component manages it
62
+ // internally; the host passes it to share one toggle with the list view and
63
+ // to drive server-side delete filtering on the underlying fetch.
64
+ showDeleted?: boolean
65
+ onShowDeletedChange?: (showDeleted: boolean) => void
60
66
  }
61
67
 
62
68
  interface ResourceLane extends BaseResourceLane {
@@ -180,7 +186,7 @@ function calculateInterestingnessWithBreakdown(lane: ResourceLane): ScoreBreakdo
180
186
  return breakdown
181
187
  }
182
188
 
183
- export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode, onViewModeChange, topology, namespaces, hasLimitedAccess = false, onNavigatePath }: TimelineSwimlanesProps) {
189
+ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode, onViewModeChange, topology, namespaces, hasLimitedAccess = false, onNavigatePath, showDeleted: showDeletedProp, onShowDeletedChange }: TimelineSwimlanesProps) {
184
190
  // Timeline lane labels for GitOps CRs (Application/Kustomization/HelmRelease)
185
191
  // deep-link to GitOps detail rather than the resource drawer — the lane is
186
192
  // already telling the user "this controller had changes/events"; the GitOps
@@ -203,6 +209,9 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
203
209
  const [expandedLanes, setExpandedLanes] = useState<Set<string>>(new Set())
204
210
  const [hasAutoZoomed, setHasAutoZoomed] = useState(false)
205
211
  const [groupByApp, setGroupByApp] = useState(true) // Group by app.kubernetes.io/name label
212
+ const [showDeletedInternal, setShowDeletedInternal] = useState(true)
213
+ const showDeleted = showDeletedProp ?? showDeletedInternal
214
+ const setShowDeleted = onShowDeletedChange ?? setShowDeletedInternal
206
215
 
207
216
  // Stable lane ordering - use ref to avoid render loop (lanes depends on order, order depends on lanes)
208
217
  const laneOrderRef = useRef<Map<string, number>>(new Map())
@@ -253,17 +262,18 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
253
262
 
254
263
  // Filter events by search term
255
264
  const filteredEvents = useMemo(() => {
256
- if (!searchTerm) return events
265
+ const baseEvents = showDeleted ? events : events.filter(e => e.eventType !== 'delete')
266
+ if (!searchTerm) return baseEvents
257
267
 
258
268
  const term = searchTerm.toLowerCase()
259
- return events.filter(e =>
269
+ return baseEvents.filter(e =>
260
270
  e.name.toLowerCase().includes(term) ||
261
271
  e.kind.toLowerCase().includes(term) ||
262
272
  e.namespace?.toLowerCase().includes(term) ||
263
273
  e.reason?.toLowerCase().includes(term) ||
264
274
  e.message?.toLowerCase().includes(term)
265
275
  )
266
- }, [events, searchTerm])
276
+ }, [events, searchTerm, showDeleted])
267
277
 
268
278
  // Build hierarchical lanes using owner references + topology edges
269
279
  // Uses the shared utility from utils/resource-hierarchy.ts
@@ -514,6 +524,18 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
514
524
  <span className="border-b border-dotted border-theme-text-tertiary">Group by app</span>
515
525
  </label>
516
526
  </Tooltip>
527
+ <Tooltip content="Show resources Radar observed being deleted, including Pods that no longer exist" position="bottom">
528
+ <label className="flex items-center gap-1.5 text-xs text-theme-text-secondary hover:text-theme-text-primary">
529
+ <input
530
+ type="checkbox"
531
+ checked={showDeleted}
532
+ onChange={(e) => setShowDeleted(e.target.checked)}
533
+ className="w-3.5 h-3.5 rounded border-theme-border-light bg-theme-elevated text-accent focus:ring-accent focus:ring-offset-0"
534
+ />
535
+ <Trash2 className="w-3.5 h-3.5" />
536
+ <span className="border-b border-dotted border-theme-text-tertiary">Deleted</span>
537
+ </label>
538
+ </Tooltip>
517
539
  {/* View toggle */}
518
540
  {onViewModeChange && (
519
541
  <div className="flex items-center gap-1 bg-theme-elevated rounded-lg p-1">
@@ -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
+ }