@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
@@ -122,6 +122,7 @@ import {
122
122
  SEVERITY_DOT_COLOR,
123
123
  } from './resource-utils'
124
124
  import { SEVERITY_BADGE, EVENT_TYPE_COLORS } from '../../utils/badge-colors'
125
+ import { pluralize } from '../../utils/pluralize'
125
126
  import { Tooltip } from '../ui/Tooltip'
126
127
  // CRD-specific cell components (extracted)
127
128
  import { GitRepositoryCell, OCIRepositoryCell, HelmRepositoryCell, KustomizationCell, FluxHelmReleaseCell, FluxAlertCell } from './renderers/flux-cells'
@@ -179,6 +180,31 @@ interface Column {
179
180
  minWidth?: number // minimum width in px
180
181
  }
181
182
 
183
+ /**
184
+ * Extra column injected by the parent — for example, a leading "Cluster"
185
+ * column when the table is rendered inside a multi-cluster host.
186
+ * Self-contained: carries its own render/sort/filter functions so the
187
+ * host code doesn't need to extend KNOWN_COLUMNS or the per-kind cell
188
+ * renderers.
189
+ *
190
+ * When extraLeadingColumns is undefined or empty (the standard
191
+ * single-cluster path), the rest of ResourcesView's behavior is
192
+ * byte-identical to today.
193
+ *
194
+ * Keys must NOT collide with existing KNOWN_COLUMNS keys (name,
195
+ * namespace, age, status, ready, etc.) — collisions silently bypass
196
+ * the extra and fall through to the built-in cell.
197
+ */
198
+ export interface ExtraColumn extends Column {
199
+ /** Cell content for this column. Receives the resource row. */
200
+ render: (resource: any) => React.ReactNode
201
+ /** Sort key extractor. Falls back to localeCompare on render output. */
202
+ getSortValue?: (resource: any) => string | number
203
+ /** Filter value extractor used by the column-filter dropdown's
204
+ * unique-values pull. Falls back to row not being filterable. */
205
+ getFilterValue?: (resource: any) => string
206
+ }
207
+
182
208
  // Tailwind width class → pixel minimum mapping for CSS Grid column sizing
183
209
  const TAILWIND_WIDTH_TO_PX: Record<string, number> = {
184
210
  'w-12': 48, 'w-14': 56, 'w-16': 64, 'w-20': 80, 'w-24': 96,
@@ -364,7 +390,7 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
364
390
  { key: 'name', label: 'Name' },
365
391
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
366
392
  { key: 'status', label: 'Ready', width: 'w-24' },
367
- { key: 'domains', label: 'Domains', width: 'w-48' },
393
+ { key: 'domains', label: 'Domains', width: 'w-56' },
368
394
  { key: 'issuer', label: 'Issuer', width: 'w-36', hideOnMobile: true },
369
395
  { key: 'expires', label: 'Expires', width: 'w-24', tooltip: 'Days until certificate expires' },
370
396
  { key: 'age', label: 'Age', width: 'w-24' },
@@ -1628,21 +1654,54 @@ interface ResourcesViewProps {
1628
1654
  hideSidebar?: boolean
1629
1655
  /** Callback when the [+] create button is clicked. Receives the currently selected kind info. */
1630
1656
  onCreateResource?: (kind: { name: string; kind: string; group: string } | null) => void
1657
+ /** Columns prepended to KNOWN_COLUMNS for every kind. For example, a
1658
+ * multi-cluster host can inject a leading Cluster column. Each extra
1659
+ * column is self-contained (own render/sort/filter), so the host
1660
+ * doesn't need to extend KNOWN_COLUMNS or per-kind cell renderers.
1661
+ * When undefined, behavior is byte-identical to single-cluster mode. */
1662
+ extraLeadingColumns?: ExtraColumn[]
1663
+ /** Escape hatch for full-page-nav row selection: receive the FULL
1664
+ * resource object on row click / Enter / `d` / search-Enter, instead
1665
+ * of the stripped {kind, namespace, name, group} shape onResourceClick
1666
+ * gets. Lets the parent read injected fields (e.g. multi-cluster
1667
+ * row-level metadata for cross-tree drill-in nav) without a parallel
1668
+ * (kind, ns, name) → owner lookup that wouldn't dedup for resources
1669
+ * sharing a namespaced name across cluster boundaries. When set,
1670
+ * fires INSTEAD OF onResourceClick on those selection paths.
1671
+ *
1672
+ * Not honored on: `y` (open YAML — routes through onResourceClickYaml)
1673
+ * and `l` (open logs — different intent). URL deep-link hydration on
1674
+ * mount (`?resource=ns/name`) only has stripped params and always
1675
+ * calls onResourceClick — hosts using onRowSelect for full-page nav
1676
+ * should also wire onResourceClick to handle the deep-link-on-load
1677
+ * case. */
1678
+ onRowSelect?: (resource: any) => void
1631
1679
  }
1632
1680
 
1633
1681
  // Default selected kind
1634
1682
  const DEFAULT_KIND_INFO: SelectedKindInfo = { name: 'pods', kind: 'Pod', group: '' }
1635
1683
 
1636
1684
  // Read initial state from URL — kind is in the path: {basePath}/{kind}
1637
- function getInitialKindFromURL(basePath: string = '/resources'): SelectedKindInfo {
1638
- // Read kind from URL path segment after basePath: {basePath}/{kind}
1639
- const pathname = window.location.pathname
1685
+ //
1686
+ // Hosts that own their own URL shape (an embedding host with custom
1687
+ // routes outside the `{basePath}/{kind}` convention) pass a synthetic
1688
+ // pathname/search via the locationPathname/locationSearch props. When
1689
+ // those are provided, prefer them over window.location — otherwise the
1690
+ // host's URL wouldn't resolve against the synthetic basePath and we'd
1691
+ // fall through to DEFAULT_KIND.
1692
+ function getInitialKindFromURL(
1693
+ basePath: string = '/resources',
1694
+ locationPathname?: string,
1695
+ locationSearch?: string,
1696
+ ): SelectedKindInfo {
1697
+ const pathname = locationPathname || window.location.pathname
1698
+ const search = locationSearch || window.location.search
1640
1699
  const base = basePath.replace(/\/$/, '') // strip trailing slash
1641
1700
  let kind: string | null = null
1642
1701
  if (pathname.startsWith(base + '/')) {
1643
1702
  kind = pathname.slice(base.length + 1).split('/')[0] || null
1644
1703
  }
1645
- const group = new URLSearchParams(window.location.search).get('apiGroup') || ''
1704
+ const group = new URLSearchParams(search).get('apiGroup') || ''
1646
1705
  if (kind) {
1647
1706
  // Find matching resource from CORE_RESOURCES or use as-is
1648
1707
  // Only match core resources when no apiGroup is specified (avoids collisions like KNative Service)
@@ -1700,13 +1759,15 @@ export function ResourcesView({
1700
1759
  onSelectedKindChange,
1701
1760
  hideSidebar = false,
1702
1761
  onCreateResource,
1762
+ extraLeadingColumns,
1763
+ onRowSelect,
1703
1764
  }: ResourcesViewProps) {
1704
1765
  const location = useMemo(() => ({ search: locationSearch, pathname: locationPathname }), [locationSearch, locationPathname])
1705
1766
  const initialFilters = getInitialFiltersFromURL()
1706
- const [selectedKind, setSelectedKind] = useState<SelectedKindInfo>(() => getInitialKindFromURL(basePath))
1767
+ const [selectedKind, setSelectedKind] = useState<SelectedKindInfo>(() => getInitialKindFromURL(basePath, locationPathname, locationSearch))
1707
1768
  // Sync selectedKind from URL when locationPathname changes (e.g., browser back, external sidebar navigation)
1708
1769
  useEffect(() => {
1709
- const kindFromURL = getInitialKindFromURL(basePath)
1770
+ const kindFromURL = getInitialKindFromURL(basePath, locationPathname, locationSearch)
1710
1771
  if (kindFromURL.name !== selectedKind.name || kindFromURL.group !== selectedKind.group) {
1711
1772
  setSelectedKind(kindFromURL)
1712
1773
  }
@@ -1804,8 +1865,36 @@ export function ResourcesView({
1804
1865
  return { pods, nodes }
1805
1866
  }, [topPodMetrics, topNodeMetrics])
1806
1867
 
1807
- // Load column settings from localStorage when kind changes
1808
- const allColumns = useMemo(() => getColumnsForKind(selectedKind.name, selectedKind.group), [selectedKind.name, selectedKind.group])
1868
+ // Prepend extraLeadingColumns (host-injected leading columns) before
1869
+ // the kind-specific KNOWN_COLUMNS entries. When extras are undefined,
1870
+ // this collapses to single-cluster behavior. Built-in keys win on
1871
+ // collision: a colliding extra is filtered out (with a dev-mode warn)
1872
+ // so we don't render two columns sharing one key — that would yield
1873
+ // duplicate React keys and corrupt visibleColumns / columnWidths state.
1874
+ const allColumns = useMemo(() => {
1875
+ const kindColumns = getColumnsForKind(selectedKind.name, selectedKind.group)
1876
+ if (!extraLeadingColumns?.length) return kindColumns
1877
+ const builtinKeys = new Set(kindColumns.map(c => c.key))
1878
+ const filteredExtras = extraLeadingColumns.filter(c => {
1879
+ if (builtinKeys.has(c.key)) {
1880
+ if (import.meta.env?.DEV) {
1881
+ // eslint-disable-next-line no-console
1882
+ console.warn(`[ResourcesView] extraLeadingColumns key "${c.key}" collides with a built-in column for kind "${selectedKind.name}" — extra ignored`)
1883
+ }
1884
+ return false
1885
+ }
1886
+ return true
1887
+ })
1888
+ return [...filteredExtras, ...kindColumns]
1889
+ }, [selectedKind.name, selectedKind.group, extraLeadingColumns])
1890
+
1891
+ // Map of extra column keys for fast O(1) lookup on each render path
1892
+ // (cell render, sort, column-filter unique-values).
1893
+ const extraColumnsByKey = useMemo(() => {
1894
+ const m = new Map<string, ExtraColumn>()
1895
+ extraLeadingColumns?.forEach(c => m.set(c.key, c))
1896
+ return m
1897
+ }, [extraLeadingColumns])
1809
1898
 
1810
1899
  useEffect(() => {
1811
1900
  const saved = loadColumnSettings(selectedKind.name, selectedKind.group)
@@ -1985,6 +2074,28 @@ export function ResourcesView({
1985
2074
  return highlightedResourceRef.current
1986
2075
  }, [])
1987
2076
 
2077
+ // Selection handler shared by row click, Enter / `d` shortcuts, and the
2078
+ // search-Enter path. Honors the onRowSelect escape hatch when set so
2079
+ // full-page-nav consumers stay consistent across activation paths;
2080
+ // otherwise falls through to the drawer-pattern onResourceClick with
2081
+ // the stripped {kind, namespace, name, group} shape.
2082
+ // (Distinct from `y` / `l` shortcuts, which open YAML / logs rather
2083
+ // than "select the row" — those route to their own callbacks.)
2084
+ const selectResource = useCallback((resource: any, isSelected = false) => {
2085
+ if (!resource?.metadata?.name) return
2086
+ if (onRowSelect) {
2087
+ onRowSelect(resource)
2088
+ return
2089
+ }
2090
+ const stripped = {
2091
+ kind: selectedKind.name,
2092
+ namespace: resource.metadata.namespace || '',
2093
+ name: resource.metadata.name,
2094
+ group: selectedKind.group,
2095
+ }
2096
+ onResourceClick?.(isSelected ? null : stripped)
2097
+ }, [onRowSelect, onResourceClick, selectedKind.name, selectedKind.group])
2098
+
1988
2099
  // Register navigation shortcuts
1989
2100
  useRegisterShortcuts([
1990
2101
  {
@@ -2047,11 +2158,7 @@ export function ResourcesView({
2047
2158
  description: 'Open resource detail',
2048
2159
  category: 'Resource Actions',
2049
2160
  scope: 'resources',
2050
- handler: () => {
2051
- const res = getHighlightedResource()
2052
- if (!res?.metadata?.name) return
2053
- onResourceClick?.({ kind: selectedKind.name, namespace: res.metadata.namespace || '', name: res.metadata.name, group: selectedKind.group })
2054
- },
2161
+ handler: () => selectResource(getHighlightedResource()),
2055
2162
  enabled: highlightedIndex >= 0,
2056
2163
  },
2057
2164
  {
@@ -2060,11 +2167,7 @@ export function ResourcesView({
2060
2167
  description: 'Open resource detail',
2061
2168
  category: 'Resource Actions',
2062
2169
  scope: 'resources',
2063
- handler: () => {
2064
- const res = getHighlightedResource()
2065
- if (!res?.metadata?.name) return
2066
- onResourceClick?.({ kind: selectedKind.name, namespace: res.metadata.namespace || '', name: res.metadata.name, group: selectedKind.group })
2067
- },
2170
+ handler: () => selectResource(getHighlightedResource()),
2068
2171
  enabled: highlightedIndex >= 0,
2069
2172
  },
2070
2173
  {
@@ -2221,7 +2324,7 @@ export function ResourcesView({
2221
2324
  isSyncingFromURL.current = true
2222
2325
 
2223
2326
  // Re-read URL params and update state
2224
- const newKind = getInitialKindFromURL(basePath)
2327
+ const newKind = getInitialKindFromURL(basePath, locationPathname, locationSearch)
2225
2328
  const newFilters = getInitialFiltersFromURL()
2226
2329
 
2227
2330
  // Update kind if it changed
@@ -2318,13 +2421,13 @@ export function ResourcesView({
2318
2421
 
2319
2422
  const newPath = `${basePath}/${kindInfo.name}`
2320
2423
  const queryStr = params.toString()
2321
- const newURL = queryStr ? `${newPath}?${queryStr}` : newPath
2322
2424
 
2323
- if (pushHistory) {
2324
- navigate({ pathname: newPath, search: queryStr }, { replace: false })
2325
- } else {
2326
- window.history.replaceState({}, '', newURL)
2327
- }
2425
+ // Route both push and replace through `navigate` (which honors the
2426
+ // onNavigate prop). The previous direct `window.history.replaceState`
2427
+ // bypass meant a host that wants to suppress URL writes (passing
2428
+ // `onNavigate={() => {}}`) couldn't suppress filter-change
2429
+ // updates — they'd still rewrite the address bar through history.
2430
+ navigate({ pathname: newPath, search: queryStr }, { replace: !pushHistory })
2328
2431
  }, [navigate, basePath])
2329
2432
 
2330
2433
  // Update URL when any filter changes
@@ -2373,12 +2476,13 @@ export function ResourcesView({
2373
2476
  // Signal that initial resource param has been processed — URL update effect can now run
2374
2477
  hasProcessedInitialResource.current = true
2375
2478
 
2376
- // If the URL has no kind segment (e.g., /resources), update to include the default kind
2479
+ // If the URL has no kind segment (e.g., /resources), update to include the default kind.
2480
+ // Route through `navigate` so an onNavigate-suppressing host can opt out.
2377
2481
  const base = basePath.replace(/\/$/, '')
2378
2482
  const path = window.location.pathname
2379
2483
  if (path === base || path === base + '/') {
2380
2484
  const search = window.location.search
2381
- window.history.replaceState({}, '', `${base}/${selectedKind.name}${search}`)
2485
+ navigate({ pathname: `${base}/${selectedKind.name}`, search: search.replace(/^\?/, '') }, { replace: true })
2382
2486
  }
2383
2487
  }, []) // Only on mount
2384
2488
 
@@ -2701,13 +2805,17 @@ export function ResourcesView({
2701
2805
  }
2702
2806
 
2703
2807
  // Apply column filters (generic, multi-select per column — OR within column, AND across columns)
2808
+ // Extra columns override the built-in getCellFilterValue
2809
+ // when the parent supplied a custom getFilterValue.
2704
2810
  const activeColFilters = Object.entries(columnFilters).filter(([, vals]) => vals.length > 0)
2705
2811
  if (activeColFilters.length > 0) {
2706
2812
  const kindLower = normalizeKindToPlural(selectedKind.name, selectedKind.group)
2707
2813
  result = result.filter((r: any) =>
2708
- activeColFilters.every(([col, vals]) =>
2709
- vals.includes(getCellFilterValue(r, col, kindLower))
2710
- )
2814
+ activeColFilters.every(([col, vals]) => {
2815
+ const extra = extraColumnsByKey.get(col)
2816
+ const cellVal = extra?.getFilterValue ? extra.getFilterValue(r) : getCellFilterValue(r, col, kindLower)
2817
+ return vals.includes(cellVal)
2818
+ })
2711
2819
  )
2712
2820
  }
2713
2821
 
@@ -2762,11 +2870,13 @@ export function ResourcesView({
2762
2870
  })
2763
2871
  }
2764
2872
 
2765
- // Apply custom sorting if set
2873
+ // Apply custom sorting if set. Extra columns override
2874
+ // the built-in getSortValue for their key.
2766
2875
  if (sortColumn && sortDirection) {
2876
+ const extra = extraColumnsByKey.get(sortColumn)
2767
2877
  result = [...result].sort((a: any, b: any) => {
2768
- const aVal = getSortValue(a, sortColumn, selectedKind.name)
2769
- const bVal = getSortValue(b, sortColumn, selectedKind.name)
2878
+ const aVal = extra?.getSortValue ? extra.getSortValue(a) : getSortValue(a, sortColumn, selectedKind.name)
2879
+ const bVal = extra?.getSortValue ? extra.getSortValue(b) : getSortValue(b, sortColumn, selectedKind.name)
2770
2880
  let comparison = 0
2771
2881
  if (typeof aVal === 'number' && typeof bVal === 'number') {
2772
2882
  comparison = aVal - bVal
@@ -2951,7 +3061,12 @@ export function ResourcesView({
2951
3061
  if (!resources || resources.length === 0) return null
2952
3062
 
2953
3063
  const kindLower = normalizeKindToPlural(selectedKind.name, selectedKind.group)
2954
- const columns = KNOWN_COLUMNS[kindLower] || DEFAULT_COLUMNS
3064
+ // Iterate over allColumns (built-ins + injected extras) so an
3065
+ // ExtraColumn with getFilterValue gets a column-filter dropdown like
3066
+ // any built-in does. The previous formulation iterated KNOWN_COLUMNS
3067
+ // directly, which silently skipped extras whose keys weren't already
3068
+ // in the built-in set.
3069
+ const columns = allColumns
2955
3070
 
2956
3071
  // Auto-detect filterable columns
2957
3072
  const filterableColumns: Array<{
@@ -2963,10 +3078,16 @@ export function ResourcesView({
2963
3078
  for (const col of columns) {
2964
3079
  if (SKIP_FILTER_COLUMNS.has(col.key)) continue
2965
3080
 
3081
+ // Extra columns supply their own filter value extractor.
3082
+ // Skip the dropdown for extras that didn't supply one (no way to
3083
+ // build the unique-values list without it).
3084
+ const extra = extraColumnsByKey.get(col.key)
3085
+ if (extra && !extra.getFilterValue) continue
3086
+
2966
3087
  // Count distinct values for this column
2967
3088
  const valueCounts: Record<string, number> = {}
2968
3089
  for (const r of resources) {
2969
- const val = getCellFilterValue(r, col.key, kindLower)
3090
+ const val = extra?.getFilterValue ? extra.getFilterValue(r) : getCellFilterValue(r, col.key, kindLower)
2970
3091
  if (val) {
2971
3092
  valueCounts[val] = (valueCounts[val] || 0) + 1
2972
3093
  }
@@ -3052,7 +3173,7 @@ export function ResourcesView({
3052
3173
 
3053
3174
  if (filterableColumns.length === 0 && !problems && labelValues.length === 0) return null
3054
3175
  return { columns: filterableColumns, problems, labels: labelValues }
3055
- }, [resources, selectedKind.name])
3176
+ }, [resources, selectedKind.name, selectedKind.group, allColumns])
3056
3177
 
3057
3178
  // Map filterable columns by key for O(1) lookup in header rendering
3058
3179
  const filterableColumnMap = useMemo(() => {
@@ -3090,17 +3211,18 @@ export function ResourcesView({
3090
3211
  ? existing.filter(p => p !== pair)
3091
3212
  : [...existing, pair]
3092
3213
  const newSelector = newLabels.join(',')
3093
- // Sync to URL
3214
+ // Sync to URL — route through `navigate` so the onNavigate prop
3215
+ // can suppress the write.
3094
3216
  const params = new URLSearchParams(window.location.search)
3095
3217
  if (newSelector) {
3096
3218
  params.set('labels', newSelector)
3097
3219
  } else {
3098
3220
  params.delete('labels')
3099
3221
  }
3100
- window.history.replaceState({}, '', `${window.location.pathname}?${params.toString()}`)
3222
+ navigate({ pathname: window.location.pathname, search: params.toString() }, { replace: true })
3101
3223
  return newSelector
3102
3224
  })
3103
- }, [])
3225
+ }, [navigate])
3104
3226
 
3105
3227
  // Parse active label pairs for display
3106
3228
  const activeLabelPairs = useMemo(() => {
@@ -3170,9 +3292,7 @@ export function ResourcesView({
3170
3292
  // Defer to next frame so the highlight renders before we open
3171
3293
  requestAnimationFrame(() => {
3172
3294
  const res = highlightedResourceRef.current ?? filteredResources[0]
3173
- if (res?.metadata?.name) {
3174
- onResourceClick?.({ kind: selectedKind.name, namespace: res.metadata.namespace || '', name: res.metadata.name, group: selectedKind.group })
3175
- }
3295
+ selectResource(res)
3176
3296
  })
3177
3297
  } else if (e.key === 'Escape') {
3178
3298
  searchInputRef.current?.blur()
@@ -3322,10 +3442,12 @@ export function ResourcesView({
3322
3442
  onClick={() => {
3323
3443
  setOwnerKind('')
3324
3444
  setOwnerName('')
3445
+ // Route through `navigate` so onNavigate-suppressing hosts
3446
+ // can opt out of the URL write.
3325
3447
  const params = new URLSearchParams(window.location.search)
3326
3448
  params.delete('ownerKind')
3327
3449
  params.delete('ownerName')
3328
- window.history.replaceState({}, '', `${window.location.pathname}?${params.toString()}`)
3450
+ navigate({ pathname: window.location.pathname, search: params.toString() }, { replace: true })
3329
3451
  }}
3330
3452
  className="hover:text-theme-text-primary"
3331
3453
  >
@@ -3676,14 +3798,12 @@ export function ResourcesView({
3676
3798
  kind={selectedKind.name}
3677
3799
  group={selectedKind.group}
3678
3800
  columns={columns}
3801
+ extraColumnsByKey={extraColumnsByKey}
3679
3802
  hasSpacerColumn={hasResizedColumns}
3680
3803
  isSelected={isSelected}
3681
3804
  isHighlighted={isHighlighted}
3682
3805
  majorityNodeMinorVersion={majorityNodeMinorVersion}
3683
- onClick={() => {
3684
- const res = { kind: selectedKind.name, namespace: resource.metadata?.namespace || '', name: resource.metadata?.name, group: selectedKind.group }
3685
- onResourceClick?.(isSelected ? null : res)
3686
- }}
3806
+ onClick={() => selectResource(resource, isSelected)}
3687
3807
  onMouseEnter={() => setHighlightedIndex(-1)}
3688
3808
  />
3689
3809
  )
@@ -3711,6 +3831,7 @@ interface ResourceRowCellsProps {
3711
3831
  kind: string
3712
3832
  group?: string
3713
3833
  columns: Column[]
3834
+ extraColumnsByKey?: Map<string, ExtraColumn>
3714
3835
  hasSpacerColumn: boolean
3715
3836
  isSelected?: boolean
3716
3837
  isHighlighted?: boolean
@@ -3719,7 +3840,7 @@ interface ResourceRowCellsProps {
3719
3840
  onMouseEnter?: () => void
3720
3841
  }
3721
3842
 
3722
- function ResourceRowCells({ resource, kind, group, columns, hasSpacerColumn, isSelected, isHighlighted, majorityNodeMinorVersion, onClick, onMouseEnter }: ResourceRowCellsProps) {
3843
+ function ResourceRowCells({ resource, kind, group, columns, extraColumnsByKey, hasSpacerColumn, isSelected, isHighlighted, majorityNodeMinorVersion, onClick, onMouseEnter }: ResourceRowCellsProps) {
3723
3844
  return (
3724
3845
  <>
3725
3846
  {columns.map((col) => (
@@ -3737,7 +3858,7 @@ function ResourceRowCells({ resource, kind, group, columns, hasSpacerColumn, isS
3737
3858
  : 'group-hover/row:bg-theme-surface/50'
3738
3859
  )}
3739
3860
  >
3740
- <CellContent resource={resource} kind={kind} group={group} column={col.key} majorityNodeMinorVersion={majorityNodeMinorVersion} />
3861
+ <CellContent resource={resource} kind={kind} group={group} column={col.key} majorityNodeMinorVersion={majorityNodeMinorVersion} extraColumn={extraColumnsByKey?.get(col.key)} />
3741
3862
  </td>
3742
3863
  ))}
3743
3864
  {hasSpacerColumn && <td className="border-b-subtle p-0" />}
@@ -3777,9 +3898,19 @@ interface CellContentProps {
3777
3898
  column: string
3778
3899
  group?: string
3779
3900
  majorityNodeMinorVersion?: string
3901
+ /** When provided, the parent has injected an ExtraColumn for this
3902
+ * column key. Render via the extra's render() and short-circuit
3903
+ * the built-in cell logic. */
3904
+ extraColumn?: ExtraColumn
3780
3905
  }
3781
3906
 
3782
- function CellContent({ resource, kind, column, group, majorityNodeMinorVersion }: CellContentProps) {
3907
+ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion, extraColumn }: CellContentProps) {
3908
+ // Parent-injected extra columns short-circuit the built-in switch.
3909
+ // Used by hosts that inject leading columns (e.g. a multi-cluster Cluster column).
3910
+ if (extraColumn) {
3911
+ return <>{extraColumn.render(resource)}</>
3912
+ }
3913
+
3783
3914
  const meta = resource.metadata || {}
3784
3915
 
3785
3916
  // Common columns
@@ -4291,7 +4422,7 @@ function PodCell({ resource, column }: { resource: any; column: string }) {
4291
4422
  {sq.restarts > 0 && (
4292
4423
  <div className={clsx('border-t border-theme-border/50 pt-1 space-y-0.5', restartColor)}>
4293
4424
  <div className="flex items-center gap-1.5">
4294
- <span>{sq.restarts} restart{sq.restarts !== 1 ? 's' : ''}</span>
4425
+ <span>{pluralize(sq.restarts, 'restart')}</span>
4295
4426
  {lt?.finishedAt && <span className="text-theme-text-tertiary">· last {timeAgo(lt.finishedAt)}</span>}
4296
4427
  </div>
4297
4428
  {lt?.reason && (
@@ -1,5 +1,6 @@
1
1
  import { Database, HardDrive, Activity, Clock, Shield, KeyRound } from 'lucide-react'
2
2
  import { Section, PropertyList, Property, ConditionsSection, AlertBanner, ResourceLink } from '../../ui/drawer-components'
3
+ import { pluralize } from '../../../utils/pluralize'
3
4
  import {
4
5
  getCNPGClusterInstances,
5
6
  getCNPGClusterPhase,
@@ -118,14 +119,14 @@ export function CNPGClusterRenderer({ data, onNavigate }: CNPGClusterRendererPro
118
119
  <AlertBanner
119
120
  variant="error"
120
121
  title="Certificate Expired"
121
- items={expiredCerts.map(c => `${c.secretName} expired ${Math.abs(c.daysUntilExpiry)} day${Math.abs(c.daysUntilExpiry) === 1 ? '' : 's'} ago (${c.expiryDate})`)}
122
+ items={expiredCerts.map(c => `${c.secretName} expired ${pluralize(Math.abs(c.daysUntilExpiry), 'day')} ago (${c.expiryDate})`)}
122
123
  />
123
124
  )}
124
125
  {criticalCerts.length > 0 && (
125
126
  <AlertBanner
126
127
  variant="error"
127
128
  title="Certificate Expiring Soon"
128
- items={criticalCerts.map(c => `${c.secretName} expires in ${c.daysUntilExpiry} day${c.daysUntilExpiry === 1 ? '' : 's'} (${c.expiryDate})`)}
129
+ items={criticalCerts.map(c => `${c.secretName} expires in ${pluralize(c.daysUntilExpiry, 'day')} (${c.expiryDate})`)}
129
130
  />
130
131
  )}
131
132
  {warningCerts.length > 0 && (
@@ -1,6 +1,7 @@
1
1
  import { Shield, Clock, Globe, Key } from 'lucide-react'
2
2
  import { clsx } from 'clsx'
3
3
  import { Section, PropertyList, Property, ConditionsSection, AlertBanner } from '../../ui/drawer-components'
4
+ import { pluralize } from '../../../utils/pluralize'
4
5
 
5
6
  interface CertificateRendererProps {
6
7
  data: any
@@ -87,7 +88,7 @@ export function CertificateRenderer({ data }: CertificateRendererProps) {
87
88
  {expiresWithin7Days && (
88
89
  <AlertBanner
89
90
  variant="error"
90
- title={`Certificate expires in ${daysUntilExpiry} day${daysUntilExpiry !== 1 ? 's' : ''}`}
91
+ title={`Certificate expires in ${pluralize(daysUntilExpiry, 'day')}`}
91
92
  message="Check that cert-manager is renewing this certificate."
92
93
  />
93
94
  )}
@@ -95,7 +96,7 @@ export function CertificateRenderer({ data }: CertificateRendererProps) {
95
96
  {expiresWithin30Days && (
96
97
  <AlertBanner
97
98
  variant="warning"
98
- title={`Certificate expires in ${daysUntilExpiry} day${daysUntilExpiry !== 1 ? 's' : ''}`}
99
+ title={`Certificate expires in ${pluralize(daysUntilExpiry, 'day')}`}
99
100
  message="Renewal should happen automatically before expiry."
100
101
  />
101
102
  )}
@@ -103,7 +104,7 @@ export function CertificateRenderer({ data }: CertificateRendererProps) {
103
104
  {failedIssuanceAttempts > 0 && (
104
105
  <AlertBanner
105
106
  variant="warning"
106
- title={`${failedIssuanceAttempts} failed issuance attempt${failedIssuanceAttempts !== 1 ? 's' : ''}`}
107
+ title={pluralize(failedIssuanceAttempts, 'failed issuance attempt')}
107
108
  message={lastFailureTime ? `Last failure: ${formatDate(lastFailureTime)}` : undefined}
108
109
  />
109
110
  )}
@@ -4,6 +4,7 @@ import { clsx } from 'clsx'
4
4
  import { Section, PropertyList, Property, AlertBanner } from '../../ui/drawer-components'
5
5
  import { formatAge } from '../resource-utils'
6
6
  import { SEVERITY_BADGE_COLORS, SEVERITY_ORDER } from './trivy-shared'
7
+ import { pluralize } from '../../../utils/pluralize'
7
8
 
8
9
  interface ClusterComplianceReportRendererProps {
9
10
  data: any
@@ -77,7 +78,7 @@ export function ClusterComplianceReportRenderer({ data }: ClusterComplianceRepor
77
78
  <AlertBanner
78
79
  variant="error"
79
80
  icon={XCircle}
80
- title={`${failCount} control${failCount !== 1 ? 's' : ''} failed`}
81
+ title={`${pluralize(failCount, 'control')} failed`}
81
82
  message="Review failed controls to improve cluster compliance."
82
83
  />
83
84
  )}
@@ -7,6 +7,7 @@ import {
7
7
  getClusterExternalSecretNamespaces,
8
8
  getClusterExternalSecretNamespaceSelector,
9
9
  } from '../resource-utils-eso'
10
+ import { pluralize } from '../../../utils/pluralize'
10
11
 
11
12
  interface ClusterExternalSecretRendererProps {
12
13
  data: any
@@ -38,7 +39,7 @@ export function ClusterExternalSecretRenderer({ data, onNavigate }: ClusterExter
38
39
  {failedNamespaces.length > 0 && (
39
40
  <AlertBanner
40
41
  variant="error"
41
- title={`Failed in ${failedNamespaces.length} namespace${failedNamespaces.length > 1 ? 's' : ''}`}
42
+ title={`Failed in ${pluralize(failedNamespaces.length, 'namespace')}`}
42
43
  message="ExternalSecret provisioning failed in one or more namespaces."
43
44
  items={failedNamespaces.map(f => `${f.namespace}${f.reason ? `: ${f.reason}` : ''}`)}
44
45
  />
@@ -48,9 +49,9 @@ export function ClusterExternalSecretRenderer({ data, onNavigate }: ClusterExter
48
49
  <Section title="Overview" icon={Globe} defaultExpanded>
49
50
  <PropertyList>
50
51
  <Property label="Status" value={cesStatus.text} />
51
- <Property label="Provisioned" value={`${provisionedNamespaces.length} namespace${provisionedNamespaces.length !== 1 ? 's' : ''}`} />
52
+ <Property label="Provisioned" value={pluralize(provisionedNamespaces.length, 'namespace')} />
52
53
  {failedNamespaces.length > 0 && (
53
- <Property label="Failed" value={`${failedNamespaces.length} namespace${failedNamespaces.length !== 1 ? 's' : ''}`} />
54
+ <Property label="Failed" value={pluralize(failedNamespaces.length, 'namespace')} />
54
55
  )}
55
56
  </PropertyList>
56
57
  </Section>
@@ -4,6 +4,7 @@ import { clsx } from 'clsx'
4
4
  import { Section, PropertyList, Property } from '../../ui/drawer-components'
5
5
  import { formatAge } from '../resource-utils'
6
6
  import { SEVERITY_BADGE_COLORS, TrivyAlertBanner, formatTrivyImage } from './trivy-shared'
7
+ import { pluralize } from '../../../utils/pluralize'
7
8
 
8
9
  interface ExposedSecretReportRendererProps {
9
10
  data: any
@@ -72,7 +73,7 @@ export function ExposedSecretReportRenderer({ data }: ExposedSecretReportRendere
72
73
  className="flex items-center gap-1 text-xs text-theme-text-secondary hover:text-theme-text-primary mb-2"
73
74
  >
74
75
  {expanded ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
75
- {secrets.length} secret{secrets.length !== 1 ? 's' : ''}
76
+ {pluralize(secrets.length, 'secret')}
76
77
  </button>
77
78
  {expanded && (
78
79
  <div className="overflow-x-auto -mx-1">
@@ -2,6 +2,7 @@ import { Container, Settings } from 'lucide-react'
2
2
  import { clsx } from 'clsx'
3
3
  import { Section, PropertyList, Property, ConditionsSection, KnativeNotReadyBanner } from '../../ui/drawer-components'
4
4
  import { getRevisionStatus } from '../resource-utils-knative'
5
+ import { pluralize } from '../../../utils/pluralize'
5
6
 
6
7
  interface KnativeRevisionRendererProps {
7
8
  data: any
@@ -82,7 +83,7 @@ export function KnativeRevisionRenderer({ data }: KnativeRevisionRendererProps)
82
83
  )}
83
84
  {c.env && c.env.length > 0 && (
84
85
  <div className="text-xs text-theme-text-tertiary mt-1">
85
- Env: {c.env.length} variable{c.env.length !== 1 ? 's' : ''}
86
+ Env: {pluralize(c.env.length, 'variable')}
86
87
  </div>
87
88
  )}
88
89
  </div>
@@ -14,6 +14,7 @@ import {
14
14
  getKyvernoPolicyRuleCountByType,
15
15
  getKyvernoPolicyAutogenRules,
16
16
  } from '../resource-utils-kyverno'
17
+ import { pluralize } from '../../../utils/pluralize'
17
18
 
18
19
  // ============================================================================
19
20
  // PolicyReport / ClusterPolicyReport Renderer
@@ -112,7 +113,7 @@ export function PolicyReportRenderer({ data }: PolicyReportRendererProps) {
112
113
  {summary.fail > 0 && (
113
114
  <AlertBanner
114
115
  variant="error"
115
- title={`${summary.fail} policy check${summary.fail > 1 ? 's' : ''} failed`}
116
+ title={`${pluralize(summary.fail, 'policy check')} failed`}
116
117
  message={failResults.length <= 3
117
118
  ? failResults.map((r: any) => `${r.policy}${r.rule ? '/' + r.rule : ''}: ${r.message || 'failed'}`).join('; ')
118
119
  : `${failResults.slice(0, 2).map((r: any) => r.policy).join(', ')} and ${failResults.length - 2} more`
@@ -122,7 +123,7 @@ export function PolicyReportRenderer({ data }: PolicyReportRendererProps) {
122
123
  {summary.error > 0 && summary.fail === 0 && (
123
124
  <AlertBanner
124
125
  variant="error"
125
- title={`${summary.error} policy check${summary.error > 1 ? 's' : ''} errored`}
126
+ title={`${pluralize(summary.error, 'policy check')} errored`}
126
127
  message={errorResults.length <= 3
127
128
  ? errorResults.map((r: any) => `${r.policy}: ${r.message || 'error'}`).join('; ')
128
129
  : undefined
@@ -132,7 +133,7 @@ export function PolicyReportRenderer({ data }: PolicyReportRendererProps) {
132
133
  {summary.warn > 0 && summary.fail === 0 && summary.error === 0 && (
133
134
  <AlertBanner
134
135
  variant="warning"
135
- title={`${summary.warn} policy warning${summary.warn > 1 ? 's' : ''}`}
136
+ title={pluralize(summary.warn, 'policy warning')}
136
137
  />
137
138
  )}
138
139