@skyhook-io/k8s-ui 1.5.2 → 1.5.4

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 (52) hide show
  1. package/package.json +1 -1
  2. package/src/components/audit/AuditAlerts.tsx +6 -0
  3. package/src/components/audit/AuditFindingsTable.tsx +83 -33
  4. package/src/components/gitops/ManagedResourcesList.tsx +2 -1
  5. package/src/components/logs/JsonLogLine.tsx +10 -7
  6. package/src/components/logs/LogCore.tsx +160 -63
  7. package/src/components/logs/LogToolbarSelects.tsx +17 -6
  8. package/src/components/logs/LogsViewer.tsx +8 -7
  9. package/src/components/logs/StructuredLogLine.tsx +46 -47
  10. package/src/components/logs/WorkloadLogsViewer.tsx +44 -34
  11. package/src/components/logs/log-palette.ts +222 -0
  12. package/src/components/logs/useLogBuffer.ts +6 -1
  13. package/src/components/resources/ResourcesView.tsx +183 -52
  14. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +3 -2
  15. package/src/components/resources/renderers/CertificateRenderer.tsx +4 -3
  16. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -1
  17. package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +4 -3
  18. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +2 -1
  19. package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +2 -1
  20. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +4 -3
  21. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +3 -2
  22. package/src/components/resources/renderers/SecretRenderer.tsx +10 -4
  23. package/src/components/resources/renderers/trivy-shared.tsx +3 -2
  24. package/src/components/resources/resource-utils-istio.ts +4 -3
  25. package/src/components/resources/resource-utils.ts +10 -2
  26. package/src/components/timeline/TimelineList.tsx +2 -1
  27. package/src/components/topology/GroupNode.tsx +2 -1
  28. package/src/components/topology/TopologyGraph.tsx +2 -1
  29. package/src/components/ui/Badge.tsx +2 -1
  30. package/src/components/ui/ClusterName.tsx +95 -0
  31. package/src/components/ui/EmptyState.tsx +118 -0
  32. package/src/components/ui/FilterPill.tsx +98 -0
  33. package/src/components/ui/ForceDeleteConfirmDialog.tsx +2 -1
  34. package/src/components/ui/ResourceBar.tsx +1 -1
  35. package/src/components/ui/Tooltip.tsx +43 -0
  36. package/src/components/ui/drawer-components.tsx +11 -0
  37. package/src/components/ui/index.ts +7 -0
  38. package/src/components/ui/provider-logos/aws-dark.png +0 -0
  39. package/src/components/ui/provider-logos/aws.png +0 -0
  40. package/src/components/ui/provider-logos/azure.svg +23 -0
  41. package/src/components/ui/provider-logos/gcp.png +0 -0
  42. package/src/components/ui/status-tone.test.ts +53 -0
  43. package/src/components/ui/status-tone.tsx +104 -0
  44. package/src/components/workload/WorkloadView.tsx +3 -3
  45. package/src/theme/components.css +7 -0
  46. package/src/utils/badge-colors.ts +1 -0
  47. package/src/utils/context-name.test.ts +106 -0
  48. package/src/utils/context-name.ts +25 -6
  49. package/src/utils/index.ts +1 -0
  50. package/src/utils/navigation.ts +4 -8
  51. package/src/utils/pluralize.test.ts +98 -0
  52. package/src/utils/pluralize.ts +60 -0
@@ -9,6 +9,7 @@ import {
9
9
  getPrometheusRuleGroupCount,
10
10
  } from '../resource-utils-prometheus'
11
11
  import type { PrometheusRuleGroup, PrometheusRule, PrometheusAlertRule, PrometheusRecordingRule } from '../resource-utils-prometheus'
12
+ import { pluralize, pluralNoun } from '../../../utils/pluralize'
12
13
 
13
14
  interface PrometheusRuleRendererProps {
14
15
  data: any
@@ -135,13 +136,13 @@ function RuleGroupSection({ group, searchTerm }: { group: PrometheusRuleGroup; s
135
136
  </span>
136
137
  )}
137
138
  <span className="text-xs text-theme-text-tertiary">
138
- {searchTerm ? `${filteredRules.length}/${group.ruleCount}` : group.ruleCount} rule{group.ruleCount !== 1 ? 's' : ''}
139
+ {searchTerm ? `${filteredRules.length}/${group.ruleCount}` : group.ruleCount} {pluralNoun(group.ruleCount, 'rule')}
139
140
  </span>
140
141
  </div>
141
142
  </button>
142
143
  {!expanded && (
143
144
  <div className="text-xs text-theme-text-secondary mt-1 ml-5.5 flex gap-3">
144
- {group.alertCount > 0 && <span>{group.alertCount} alert{group.alertCount !== 1 ? 's' : ''}</span>}
145
+ {group.alertCount > 0 && <span>{pluralize(group.alertCount, 'alert')}</span>}
145
146
  {group.recordCount > 0 && <span>{group.recordCount} recording</span>}
146
147
  </div>
147
148
  )}
@@ -1,10 +1,11 @@
1
1
  import { useState, useCallback, useRef, useEffect } from 'react'
2
- import { AlertTriangle, Copy, Check, Shield, Pencil, Save, XCircle, RefreshCw } from 'lucide-react'
2
+ import { AlertTriangle, Copy, Check, Shield, Pencil, Save, XCircle, RefreshCw, Eye, EyeOff } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import { stringify as yamlStringify } from 'yaml'
5
5
  import { Section, PropertyList, Property, AlertBanner } from '../../ui/drawer-components'
6
6
  import { ConfirmDialog } from '../../ui/ConfirmDialog'
7
7
  import type { SecretCertificateInfo, CertificateInfo } from '../../../types'
8
+ import { pluralize } from '../../../utils/pluralize'
8
9
 
9
10
  interface SecretRendererProps {
10
11
  data: any
@@ -125,7 +126,7 @@ export function SecretRenderer({ data, certificateInfo, resourceData, onSaveSecr
125
126
  {leafCert && !leafCert.expired && leafCert.daysLeft <= 7 && (
126
127
  <AlertBanner
127
128
  variant="error"
128
- title={`Certificate expires in ${leafCert.daysLeft} day${leafCert.daysLeft !== 1 ? 's' : ''}`}
129
+ title={`Certificate expires in ${pluralize(leafCert.daysLeft, 'day')}`}
129
130
  message="Check that cert-manager or your CA is renewing this certificate."
130
131
  />
131
132
  )}
@@ -133,7 +134,7 @@ export function SecretRenderer({ data, certificateInfo, resourceData, onSaveSecr
133
134
  {leafCert && !leafCert.expired && leafCert.daysLeft > 7 && leafCert.daysLeft <= 30 && (
134
135
  <AlertBanner
135
136
  variant="warning"
136
- title={`Certificate expires in ${leafCert.daysLeft} day${leafCert.daysLeft !== 1 ? 's' : ''}`}
137
+ title={`Certificate expires in ${pluralize(leafCert.daysLeft, 'day')}`}
137
138
  message="Renewal should happen automatically before expiry."
138
139
  />
139
140
  )}
@@ -190,8 +191,13 @@ export function SecretRenderer({ data, certificateInfo, resourceData, onSaveSecr
190
191
  {!isEditing && (
191
192
  <button
192
193
  onClick={() => toggleReveal(key)}
193
- className="text-xs text-theme-text-secondary hover:text-theme-text-primary px-1.5 py-0.5 rounded hover:bg-theme-elevated transition-colors"
194
+ className="inline-flex items-center gap-1 text-xs text-theme-text-secondary hover:text-theme-text-primary px-1.5 py-0.5 rounded hover:bg-theme-elevated transition-colors"
194
195
  >
196
+ {revealed.has(key) ? (
197
+ <EyeOff className="w-3.5 h-3.5" />
198
+ ) : (
199
+ <Eye className="w-3.5 h-3.5" />
200
+ )}
195
201
  {revealed.has(key) ? 'Hide' : 'Reveal'}
196
202
  </button>
197
203
  )}
@@ -4,6 +4,7 @@ import {
4
4
  VULN_SEVERITY_BAR,
5
5
  VULN_SEVERITY_TEXT,
6
6
  } from '../../../utils/badge-colors'
7
+ import { pluralNoun } from '../../../utils/pluralize'
7
8
 
8
9
  // Re-export from centralized badge-colors under the legacy names used by Trivy renderers
9
10
  export const SEVERITY_BADGE_COLORS = VULN_SEVERITY_BADGE
@@ -33,7 +34,7 @@ export function TrivyAlertBanner({ critical, high, noun }: { critical: number; h
33
34
  return (
34
35
  <AlertBanner
35
36
  variant="error"
36
- title={`${critical} critical ${noun}${critical !== 1 ? 's' : ''}`}
37
+ title={`${critical} critical ${pluralNoun(critical, noun)}`}
37
38
  message="Critical issues should be addressed immediately."
38
39
  />
39
40
  )
@@ -42,7 +43,7 @@ export function TrivyAlertBanner({ critical, high, noun }: { critical: number; h
42
43
  return (
43
44
  <AlertBanner
44
45
  variant="warning"
45
- title={`${high} high-severity ${noun}${high !== 1 ? 's' : ''}`}
46
+ title={`${high} high-severity ${pluralNoun(high, noun)}`}
46
47
  message="Consider addressing these issues to improve security posture."
47
48
  />
48
49
  )
@@ -2,6 +2,7 @@
2
2
 
3
3
  import type { StatusBadge } from './resource-utils'
4
4
  import { healthColors } from './resource-utils'
5
+ import { pluralize } from '../../utils/pluralize'
5
6
 
6
7
  // ============================================================================
7
8
  // SHARED HELPERS
@@ -134,7 +135,7 @@ export function getDestinationRuleStatus(resource: any): StatusBadge {
134
135
  const trafficPolicy = spec.trafficPolicy
135
136
 
136
137
  if (subsets.length > 0) {
137
- return { text: `${subsets.length} Subset${subsets.length !== 1 ? 's' : ''}`, color: healthColors.healthy, level: 'healthy' }
138
+ return { text: pluralize(subsets.length, 'Subset'), color: healthColors.healthy, level: 'healthy' }
138
139
  }
139
140
 
140
141
  if (trafficPolicy) {
@@ -332,9 +333,9 @@ export function getAuthorizationPolicyStatus(resource: any): StatusBadge {
332
333
 
333
334
  switch (action) {
334
335
  case 'ALLOW':
335
- return { text: `Allow (${rules.length} rule${rules.length !== 1 ? 's' : ''})`, color: healthColors.healthy, level: 'healthy' }
336
+ return { text: `Allow (${pluralize(rules.length, 'rule')})`, color: healthColors.healthy, level: 'healthy' }
336
337
  case 'DENY':
337
- return { text: `Deny (${rules.length} rule${rules.length !== 1 ? 's' : ''})`, color: healthColors.unhealthy, level: 'unhealthy' }
338
+ return { text: `Deny (${pluralize(rules.length, 'rule')})`, color: healthColors.unhealthy, level: 'unhealthy' }
338
339
  case 'CUSTOM':
339
340
  return { text: 'Custom', color: healthColors.degraded, level: 'degraded' }
340
341
  case 'AUDIT':
@@ -1,6 +1,7 @@
1
1
  // Utility functions for resource display in tables
2
2
 
3
3
  import { formatCPUString, formatMemoryString, formatBytes } from '../../utils/format'
4
+ import { pluralize } from '../../utils/pluralize'
4
5
 
5
6
  // Import functions from sub-modules used internally by getCellFilterValue
6
7
  import { getCertificateStatus, getCertificateRequestStatus, getClusterIssuerStatus, getClusterIssuerType, getOrderState, getChallengeState, getChallengeType } from './resource-utils-certmanager'
@@ -16,7 +17,13 @@ import { getExternalSecretStatus as _getExternalSecretStatus, getClusterExternal
16
17
  // STATUS & HEALTH UTILITIES
17
18
  // ============================================================================
18
19
 
19
- export type HealthLevel = 'healthy' | 'degraded' | 'unhealthy' | 'unknown' | 'neutral'
20
+ // Six health levels in escalating urgency order:
21
+ // healthy < neutral < unknown < degraded < alert < unhealthy
22
+ // `alert` (orange) is the intermediate tier between degraded (amber) and
23
+ // unhealthy (red). Used for severity gradients like Problems/Audit
24
+ // (critical/high/medium → unhealthy/alert/degraded), where collapsing
25
+ // `high` into either neighbor erases real signal.
26
+ export type HealthLevel = 'healthy' | 'degraded' | 'alert' | 'unhealthy' | 'unknown' | 'neutral'
20
27
 
21
28
  export interface StatusBadge {
22
29
  text: string
@@ -28,6 +35,7 @@ export interface StatusBadge {
28
35
  export const healthColors: Record<HealthLevel, string> = {
29
36
  healthy: 'status-healthy',
30
37
  degraded: 'status-degraded',
38
+ alert: 'status-alert',
31
39
  unhealthy: 'status-unhealthy',
32
40
  unknown: 'status-unknown',
33
41
  neutral: 'status-neutral',
@@ -904,7 +912,7 @@ export function getNodeTaints(node: any): { count: number; text: string } {
904
912
  const taints = node.spec?.taints || []
905
913
  const count = taints.length
906
914
  if (count === 0) return { count: 0, text: 'None' }
907
- return { count, text: count === 1 ? '1 taint' : `${count} taints` }
915
+ return { count, text: pluralize(count, 'taint') }
908
916
  }
909
917
 
910
918
  export function getNodeVersion(node: any): string {
@@ -22,6 +22,7 @@ import { getOperationColor, getHealthBadgeColor, SEVERITY_BADGE } from '../../ut
22
22
  import { ResourceRefBadge } from '../ui/drawer-components'
23
23
  import type { NavigateToResource } from '../../utils/navigation'
24
24
  import { kindToPlural, refToSelectedResource } from '../../utils/navigation'
25
+ import { pluralize } from '../../utils/pluralize'
25
26
  import { useRegisterShortcut } from '../../hooks/useKeyboardShortcuts'
26
27
 
27
28
  /** Format resource age (e.g., "3d", "5h", "10m") */
@@ -436,7 +437,7 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
436
437
  <Clock className="w-4 h-4 text-theme-text-tertiary" />
437
438
  <span className="text-sm font-medium text-theme-text-secondary">{group.label}</span>
438
439
  <span className="text-xs text-theme-text-disabled">
439
- ({group.items.length} item{group.items.length !== 1 ? 's' : ''})
440
+ ({pluralize(group.items.length, 'item')})
440
441
  </span>
441
442
  </div>
442
443
 
@@ -5,6 +5,7 @@ import { healthToSeverity, SEVERITY_DOT } from '../../utils/badge-colors'
5
5
  import type { HealthStatus } from '../../types'
6
6
  import { Tooltip } from '../ui/Tooltip'
7
7
  import type { WorkloadCard, GroupDisplayLevel } from './layout'
8
+ import { pluralize } from '../../utils/pluralize'
8
9
 
9
10
  interface GroupNodeData {
10
11
  type: 'namespace' | 'app' | 'label'
@@ -195,7 +196,7 @@ export const GroupNode = memo(function GroupNode({
195
196
  {kindPills.map(([kind, count]) => (
196
197
  <div key={kind} className="flex items-center gap-1 bg-theme-surface/50 rounded px-1.5 py-0.5">
197
198
  <span className={`topology-icon topology-icon-${kind.toLowerCase()}`} style={{ width: 10, height: 10, fontSize: 6, borderRadius: 2 }} />
198
- <span className="text-[10px] text-theme-text-secondary">{count} {kind}{count > 1 ? 's' : ''}</span>
199
+ <span className="text-[10px] text-theme-text-secondary">{pluralize(count, kind)}</span>
199
200
  </div>
200
201
  ))}
201
202
  {kindCounts && Object.keys(kindCounts).length > maxPills && (
@@ -29,6 +29,7 @@ import { K8sResourceNode } from './K8sResourceNode'
29
29
  import { GroupNode } from './GroupNode'
30
30
  import { buildHierarchicalElkGraph, applyHierarchicalLayout, getGroupKey, type GroupDisplayLevel } from './layout'
31
31
  import type { Topology, TopologyNode, TopologyEdge, ViewMode, GroupingMode } from '../../types'
32
+ import { pluralize } from '../../utils/pluralize'
32
33
 
33
34
  // Edge colors by type
34
35
  const EDGE_COLORS = {
@@ -742,7 +743,7 @@ export function TopologyGraph({
742
743
  </span>
743
744
  <span className="text-theme-text-secondary ml-1">
744
745
  {isAllRbac
745
- ? `${rbacWarnings.length} resource type${rbacWarnings.length > 1 ? 's' : ''} not accessible due to RBAC restrictions.`
746
+ ? `${pluralize(rbacWarnings.length, 'resource type')} not accessible due to RBAC restrictions.`
746
747
  : 'Some resources failed to load. Data may be incomplete.'}
747
748
  </span>
748
749
  <details className="mt-1">
@@ -6,7 +6,7 @@ import type { ReactNode } from 'react'
6
6
  // All class names are STATIC STRINGS (required for Tailwind content scanning)
7
7
  // =============================================================================
8
8
 
9
- export type BadgeSeverity = 'success' | 'warning' | 'error' | 'info' | 'neutral'
9
+ export type BadgeSeverity = 'success' | 'warning' | 'alert' | 'error' | 'info' | 'neutral'
10
10
  export type BadgeSize = 'sm' | 'default'
11
11
 
12
12
  interface BadgeProps {
@@ -33,6 +33,7 @@ interface BadgeProps {
33
33
  const SEVERITY: Record<BadgeSeverity, string> = {
34
34
  success: 'bg-emerald-100 text-emerald-700 border-emerald-300 dark:bg-emerald-950/50 dark:text-emerald-400 dark:border-emerald-700/40',
35
35
  warning: 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/50 dark:text-amber-400 dark:border-amber-700/40',
36
+ alert: 'bg-orange-100 text-orange-800 border-orange-300 dark:bg-orange-950/50 dark:text-orange-400 dark:border-orange-700/40',
36
37
  error: 'bg-red-100 text-red-700 border-red-300 dark:bg-red-950/50 dark:text-red-400 dark:border-red-700/40',
37
38
  info: 'bg-sky-100 text-sky-700 border-sky-300 dark:bg-sky-950/50 dark:text-sky-400 dark:border-sky-700/40',
38
39
  neutral: 'bg-theme-hover/50 text-theme-text-secondary border-theme-border',
@@ -0,0 +1,95 @@
1
+ import { Tooltip } from './Tooltip'
2
+ import { parseContextName } from '../../utils/context-name'
3
+ import type { ParsedContextName } from '../../utils/context-name'
4
+ import awsLogo from './provider-logos/aws.png'
5
+ import awsLogoDark from './provider-logos/aws-dark.png'
6
+ import gcpLogo from './provider-logos/gcp.png'
7
+ import azureLogo from './provider-logos/azure.svg'
8
+
9
+ // ClusterName renders a kubectl context string with the meaningful
10
+ // cluster identity surfaced as primary text and provider/region pushed
11
+ // into supporting metadata. Wraps parseContextName from utils/context-name
12
+ // so all surfaces (cluster cards, table cells, column headers, switcher
13
+ // dropdowns, breadcrumb, error views) share identical cluster-identity
14
+ // rendering.
15
+ //
16
+ // Variants:
17
+ // inline — name + small provider logo, fits in a table cell or
18
+ // column header
19
+ // stacked — name on top, provider/region on a smaller second line,
20
+ // for card-sized surfaces
21
+ //
22
+ // User-named clusters that don't match a known shape pass through
23
+ // unchanged — no provider badge, no tooltip needed.
24
+
25
+ type Provider = NonNullable<ParsedContextName['provider']>
26
+
27
+ // AWS uses the official aws+smile mark, which has dark navy text — needs
28
+ // a white-text variant on dark backgrounds. GCP (4-color cloud) and
29
+ // Azure (blue prism A) read fine on either theme.
30
+ const PROVIDER_LOGOS: Record<Provider, { light: string; dark?: string }> = {
31
+ GKE: { light: gcpLogo },
32
+ EKS: { light: awsLogo, dark: awsLogoDark },
33
+ AKS: { light: azureLogo },
34
+ }
35
+
36
+ interface Props {
37
+ /** Raw context / display string, as stored in the cluster record. */
38
+ name: string
39
+ /** Visual shape. Default: inline. */
40
+ variant?: 'inline' | 'stacked'
41
+ /** Suppress the provider badge — use when context already conveys provider. */
42
+ noBadge?: boolean
43
+ /** Optional className on the outer span. */
44
+ className?: string
45
+ }
46
+
47
+ function ProviderBadge({ provider }: { provider: Provider }) {
48
+ const logos = PROVIDER_LOGOS[provider]
49
+ // object-contain keeps the AWS aws+smile mark from being warped when
50
+ // forced into a square box. GCP and Azure are square already.
51
+ const baseClass = 'h-4 w-4 flex-shrink-0 object-contain'
52
+ if (!logos.dark) {
53
+ return <img src={logos.light} alt={`${provider} cluster`} className={baseClass} />
54
+ }
55
+ return (
56
+ <>
57
+ <img src={logos.light} alt={`${provider} cluster`} className={`${baseClass} dark:hidden`} />
58
+ <img src={logos.dark} alt={`${provider} cluster`} className={`${baseClass} hidden dark:block`} />
59
+ </>
60
+ )
61
+ }
62
+
63
+ export function ClusterName({ name, variant = 'inline', noBadge, className }: Props) {
64
+ const parsed = parseContextName(name)
65
+
66
+ const showBadge = !noBadge && parsed.provider !== null
67
+ const showRegion = parsed.region !== null && variant === 'stacked'
68
+ const needsTooltip = parsed.raw !== parsed.clusterName
69
+
70
+ const body = (
71
+ <span className={['inline-flex items-center gap-1.5 min-w-0', className ?? ''].join(' ')}>
72
+ {showBadge && <ProviderBadge provider={parsed.provider!} />}
73
+ {variant === 'stacked' ? (
74
+ <span className="flex flex-col min-w-0">
75
+ <span className="truncate">{parsed.clusterName}</span>
76
+ {showRegion && (
77
+ <span className="text-[10px] text-theme-text-tertiary truncate">
78
+ {parsed.provider} · {parsed.region}
79
+ </span>
80
+ )}
81
+ </span>
82
+ ) : (
83
+ <span className="truncate">{parsed.clusterName}</span>
84
+ )}
85
+ </span>
86
+ )
87
+
88
+ if (!needsTooltip) return body
89
+
90
+ return (
91
+ <Tooltip content={parsed.raw} delay={250}>
92
+ {body}
93
+ </Tooltip>
94
+ )
95
+ }
@@ -0,0 +1,118 @@
1
+ import type { ReactNode } from 'react'
2
+ import type { LucideIcon } from 'lucide-react'
3
+ import { clsx } from 'clsx'
4
+
5
+ // EmptyState is the shared component for the three distinct kinds of
6
+ // "nothing here" UI:
7
+ //
8
+ // tone='healthy' — emerald, reassuring. "All checks passing".
9
+ // Communicates "things are good" not "no data".
10
+ // tone='filtered' — neutral. "No findings match the current filters."
11
+ // Communicates "your filter is too narrow", suggests
12
+ // widening.
13
+ // tone='neutral' — pre-data / setup. "No clusters connected yet" with
14
+ // an action CTA.
15
+ //
16
+ // Variants:
17
+ // variant='card' — centered icon + headline + body in a bordered
18
+ // box. Use when an entire panel/page section is
19
+ // empty.
20
+ // variant='inline' — single-line callout. Use when one tile in a row
21
+ // is "empty" and you want to collapse zeros into
22
+ // one positive line instead.
23
+
24
+ export type EmptyStateTone = 'healthy' | 'filtered' | 'neutral'
25
+ export type EmptyStateVariant = 'card' | 'inline'
26
+
27
+ interface Props {
28
+ tone?: EmptyStateTone
29
+ variant?: EmptyStateVariant
30
+ /** Optional Lucide icon. When omitted: the inline variant falls back
31
+ * to a small colored dot (emerald for healthy, tertiary text for the
32
+ * rest) so the line keeps a visual anchor; the card variant renders
33
+ * no leading visual. Callers pass icons that match their context
34
+ * (e.g. CheckCircle2 for a healthy state). */
35
+ icon?: LucideIcon
36
+ headline: string
37
+ body?: ReactNode
38
+ /** Optional CTA — usually an <a> or <button>. Card variant only. */
39
+ action?: ReactNode
40
+ className?: string
41
+ }
42
+
43
+ const TONE_CLASSES: Record<EmptyStateTone, { card: string; inline: string; icon: string }> = {
44
+ healthy: {
45
+ card: 'border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200',
46
+ inline: 'border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200',
47
+ icon: 'text-emerald-600 dark:text-emerald-400',
48
+ },
49
+ filtered: {
50
+ card: 'border-theme-border bg-theme-surface text-theme-text-secondary',
51
+ inline: 'border-theme-border bg-theme-surface text-theme-text-secondary',
52
+ icon: 'text-theme-text-tertiary',
53
+ },
54
+ neutral: {
55
+ card: 'border-theme-border bg-theme-surface text-theme-text-secondary',
56
+ inline: 'border-theme-border bg-theme-surface text-theme-text-secondary',
57
+ icon: 'text-theme-text-tertiary',
58
+ },
59
+ }
60
+
61
+ export function EmptyState({
62
+ tone = 'neutral',
63
+ variant = 'card',
64
+ icon: Icon,
65
+ headline,
66
+ body,
67
+ action,
68
+ className,
69
+ }: Props) {
70
+ const t = TONE_CLASSES[tone]
71
+
72
+ if (variant === 'inline') {
73
+ return (
74
+ <div
75
+ className={clsx(
76
+ 'flex flex-1 min-w-[200px] items-center gap-2 rounded-md border px-3 py-2 text-sm',
77
+ t.inline,
78
+ className,
79
+ )}
80
+ >
81
+ {Icon ? (
82
+ <Icon className={clsx('h-4 w-4 flex-shrink-0', t.icon)} aria-hidden />
83
+ ) : (
84
+ // Default to a small dot for the inline variant — keeps a visual
85
+ // anchor without forcing every caller to pick an icon.
86
+ <span
87
+ className={clsx(
88
+ 'inline-flex h-2 w-2 flex-shrink-0 rounded-full',
89
+ tone === 'healthy'
90
+ ? 'bg-emerald-500 dark:bg-emerald-400'
91
+ : 'bg-theme-text-tertiary',
92
+ )}
93
+ aria-hidden
94
+ />
95
+ )}
96
+ <span>
97
+ {headline}
98
+ {body && <span className="opacity-80"> · {body}</span>}
99
+ </span>
100
+ </div>
101
+ )
102
+ }
103
+
104
+ return (
105
+ <div
106
+ className={clsx(
107
+ 'flex flex-col items-center gap-2 rounded-md border px-4 py-8 text-center',
108
+ t.card,
109
+ className,
110
+ )}
111
+ >
112
+ {Icon && <Icon className={clsx('h-6 w-6', t.icon)} aria-hidden />}
113
+ <div className="text-sm font-medium">{headline}</div>
114
+ {body && <div className="max-w-md text-xs opacity-80">{body}</div>}
115
+ {action && <div className="mt-2">{action}</div>}
116
+ </div>
117
+ )
118
+ }
@@ -0,0 +1,98 @@
1
+ import type { ReactNode } from 'react'
2
+ import type { LucideIcon } from 'lucide-react'
3
+ import { clsx } from 'clsx'
4
+ import { Tooltip } from './Tooltip'
5
+
6
+ // FilterPill is a single-button toggle filter — clickable pill that
7
+ // communicates an active/inactive state. Used in horizontal filter rows
8
+ // where each pill toggles one filter on/off (no dropdown — this is the
9
+ // toggle pattern, not a combobox).
10
+ //
11
+ // Tone-encoded active state: when tone='danger' and active=true, the
12
+ // pill bg+text use rose; tone='warn' uses amber; etc. This is a
13
+ // filter-UI-scoped vocabulary (neutral/danger/warn/ok/brand) — distinct
14
+ // from the canonical HealthLevel vocabulary (healthy/degraded/alert/
15
+ // unhealthy/neutral/unknown), since "an active filter for danger
16
+ // problems" reads better as `tone='danger'` than `tone='unhealthy'`.
17
+ // Useful for filter rows that mix severity-bearing categories with
18
+ // neutral ones (Critical filters and Warning filters get visually
19
+ // distinct active states).
20
+ //
21
+ // Accessibility: every pill renders aria-pressed automatically, so
22
+ // screen readers announce pressed/unpressed correctly. Optional tooltip
23
+ // describes the toggle action ("Click to stop filtering by danger").
24
+
25
+ export type FilterPillTone = 'neutral' | 'danger' | 'warn' | 'ok' | 'brand'
26
+
27
+ interface Props {
28
+ label: ReactNode
29
+ active: boolean
30
+ onClick: () => void
31
+ /** Active-state color encoding. Default: neutral (Radar's existing style). */
32
+ tone?: FilterPillTone
33
+ /** Optional leading icon. */
34
+ icon?: LucideIcon
35
+ /** Optional count badge — renders " (N)" after label. */
36
+ count?: number
37
+ /** Tooltip explaining the toggle. Wraps button in Tooltip if set. */
38
+ tooltip?: string
39
+ /** Override the accessible name. Defaults to label + active state. */
40
+ 'aria-label'?: string
41
+ className?: string
42
+ }
43
+
44
+ // Always-bordered chip: same border-width in both states keeps geometry stable
45
+ // when toggling, and a visible inactive border is what makes pills read as
46
+ // pressable chips instead of plain links. Active states fill the chip and
47
+ // promote the border to a tone-matched ring.
48
+ const TONE_ACTIVE: Record<FilterPillTone, string> = {
49
+ neutral: 'bg-theme-text-primary/10 border-theme-text-primary/25 text-theme-text-primary',
50
+ danger: 'bg-red-500/15 border-red-500/40 text-red-700 dark:text-red-300',
51
+ warn: 'bg-amber-500/15 border-amber-500/40 text-amber-800 dark:text-amber-300',
52
+ ok: 'bg-emerald-500/15 border-emerald-500/40 text-emerald-700 dark:text-emerald-300',
53
+ brand: 'bg-[var(--color-brand-50)] border-[var(--color-radar-accent)] text-theme-text-primary dark:bg-[var(--color-brand-950)]',
54
+ }
55
+
56
+ const INACTIVE = 'border-theme-border-light text-theme-text-secondary hover:border-theme-border hover:text-theme-text-primary hover:bg-theme-hover/50'
57
+
58
+ export function FilterPill({
59
+ label,
60
+ active,
61
+ onClick,
62
+ tone = 'neutral',
63
+ icon: Icon,
64
+ count,
65
+ tooltip,
66
+ className,
67
+ ...rest
68
+ }: Props) {
69
+ const ariaLabel = rest['aria-label']
70
+
71
+ const button = (
72
+ <button
73
+ type="button"
74
+ onClick={onClick}
75
+ aria-pressed={active}
76
+ aria-label={ariaLabel}
77
+ className={clsx(
78
+ 'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium transition-colors',
79
+ 'focus-visible:ring-2 focus-visible:ring-theme-text-primary/20 focus-visible:outline-none',
80
+ active ? TONE_ACTIVE[tone] : INACTIVE,
81
+ className,
82
+ )}
83
+ >
84
+ {Icon && <Icon className="h-3.5 w-3.5" aria-hidden />}
85
+ <span>{label}</span>
86
+ {count !== undefined && (
87
+ <span className="text-theme-text-tertiary">({count})</span>
88
+ )}
89
+ </button>
90
+ )
91
+
92
+ if (!tooltip) return button
93
+ return (
94
+ <Tooltip content={tooltip} delay={200}>
95
+ {button}
96
+ </Tooltip>
97
+ )
98
+ }
@@ -2,6 +2,7 @@ import { useState, useMemo } from 'react'
2
2
  import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react'
3
3
  import { ConfirmDialog } from './ConfirmDialog'
4
4
  import { formatKindName } from './drawer-components'
5
+ import { pluralize } from '../../utils/pluralize'
5
6
 
6
7
  export interface CascadeDependent {
7
8
  kind: string
@@ -106,7 +107,7 @@ function CascadeDependentsList({ dependents }: { dependents: CascadeDependent[]
106
107
  >
107
108
  {expanded ? <ChevronDown className="w-3.5 h-3.5 shrink-0" /> : <ChevronRight className="w-3.5 h-3.5 shrink-0" />}
108
109
  <span>
109
- Will also delete {dependents.length} dependent {dependents.length === 1 ? 'resource' : 'resources'}
110
+ Will also delete {pluralize(dependents.length, 'dependent resource')}
110
111
  </span>
111
112
  </button>
112
113
 
@@ -40,7 +40,7 @@ export function ResourceBar({ used, total, percent, colorScheme = 'utilization',
40
40
  </span>
41
41
  </div>
42
42
  <div className="relative">
43
- <div className="h-1.5 rounded-full border border-theme-border bg-theme-bg-elevated overflow-hidden">
43
+ <div className="h-1.5 rounded-full border border-theme-border bg-theme-elevated overflow-hidden">
44
44
  <div
45
45
  className={clsx('h-full rounded-full transition-[width] duration-300 ease-out', getBarColor(percent, colorScheme))}
46
46
  style={{ width: `${Math.min(percent, 100)}%` }}
@@ -2,6 +2,21 @@ import { ReactNode, useState, useRef, useEffect, useCallback } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
3
  import { clsx } from 'clsx'
4
4
 
5
+ // Module-level singleton coordinator: only one Tooltip can be visible
6
+ // at a time across the whole app. Without this, two Tooltip instances
7
+ // could both render their portal simultaneously — happens when a
8
+ // trigger element unmounts/remounts during an in-progress hover (React
9
+ // re-render, HMR, rapid cursor movement between adjacent triggers),
10
+ // because the old trigger's mouseleave never fires so its visible
11
+ // state stays stuck. Observed in multi-cluster visual tests on
12
+ // densely-populated source-chip hovers.
13
+ //
14
+ // Each visible Tooltip registers a `hide` callback. When the next
15
+ // Tooltip becomes visible, it calls the previous active tooltip's
16
+ // hide(), guaranteeing a single visible portal. Registry clears on
17
+ // hide or unmount.
18
+ let activeHide: (() => void) | null = null
19
+
5
20
  interface TooltipProps {
6
21
  content: ReactNode
7
22
  children: ReactNode
@@ -79,9 +94,27 @@ export function Tooltip({
79
94
  setCoords({ top, left })
80
95
  }, [position])
81
96
 
97
+ // Stable hide function for the singleton registry — useRef so the
98
+ // identity stays the same across renders, otherwise the registry
99
+ // could hold a stale closure that doesn't see the latest setState.
100
+ const hideRef = useRef<() => void>(() => {})
101
+ hideRef.current = () => {
102
+ if (timeoutRef.current) {
103
+ clearTimeout(timeoutRef.current)
104
+ timeoutRef.current = null
105
+ }
106
+ setIsVisible(false)
107
+ }
108
+
82
109
  const showTooltip = () => {
83
110
  if (disabled || !content) return
84
111
  timeoutRef.current = window.setTimeout(() => {
112
+ // Singleton: hide whoever was visible before us, register self
113
+ // as the new active tooltip. Guards against stuck duplicates.
114
+ if (activeHide && activeHide !== hideRef.current) {
115
+ activeHide()
116
+ }
117
+ activeHide = hideRef.current
85
118
  setIsVisible(true)
86
119
  }, delay)
87
120
  }
@@ -91,6 +124,9 @@ export function Tooltip({
91
124
  clearTimeout(timeoutRef.current)
92
125
  timeoutRef.current = null
93
126
  }
127
+ if (activeHide === hideRef.current) {
128
+ activeHide = null
129
+ }
94
130
  setIsVisible(false)
95
131
  }
96
132
 
@@ -106,6 +142,13 @@ export function Tooltip({
106
142
  if (timeoutRef.current) {
107
143
  clearTimeout(timeoutRef.current)
108
144
  }
145
+ // Clear from singleton registry on unmount — otherwise a Tooltip
146
+ // that unmounts while visible (e.g. row removed during hover)
147
+ // would leave a stale entry in activeHide pointing at a torn-down
148
+ // setState, blocking the next Tooltip from registering.
149
+ if (activeHide === hideRef.current) {
150
+ activeHide = null
151
+ }
109
152
  }
110
153
  }, [])
111
154