@skyhook-io/k8s-ui 1.7.14 → 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 (48) hide show
  1. package/package.json +7 -7
  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/IssuesView.tsx +4 -6
  14. package/src/components/issues/severity.ts +19 -11
  15. package/src/components/issues/types.ts +22 -2
  16. package/src/components/logs/WorkloadLogsViewer.tsx +16 -7
  17. package/src/components/resources/ResourcesSidebar.tsx +3 -1
  18. package/src/components/resources/ResourcesView.tsx +180 -21
  19. package/src/components/resources/get-pod-problems.test.ts +127 -0
  20. package/src/components/resources/index.ts +1 -0
  21. package/src/components/resources/renderers/HPARenderer.test.tsx +91 -0
  22. package/src/components/resources/renderers/HPARenderer.tsx +93 -86
  23. package/src/components/resources/renderers/PodRenderer.test.tsx +56 -0
  24. package/src/components/resources/renderers/PodRenderer.tsx +1 -1
  25. package/src/components/resources/renderers/WorkloadRenderer.test.tsx +83 -0
  26. package/src/components/resources/renderers/WorkloadRenderer.tsx +73 -4
  27. package/src/components/resources/resource-utils-hpa.test.ts +30 -0
  28. package/src/components/resources/resource-utils-hpa.ts +106 -0
  29. package/src/components/resources/resource-utils.ts +41 -28
  30. package/src/components/shared/ResourceRendererDispatch.tsx +10 -2
  31. package/src/components/ui/DistributionBar.tsx +88 -0
  32. package/src/components/ui/Facet.tsx +130 -0
  33. package/src/components/ui/PageHeader.tsx +43 -0
  34. package/src/components/ui/SearchPillInput.tsx +237 -0
  35. package/src/components/ui/SortableTh.tsx +58 -0
  36. package/src/components/ui/SummaryTile.tsx +60 -0
  37. package/src/components/ui/drawer-components.tsx +56 -17
  38. package/src/components/ui/index.ts +12 -0
  39. package/src/components/workload/WorkloadView.tsx +18 -1
  40. package/src/theme/tailwind-theme.css +1 -0
  41. package/src/theme/variables.css +7 -3
  42. package/src/types/core.ts +64 -2
  43. package/src/utils/bulk-workload-actions.test.ts +78 -0
  44. package/src/utils/bulk-workload-actions.ts +50 -0
  45. package/src/utils/index.ts +1 -0
  46. package/src/utils/rbac-blast-radius.test.ts +48 -3
  47. package/src/utils/rbac-blast-radius.ts +14 -4
  48. package/tsconfig.json +1 -0
@@ -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
@@ -267,26 +268,39 @@ export function getPodProblems(pod: any): PodProblem[] {
267
268
  const initContainerStatuses = pod.status?.initContainerStatuses || []
268
269
  const conditions = pod.status?.conditions || []
269
270
  const phase = pod.status?.phase
271
+ const podStatusMessage = pod.status?.message || undefined
272
+ const hasPodIP = Boolean(pod.status?.podIP || pod.status?.podIPs?.some((ip: any) => ip?.ip))
273
+ const hasScheduledNode = Boolean(pod.spec?.nodeName)
274
+ const hasUnschedulableCondition = conditions.some((cond: any) => cond.type === 'PodScheduled' && cond.status === 'False')
275
+ const hasContainerCreating = containerStatuses.some((cs: any) => cs.state?.waiting?.reason === 'ContainerCreating')
276
+ const createdAtMs = pod.metadata?.creationTimestamp ? new Date(pod.metadata.creationTimestamp).getTime() : NaN
277
+ const podAgeMs = Number.isFinite(createdAtMs) ? Date.now() - createdAtMs : 0
278
+ const sandboxStartupStallAgeMs = 10 * 60 * 1000
279
+ const sandboxStartupStallCriticalAgeMs = 30 * 60 * 1000
280
+ const inferredSandboxStartupStall = phase === 'Pending' && hasScheduledNode && !hasUnschedulableCondition && hasContainerCreating && !hasPodIP && podAgeMs > sandboxStartupStallAgeMs
281
+ const inferredSandboxStartupStallSeverity: PodProblem['severity'] = podAgeMs >= sandboxStartupStallCriticalAgeMs ? 'critical' : 'high'
282
+ let hasSandboxStartupStallProblem = false
270
283
 
271
284
  // Failed or Unknown phase
272
285
  if (phase === 'Failed' && pod.status?.reason !== 'Evicted') {
273
- problems.push({ severity: 'critical', message: 'Failed' })
286
+ problems.push({ severity: 'critical', message: 'Failed', detail: podStatusMessage })
274
287
  } else if (phase === 'Unknown') {
275
- problems.push({ severity: 'high', message: 'Unknown' })
288
+ problems.push({ severity: 'high', message: 'Unknown', detail: podStatusMessage })
276
289
  }
277
290
 
278
291
  // Init container failures
279
292
  for (const cs of initContainerStatuses) {
280
293
  if (cs.state?.waiting?.reason && cs.state.waiting.reason !== 'PodInitializing') {
281
294
  const reason = cs.state.waiting.reason
295
+ const detail = cs.state.waiting.message || undefined
282
296
  if (['CrashLoopBackOff', 'ImagePullBackOff', 'ErrImagePull'].includes(reason)) {
283
- problems.push({ severity: 'critical', message: `Init: ${reason}` })
297
+ problems.push({ severity: 'critical', message: `Init: ${reason}`, detail })
284
298
  } else {
285
- problems.push({ severity: 'high', message: `Init: ${reason}` })
299
+ problems.push({ severity: 'high', message: `Init: ${reason}`, detail })
286
300
  }
287
301
  }
288
302
  if (cs.state?.terminated?.exitCode && cs.state.terminated.exitCode !== 0) {
289
- problems.push({ severity: 'high', message: `Init: Exit Code ${cs.state.terminated.exitCode}` })
303
+ problems.push({ severity: 'high', message: `Init: Exit Code ${cs.state.terminated.exitCode}`, detail: cs.state.terminated.message || undefined })
290
304
  }
291
305
  }
292
306
 
@@ -294,30 +308,32 @@ export function getPodProblems(pod: any): PodProblem[] {
294
308
  // Check waiting state
295
309
  if (cs.state?.waiting?.reason) {
296
310
  const reason = cs.state.waiting.reason
311
+ const detail = cs.state.waiting.message || undefined
297
312
  if (['CrashLoopBackOff', 'ImagePullBackOff', 'ErrImagePull'].includes(reason)) {
298
- problems.push({ severity: 'critical', message: reason })
313
+ problems.push({ severity: 'critical', message: reason, detail })
299
314
  } else if (reason === 'CreateContainerConfigError') {
300
- problems.push({ severity: 'critical', message: 'Config Error' })
315
+ problems.push({ severity: 'critical', message: 'Config Error', detail })
301
316
  } else if (reason === 'ContainerCannotRun') {
302
- problems.push({ severity: 'critical', message: 'Cannot Run' })
317
+ problems.push({ severity: 'critical', message: 'Cannot Run', detail })
303
318
  } else if (reason !== 'ContainerCreating' && reason !== 'PodInitializing') {
304
- problems.push({ severity: 'high', message: reason })
319
+ problems.push({ severity: 'high', message: reason, detail })
305
320
  }
306
321
  }
307
322
  // Check terminated state
308
323
  if (cs.state?.terminated?.reason === 'OOMKilled') {
309
- problems.push({ severity: 'critical', message: 'OOMKilled' })
324
+ problems.push({ severity: 'critical', message: 'OOMKilled', detail: cs.state.terminated.message || undefined })
310
325
  } else if (cs.state?.terminated?.exitCode && cs.state.terminated.exitCode !== 0) {
311
- problems.push({ severity: 'high', message: `Exit Code ${cs.state.terminated.exitCode}` })
326
+ problems.push({ severity: 'high', message: `Exit Code ${cs.state.terminated.exitCode}`, detail: cs.state.terminated.message || undefined })
312
327
  }
313
328
  // High restart count
314
329
  if (cs.restartCount > 5) {
315
330
  problems.push({ severity: 'medium', message: `${cs.restartCount} restarts` })
316
331
  }
317
332
  // Volume mount issues from last state
318
- const lastMsg = cs.lastState?.terminated?.message?.toLowerCase() || ''
333
+ const lastMsgRaw = cs.lastState?.terminated?.message || ''
334
+ const lastMsg = lastMsgRaw.toLowerCase()
319
335
  if (lastMsg.includes('failed to mount') || lastMsg.includes('failedattachvolume')) {
320
- problems.push({ severity: 'high', message: 'Volume Mount Failed' })
336
+ problems.push({ severity: 'high', message: 'Volume Mount Failed', detail: lastMsgRaw || undefined })
321
337
  }
322
338
  }
323
339
 
@@ -332,23 +348,29 @@ export function getPodProblems(pod: any): PodProblem[] {
332
348
  if (cond.type === 'ContainersReady' && cond.status === 'False') {
333
349
  const msg = (cond.message || '').toLowerCase()
334
350
  if (msg.includes('readiness')) {
335
- problems.push({ severity: 'medium', message: 'Readiness Probe Failing' })
351
+ problems.push({ severity: 'medium', message: 'Readiness Probe Failing', detail: cond.message || undefined })
336
352
  } else if (msg.includes('liveness')) {
337
- problems.push({ severity: 'high', message: 'Liveness Probe Failing' })
353
+ problems.push({ severity: 'high', message: 'Liveness Probe Failing', detail: cond.message || undefined })
338
354
  }
339
355
  }
340
356
  // IP allocation failures (subnet exhaustion)
341
357
  if (cond.type === 'PodReadyToStartContainers' && cond.status === 'False') {
342
358
  const msg = (cond.message || '').toLowerCase()
343
- if (msg.includes('failed to assign an ip') || msg.includes('pod sandbox')) {
344
- problems.push({ severity: 'critical', message: 'IP Allocation Failed' })
359
+ if (msg.includes('failed to assign an ip')) {
360
+ problems.push({ severity: 'critical', message: 'IP Allocation Failed', detail: cond.message || undefined })
361
+ } else if (msg.includes('pod sandbox')) {
362
+ problems.push({ severity: 'critical', message: 'Sandbox Startup Stalled', detail: cond.message || undefined })
363
+ hasSandboxStartupStallProblem = true
345
364
  }
346
365
  }
347
366
  }
367
+ if (inferredSandboxStartupStall && !hasSandboxStartupStallProblem) {
368
+ problems.push({ severity: inferredSandboxStartupStallSeverity, message: 'Sandbox Startup Stalled' })
369
+ }
348
370
 
349
371
  // Evicted pods
350
372
  if (phase === 'Failed' && pod.status?.reason === 'Evicted') {
351
- problems.push({ severity: 'high', message: 'Evicted' })
373
+ problems.push({ severity: 'high', message: 'Evicted', detail: podStatusMessage })
352
374
  }
353
375
 
354
376
  // Stuck terminating (zombie pod)
@@ -939,16 +961,7 @@ export function getCronJobLastRun(cj: any): string | null {
939
961
  // ============================================================================
940
962
 
941
963
  export function getHPAStatus(hpa: any): StatusBadge {
942
- const current = hpa.status?.currentReplicas || 0
943
- const desired = hpa.status?.desiredReplicas || 0
944
-
945
- if (current === desired) {
946
- return { text: 'Stable', color: healthColors.healthy, level: 'healthy' }
947
- }
948
- if (current < desired) {
949
- return { text: 'Scaling Up', color: healthColors.degraded, level: 'degraded' }
950
- }
951
- return { text: 'Scaling Down', color: healthColors.degraded, level: 'degraded' }
964
+ return hpaStatusFromState(getHPATableState(hpa), healthColors)
952
965
  }
953
966
 
954
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
+ }